From 92831ee11d0cf3d1d56f4b52a507e4300a3886d8 Mon Sep 17 00:00:00 2001 From: Eltavine Date: Fri, 27 Mar 2026 12:43:48 +0800 Subject: [PATCH 1/2] feat: add Vue WebUI manager and harden Android module behavior Introduce a full WebUI-based management flow for OukaroManager and rework the module runtime so configuration is saved through okrmng and applied reliably during the next boot instead of depending on runtime hot reload. This change turns the project into a complete, documented, test-backed KernelSU module package with a dedicated frontend, stronger Android package introspection, safer config persistence, and a more reproducible CI pipeline. - add a dedicated `webui/` frontend built with Vue 3, TypeScript, Vite, Tailwind CSS, vue-i18n, vue-sonner, and shadcn-style UI primitives - build the frontend directly into `module/webroot/` with relative asset paths and a cleanup step so the output matches the KernelSU WebUI layout - implement a single-page console that loads module state, supports package search, provides mutually exclusive `None` / `System` / `Priv` selection, shows draft statistics, preserves stale config entries, and clearly tells the user that a reboot is required before changes take effect - add bilingual Chinese and English UI copy with automatic locale selection from `navigator.language` and manual language switching in the header - style the WebUI with a high-contrast black-and-white visual system, remove template-like placeholder wording and gradients, and embed the `karo.svg` brand asset into both the WebUI and the repository README - integrate the frontend with the official KernelSU JavaScript API through `exec`, `moduleInfo`, `toast`, and `enableEdgeToEdge`, while also adding compatibility for both `window.ksu` and `window.kernelsu` - make frontend state loading more resilient by allowing failed module metadata lookups to be retried instead of poisoning the cached promise - extend `okrmng` into a WebUI-ready management CLI with `inspect --json` and `replace --system ... --priv ...` commands - return a stable JSON payload that includes configured system packages, configured priv packages, installed user apps, and missing configured apps - validate that a package cannot exist in both groups at the same time and keep package ordering deterministic through sorted `BTreeSet` storage - write `config.toml` atomically through a temporary file and persist only fully written updates to avoid half-written config during save operations - add parser coverage and config persistence tests for CSV handling, JSON shape expectations, duplicate-group rejection, stale package detection, empty config handling, and sorted round-trip serialization - scope app discovery consistently to the Android primary user (`system user` / `user 0`) so WebUI visibility matches actual module behavior on multi-user and work-profile devices - switch package listing to `pm list packages -3 --user 0` and fall back to `packages.xml` plus `package-restrictions.xml` parsing when shell output is unavailable or incomplete - parse package install-state metadata from `/data/system/users/0/package-restrictions*.xml` and filter out packages that are not actually installed for the primary user - document this primary-user-only contract in the WebUI copy and README so the project no longer implies cross-user package management support - rework `oukaro` so saved configuration is applied during boot from `module/post-mount.sh`, which is a better match for KernelSU overlay timing than the previous service-stage approach - leave `service.sh` as an intentional no-op to make it explicit that runtime hot reload is disabled and that reboot is the activation boundary - mount overlayfs only when needed, reuse existing mount points safely, clean unmanaged package directories, and copy package trees through a staging directory before renaming them into place - harden package path discovery by preferring `packages.xml`, supporting both directory and `base.apk` code paths, and falling back to `pm path --user 0` only when necessary - gate package application on confirmed primary-user installation state so packages from other users or stale metadata are not silently mounted - add tests around package path parsing, stale backup handling, invalid XML, package-restrictions parsing, and unmanaged directory cleanup - tighten module packaging and installation behavior by verifying required payload files in `customize.sh` and explicitly fixing executable bits for `oukaro`, `okrmng`, `post-mount.sh`, and `service.sh` - update `module.prop` to describe the actual save-then-reboot workflow and keep `module/.gitignore` focused on generated module artifacts - add a root `.gitignore` and refine `webui/.gitignore` so Rust targets, `module/webroot`, frontend caches, and other generated outputs stay out of version control noise - refresh the README so it matches the real implementation instead of the old simplified description - document the new Vue WebUI, CLI usage, save/reboot behavior, stale-config preservation, WebUI access paths, and the limitations of `/system/priv-app` on modern Android when privileged-permission allowlists are required - include the project SVG logo in the README for consistency with the WebUI - improve CI reproducibility by pinning Node, Rust, and Android NDK inputs instead of scraping the latest NDK release dynamically - add formatting checks and keep Rust tests in the workflow before building Android release binaries and the frontend bundle - make the workflow produce a complete `module/` artifact containing the native binaries and static WebUI assets expected by KernelSU Validation: - `cargo fmt --all --check` in `okrmng` - `cargo fmt --all --check` in `oukaro` - `cargo test` in `okrmng` - `cargo test` in `oukaro` - `npm run build` in `webui` --- .github/workflows/build.yml | 86 +- .gitignore | 12 + README.md | 135 +- module/.gitignore | 4 +- module/customize.sh | 30 +- module/module.prop | 2 +- module/post-mount.sh | 10 + module/service.sh | 13 +- okrmng/Cargo.lock | 333 +- okrmng/Cargo.toml | 3 + okrmng/src/cli.rs | 559 +++- okrmng/src/config.rs | 122 +- okrmng/src/defs.rs | 21 + oukaro/Cargo.lock | 199 +- oukaro/Cargo.toml | 10 +- oukaro/src/defs.rs | 13 +- oukaro/src/main.rs | 69 +- oukaro/src/utils.rs | 658 +++- webui/.gitignore | 26 + webui/.vscode/extensions.json | 3 + webui/README.md | 12 + webui/components.json | 15 + webui/index.html | 13 + webui/package-lock.json | 2723 +++++++++++++++++ webui/package.json | 35 + webui/postcss.config.js | 6 + webui/scripts/clean-webroot.mjs | 9 + webui/src/App.vue | 692 +++++ webui/src/assets/karo.svg | 9 + webui/src/components/ui/alert/Alert.vue | 43 + webui/src/components/ui/alert/alert.ts | 19 + webui/src/components/ui/alert/index.ts | 2 + webui/src/components/ui/badge/Badge.vue | 35 + webui/src/components/ui/badge/badge.ts | 20 + webui/src/components/ui/badge/index.ts | 2 + webui/src/components/ui/button/Button.vue | 40 + webui/src/components/ui/button/button.ts | 29 + webui/src/components/ui/button/index.ts | 2 + webui/src/components/ui/card/Card.vue | 30 + webui/src/components/ui/card/index.ts | 1 + webui/src/components/ui/input/Input.vue | 42 + webui/src/components/ui/input/index.ts | 1 + .../components/ui/radio-group/RadioGroup.vue | 38 + webui/src/components/ui/radio-group/index.ts | 2 + webui/src/components/ui/radio-group/types.ts | 5 + .../components/ui/scroll-area/ScrollArea.vue | 22 + webui/src/components/ui/scroll-area/index.ts | 1 + .../src/components/ui/separator/Separator.vue | 22 + webui/src/components/ui/separator/index.ts | 1 + webui/src/components/ui/toaster/Toaster.vue | 13 + webui/src/components/ui/toaster/index.ts | 1 + webui/src/lib/i18n.ts | 199 ++ webui/src/lib/module-api.ts | 125 + webui/src/lib/types.ts | 17 + webui/src/lib/utils.ts | 6 + webui/src/main.ts | 8 + webui/src/style.css | 63 + webui/tailwind.config.js | 71 + webui/tailwind.config.ts | 71 + webui/tsconfig.app.json | 20 + webui/tsconfig.json | 13 + webui/tsconfig.node.json | 25 + webui/vite.config.ts | 19 + 63 files changed, 6484 insertions(+), 346 deletions(-) create mode 100644 .gitignore create mode 100644 module/post-mount.sh create mode 100644 webui/.gitignore create mode 100644 webui/.vscode/extensions.json create mode 100644 webui/README.md create mode 100644 webui/components.json create mode 100644 webui/index.html create mode 100644 webui/package-lock.json create mode 100644 webui/package.json create mode 100644 webui/postcss.config.js create mode 100644 webui/scripts/clean-webroot.mjs create mode 100644 webui/src/App.vue create mode 100644 webui/src/assets/karo.svg create mode 100644 webui/src/components/ui/alert/Alert.vue create mode 100644 webui/src/components/ui/alert/alert.ts create mode 100644 webui/src/components/ui/alert/index.ts create mode 100644 webui/src/components/ui/badge/Badge.vue create mode 100644 webui/src/components/ui/badge/badge.ts create mode 100644 webui/src/components/ui/badge/index.ts create mode 100644 webui/src/components/ui/button/Button.vue create mode 100644 webui/src/components/ui/button/button.ts create mode 100644 webui/src/components/ui/button/index.ts create mode 100644 webui/src/components/ui/card/Card.vue create mode 100644 webui/src/components/ui/card/index.ts create mode 100644 webui/src/components/ui/input/Input.vue create mode 100644 webui/src/components/ui/input/index.ts create mode 100644 webui/src/components/ui/radio-group/RadioGroup.vue create mode 100644 webui/src/components/ui/radio-group/index.ts create mode 100644 webui/src/components/ui/radio-group/types.ts create mode 100644 webui/src/components/ui/scroll-area/ScrollArea.vue create mode 100644 webui/src/components/ui/scroll-area/index.ts create mode 100644 webui/src/components/ui/separator/Separator.vue create mode 100644 webui/src/components/ui/separator/index.ts create mode 100644 webui/src/components/ui/toaster/Toaster.vue create mode 100644 webui/src/components/ui/toaster/index.ts create mode 100644 webui/src/lib/i18n.ts create mode 100644 webui/src/lib/module-api.ts create mode 100644 webui/src/lib/types.ts create mode 100644 webui/src/lib/utils.ts create mode 100644 webui/src/main.ts create mode 100644 webui/src/style.css create mode 100644 webui/tailwind.config.js create mode 100644 webui/tailwind.config.ts create mode 100644 webui/tsconfig.app.json create mode 100644 webui/tsconfig.json create mode 100644 webui/tsconfig.node.json create mode 100644 webui/vite.config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc0616e..e07fb41 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,76 +14,76 @@ on: workflow_dispatch: env: CARGO_TERM_COLOR: always + NODE_VERSION: "22" + RUST_TOOLCHAIN: stable + ANDROID_NDK_PACKAGE: android-ndk-r27d-linux.zip + ANDROID_NDK_DIR: android-ndk-r27d + ANDROID_NDK_SHA1: 22105e410cf29afcf163760cc95522b9fb981121 jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: webui/package-lock.json - name: Setup NDK run: | - ndk_url=$(wget -qO- https://github.com/android/ndk/releases/latest \ - | grep -oP 'href="\Khttps://dl.google.com/android/repository/android-ndk-[^"]*-linux.zip' \ - | head -n1) - wget -O ndk.zip "$ndk_url" -nv + wget -O ndk.zip "https://dl.google.com/android/repository/${ANDROID_NDK_PACKAGE}" -nv + echo "${ANDROID_NDK_SHA1} ndk.zip" | sha1sum -c - unzip -q ndk.zip -d "$HOME" - mv "$HOME/android-ndk-"* "$HOME/ndk" + mv "$HOME/${ANDROID_NDK_DIR}" "$HOME/ndk" - name: Setup Rust toolchains run: | - rustup default nightly - rustup target add aarch64-linux-android - rustup component add rust-src + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + rustup default "$RUST_TOOLCHAIN" + rustup target add aarch64-linux-android --toolchain "$RUST_TOOLCHAIN" + rustup component add rustfmt --toolchain "$RUST_TOOLCHAIN" - name: Install build dependencies run: | sudo apt-get update -qq sudo apt-get install -y gcc-multilib cargo install --locked cargo-ndk - - name: Build + - name: Check formatting + run: | + cd okrmng + cargo fmt --all --check + cd ../oukaro + cargo fmt --all --check + - name: Test okrmng + run: | + cd okrmng + cargo test + - name: Test oukaro + run: | + cd oukaro + cargo test + - name: Build module binaries run: | export ANDROID_NDK_HOME="$HOME/ndk" export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" cd oukaro cargo ndk -t arm64-v8a build --release cp target/aarch64-linux-android/release/oukaro ../module/ - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: oukaromanager-module - path: module/ - retention-days: 30 - build-okrmng: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup NDK - run: | - ndk_url=$(wget -qO- https://github.com/android/ndk/releases/latest \ - | grep -oP 'href="\Khttps://dl.google.com/android/repository/android-ndk-[^"]*-linux.zip' \ - | head -n1) - wget -O ndk.zip "$ndk_url" -nv - unzip -q ndk.zip -d "$HOME" - mv "$HOME/android-ndk-"* "$HOME/ndk" - - name: Setup Rust toolchains - run: | - rustup default nightly - rustup target add aarch64-linux-android - rustup component add rust-src - - name: Install build dependencies - run: | - sudo apt-get update -qq - sudo apt-get install -y gcc-multilib - cargo install --locked cargo-ndk - - name: Build + - name: Build okrmng run: | export ANDROID_NDK_HOME="$HOME/ndk" export ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" cd okrmng cargo ndk -t arm64-v8a build --release - - name: Upload build artifacts + cp target/aarch64-linux-android/release/okrmng ../module/ + - name: Build WebUI + run: | + cd webui + npm ci + npm run build + - name: Upload module artifact uses: actions/upload-artifact@v4 with: - name: okrmng - path: okrmng/target/aarch64-linux-android/release/okrmng + name: oukaromanager-module + path: module/ retention-days: 30 - \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29867cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/module/oukaro +/module/okrmng +/module/webroot/ +/okrmng/target/ +/oukaro/target/ +/webui/node_modules/ +/webui/dist/ +/webui/.vite/ +*.log +*.tmp +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 7b67cab..55f8f24 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,30 @@ -# 📱 OukaroManager - -[![Build Status](https://github.com/OukaroMF/OukaroManager/workflows/Build%20KernelSU%20Module/badge.svg)](https://github.com/OukaroMF/OukaroManager/actions) -[![License: Anti-996](https://img.shields.io/badge/license-Anti%20996-blue.svg)](https://github.com/kattgu7/Anti-996-License) -[![KernelSU](https://img.shields.io/badge/KernelSU-Compatible-green.svg)](https://github.com/tiann/KernelSU) -[![WebUIX](https://img.shields.io/badge/WebUIX-Compatible-orange.svg)](https://github.com/MMRLApp/WebUI-X-Portable) - -一个KernelSU模块,提供简单的WebUI来将普通Android应用转换为系统应用 — 无需ADB,无需root shell,只需点击。 - -A KernelSU module that provides a simple WebUI to convert regular Android apps to system apps — no ADB, no root shell, just click. +# 📱 OukaroManager + +[![Build Status](https://github.com/OukaroMF/OukaroManager/workflows/Build%20KernelSU%20Module/badge.svg)](https://github.com/OukaroMF/OukaroManager/actions) +[![License: Anti-996](https://img.shields.io/badge/license-Anti%20996-blue.svg)](https://github.com/kattgu7/Anti-996-License) +[![KernelSU](https://img.shields.io/badge/KernelSU-Compatible-green.svg)](https://github.com/tiann/KernelSU) +[![WebUIX](https://img.shields.io/badge/WebUIX-Compatible-orange.svg)](https://github.com/MMRLApp/WebUI-X-Portable) + +![OukaroManager logo](webui/src/assets/karo.svg) + +一个 KernelSU 模块,提供基于 Vue 3 的 WebUI,将主用户(system user / user 0)的普通 Android 应用加入系统应用配置中。WebUI 可搜索主用户应用、切换 `System`/`Priv` 模式、保存配置,并在保存后明确提示重启生效。 + +A KernelSU module with a Vue 3 WebUI for turning regular Android apps installed for the primary Android user (system user / user 0) into system-app config entries. The WebUI lets you search primary-user apps, switch between `System` and `Priv` modes, save config, and then reboot to apply. ## ✨ 功能特性 | Features - 🧱 **将普通应用转换为系统应用** | **Convert regular apps to system apps** - 📁 **支持 `System` 和 `Priv` 两种模式** | **Supports both `System` and `Priv` modes** -- 🌐 **WebUI 兼容界面** — 通过KernelSU Manager、MMRL或WebUIX portable控制 | **WebUI compatible interface** — Control via KernelSU Manager, MMRL or WebUIX portable -- 🛠️ **与KernelSU的挂载系统协同工作**,无需手动重新挂载/system | **Works with KernelSU's bind system**, no manual /system remounting required -- 🌍 **多语言支持** — 支持简体中文和英文 | **Multi-language support** — Supports Simplified Chinese and English +- 🌐 **Vue 3 + shadcn 风格 WebUI** — 在 KernelSU Manager、MMRL 或 WebUIX portable 中使用 | **Vue 3 + shadcn-style WebUI** — Use it in KernelSU Manager, MMRL or WebUIX portable +- 🛠️ **通过模块内 overlayfs 挂载工作**,无需手动重新挂载/system | **Works through module-managed overlayfs mounts**, no manual /system remounting required +- 🔎 **应用搜索与模式切换** — 按包名筛选并逐个选择 `None` / `System` / `Priv` | **Search and mode switching** — Filter by package name and choose `None` / `System` / `Priv` +- 🌍 **多语言支持** — 支持简体中文和英文,并默认跟随浏览器/管理器语言 | **Multi-language support** — Simplified Chinese and English with locale-aware default selection ## 📦 工作原理 | How It Works -该模块使用KernelSU的挂载系统将选定的用户应用注入到系统分区中,模拟它们作为预装应用的行为。 - -This module uses KernelSU's mount system to inject selected user applications into the system partition, simulating their behavior as pre-installed apps. +该模块会在下一次启动的 KernelSU `post-mount` 阶段,通过模块内的 overlayfs 上层目录,将选定主用户应用同步到 `/system/app` 或 `/system/priv-app`,尽量让它们以“预装应用”的方式被系统扫描。 + +This module syncs selected primary-user apps into `/system/app` or `/system/priv-app` during the next boot's KernelSU `post-mount` stage by using module-managed overlayfs upper directories, so Android can scan them like pre-installed apps. ## 🚀 安装 | Installation @@ -32,24 +35,32 @@ This module uses KernelSU's mount system to inject selected user applications in ## 🖥️ 使用方法 | Usage -### Webui -1. 打开KernelSU Manager(如果KernelSU Manager不可用,可使用MMRL/WebUIX portable) | Open KernelSU Manager (if KernelSU Manager is unavailable, use MMRL/WebUIX portable) -2. 导航到OukaroManager模块WebUI | Navigate to OukaroManager module WebUI -3. 选择要转换的应用 | Select the apps you want to convert -4. 在 `System` 或 `Priv` 路径之间选择 | Choose between `System` or `Priv` path -5. 点击转换并在提示时重启 | Click convert and reboot when prompted - -### 手动修改 | Manual -> 配置文件路径在 /data/adb/modules/oukaro_manager/config.toml - -e.g -```toml -[app] -system_app = ["bin.mt.plus"] -priv_app = ["com termux"] -``` - -> 注意 `system_app` 和 `priv_app` 仅能使用应用包名 +### WebUI +1. 打开KernelSU Manager(如果KernelSU Manager不可用,可使用MMRL/WebUIX portable) | Open KernelSU Manager (if KernelSU Manager is unavailable, use MMRL/WebUIX portable) +2. 导航到OukaroManager模块WebUI | Navigate to OukaroManager module WebUI +3. 搜索要处理的主用户应用 | Search for the primary-user apps you want to manage +4. 为每个应用选择 `None`、`System` 或 `Priv` | Choose `None`, `System`, or `Priv` for each app +5. 点击保存配置,并在提示后重启设备 | Save the configuration, then reboot when prompted + +### 手动修改 | Manual +> 配置文件路径在 /data/adb/modules/oukaro_manager/config.toml + +e.g +```toml +[app] +system_app = ["bin.mt.plus"] +priv_app = ["com.termux"] +``` + +> 注意 `system_app` 和 `priv_app` 仅能使用应用包名 + +### CLI +> 模块内会同时打包 `okrmng`,用于 WebUI 和命令行配置管理 + +```sh +okrmng inspect --json +okrmng replace --system "bin.mt.plus" --priv "com.termux" +``` ## ⚠️ 系统要求 | System Requirements @@ -59,11 +70,13 @@ priv_app = ["com termux"] ## 🔧 技术细节 | Technical Details -- 使用KernelSU的挂载系统 | Uses KernelSU's bind system +- 使用模块内 overlayfs 挂载 | Uses module-managed overlayfs mounts - 无直接系统分区修改 | No direct system partition modifications -- 通过模块移除可逆转更改 | Reversible changes through module removal -- 兼容大多数Android版本 | Compatible with most Android versions -- **WebUIX兼容**,可增强模块管理体验 | **WebUIX compatible** for enhanced module management experience +- WebUI 保存只会更新 `config.toml`,需要重启后由模块在 `post-mount` 阶段应用挂载 | Saving in WebUI only updates `config.toml`; the module applies mounts during the next boot's `post-mount` stage +- WebUI 和 `okrmng inspect --json` 的“已安装用户应用”语义固定为主用户(system user / user 0),避免多用户/工作资料夹环境下的范围歧义 | The "installed user apps" view in WebUI and `okrmng inspect --json` is intentionally scoped to the primary Android user (system user / user 0) to avoid ambiguity on multi-user and work-profile devices +- 兼容性以“尽量适配”为目标,具体表现仍取决于 Android 版本、ROM 策略和权限模型 | Compatibility is best-effort and still depends on the Android version, ROM policy, and permission model +- **WebUIX兼容**,可增强模块管理体验 | **WebUIX compatible** for enhanced module management experience +- WebUI 使用 `okrmng inspect --json` 读取状态,使用 `okrmng replace` 原子写回配置 | The WebUI reads state through `okrmng inspect --json` and writes config atomically with `okrmng replace` ## 📱 WebUI访问选项 | WebUI Access Options @@ -85,20 +98,21 @@ This module supports the **WebUIX** standard and can be accessed through multipl ## 🔄 转换模式 | Conversion Modes -### System: `/system/app/` -标准系统应用位置,具有基本系统权限。适合大多数普通应用。 -Standard system app location with basic system privileges. Suitable for most regular apps. - -### Priv: `/system/priv-app/` -特权系统应用位置,具有增强的系统权限。适合需要特殊权限的应用。 -Privileged system app location with enhanced system privileges. Suitable for apps requiring special permissions. +### System: `/system/app/` +标准系统应用位置。适合希望在下次开机时以“预装应用”方式参与系统扫描的大多数普通应用。 +Standard system app location. Suitable for most regular apps that need to be scanned like pre-installed apps on the next boot. + +### Priv: `/system/priv-app/` +特权系统应用位置,但这不等于现代 Android 一定授予特权权限。自 Android 8.0/9 起,很多 ROM 还要求同分区的 `privapp-permissions.xml` allowlist;本模块不会自动生成这些 XML。 +Privileged system app location, but this does not guarantee privileged permissions on modern Android. Since Android 8.0/9, many ROMs still require same-partition `privapp-permissions.xml` allowlists, and this module does not generate those XML files automatically. ## 🛡️ 安全说明 | Security Notes -- 转换应用为系统应用会赋予它们额外的权限 | Converting apps to system apps grants them additional permissions -- 请仅转换您信任的应用 | Only convert apps you trust -- 备份重要数据,以防意外情况 | Backup important data in case of unexpected issues -- 可以随时通过WebUI或移除模块来还原更改 | Changes can be reverted anytime through WebUI or module removal +- 转换应用为系统应用会赋予它们额外的权限 | Converting apps to system apps grants them additional permissions +- 请仅转换您信任的应用 | Only convert apps you trust +- 备份重要数据,以防意外情况 | Backup important data in case of unexpected issues +- `Priv` 模式在严格执行 privileged-permission allowlist 的 ROM 上可能无法达到预期,极端情况下还可能引发启动期兼容性问题 | `Priv` mode may not behave as expected on ROMs that strictly enforce privileged-permission allowlists, and in extreme cases can cause boot-time compatibility issues +- 可以随时通过 WebUI 将应用改回 `None`、保存配置并重启来还原更改 | You can revert changes anytime by switching an app back to `None` in WebUI, saving, and rebooting ## 🐛 故障排除 | Troubleshooting @@ -107,15 +121,20 @@ Privileged system app location with enhanced system privileges. Suitable for app 2. 尝试使用MMRL或WebUIX portable作为替代 | Try using MMRL or WebUIX portable as alternatives 3. 检查模块是否正确安装并启用 | Check if the module is properly installed and enabled -### 应用转换失败 | App Conversion Fails -1. 确保有足够的存储空间 | Ensure sufficient storage space -2. 检查应用是否已经是系统应用 | Check if the app is already a system app -3. 尝试重启设备后再次转换 | Try rebooting the device and converting again - -### 转换后应用无法正常工作 | Apps Not Working After Conversion -1. 尝试将应用还原为用户应用 | Try reverting the app back to user app -2. 清除应用数据和缓存 | Clear app data and cache -3. 检查应用是否与您的Android版本兼容 | Check if the app is compatible with your Android version +### 应用转换失败 | App Conversion Fails +1. 确保有足够的存储空间 | Ensure sufficient storage space +2. 检查应用是否已经是系统应用 | Check if the app is already a system app +3. 确认已经点击“保存配置”,然后重启设备再检查结果 | Make sure you saved the config, then reboot the device and check again + +### 转换后应用无法正常工作 | Apps Not Working After Conversion +1. 在 WebUI 中将应用切回 `None`,保存后重启 | Switch the app back to `None` in WebUI, save, and reboot +2. 清除应用数据和缓存 | Clear app data and cache +3. 检查应用是否与您的Android版本兼容 | Check if the app is compatible with your Android version + +### WebUI显示失效配置 | WebUI Shows Stale Configuration +1. 这表示某些已配置包名不再属于当前主用户应用列表 | This means some configured package names are no longer present for the primary Android user +2. WebUI 保存时会保留这些条目,不会自动丢失 | The WebUI preserves those entries when saving +3. 如需移除,请通过 WebUI 重新选择有效包,或手动编辑 `config.toml` | Remove them by selecting valid packages in WebUI or editing `config.toml` manually ## 🤝 贡献 | Contributing diff --git a/module/.gitignore b/module/.gitignore index 60c1bed..4f7b469 100644 --- a/module/.gitignore +++ b/module/.gitignore @@ -1 +1,3 @@ -oukaro \ No newline at end of file +/oukaro +/okrmng +/webroot/ diff --git a/module/customize.sh b/module/customize.sh index 3db1e15..89a4e65 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -1,9 +1,27 @@ SKIPUNZIP=1 -ui_print "安装中" -sleep 0.1 -ui_print "解压文件中" -unzip -o "$ZIPFILE" -x "META-INF/*" -d "$MODPATH" +require_path() { + [ -e "$1" ] || abort "! Missing required module file: ${1#$MODPATH/}" +} -set_perm_recursive $MODPATH 0 0 0755 0644 -set_perm $MODPATH/oukaro 0 0 0755 +ui_print "- Installing OukaroManager" +ui_print "- Unpacking module payload" +if ! unzip -o "$ZIPFILE" -x "META-INF/*" -d "$MODPATH" >/dev/null; then + abort "! Failed to unzip module payload" +fi + +ui_print "- Verifying payload" +require_path "$MODPATH/module.prop" +require_path "$MODPATH/skip_mount" +require_path "$MODPATH/oukaro" +require_path "$MODPATH/okrmng" +require_path "$MODPATH/post-mount.sh" +require_path "$MODPATH/service.sh" +require_path "$MODPATH/webroot/index.html" + +ui_print "- Setting permissions" +set_perm_recursive "$MODPATH" 0 0 0755 0644 +set_perm "$MODPATH/oukaro" 0 0 0755 +set_perm "$MODPATH/okrmng" 0 0 0755 +set_perm "$MODPATH/post-mount.sh" 0 0 0755 +set_perm "$MODPATH/service.sh" 0 0 0755 diff --git a/module/module.prop b/module/module.prop index f1d4272..790b519 100644 --- a/module/module.prop +++ b/module/module.prop @@ -3,4 +3,4 @@ name=OukaroManager version=v1.0.0 versionCode=1 author=GitHub.com/OukaroMF/OukaroManager -description=A KernelSU module with WebUI to convert regular apps into system apps. Supports both /system/app/ and /system/priv-app/ modes. +description=KernelSU WebUI module for staging primary-user apps into /system/app or /system/priv-app. Save config, then reboot to apply. diff --git a/module/post-mount.sh b/module/post-mount.sh new file mode 100644 index 0000000..4d7befe --- /dev/null +++ b/module/post-mount.sh @@ -0,0 +1,10 @@ +#!/system/bin/sh + +MODDIR=${0%/*} +LOG=$MODDIR/oukaro.log + +until [ -d "$MODDIR" ]; do + sleep 1 +done + +RUST_BACKTRACE=1 "$MODDIR/oukaro" >>"$LOG" 2>&1 diff --git a/module/service.sh b/module/service.sh index b40d744..a6001a3 100755 --- a/module/service.sh +++ b/module/service.sh @@ -1,14 +1,11 @@ -#!/bin/sh - -# OukaroManager Service Script -# Runs in background to maintain apps.json accuracy -# Auto-generates configuration on boot and periodically updates +#!/system/bin/sh MODDIR=${0%/*} -LOG=$MODDIR/oukaro.log -until [ -d $MODDIR ]; do +until [ -d "$MODDIR" ]; do sleep 1 done -RUST_BACKTRACE=1 nohup $MODDIR/oukaro >$LOG 2>&1 & +# Runtime hot-reload is intentionally disabled. +# The next reboot's post-mount stage applies the saved config. +exit 0 diff --git a/okrmng/Cargo.lock b/okrmng/Cargo.lock index 1010f4f..67d53c8 100644 --- a/okrmng/Cargo.lock +++ b/okrmng/Cargo.lock @@ -58,6 +58,18 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.5.51" @@ -110,6 +122,50 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -122,6 +178,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.12.0" @@ -129,7 +191,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -138,22 +202,77 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + [[package]] name = "okrmng" version = "0.1.0" dependencies = [ "anyhow", "clap", + "quick-xml", "serde", + "serde_json", + "tempfile", "toml", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -163,6 +282,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.42" @@ -172,6 +300,31 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -202,6 +355,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -228,6 +394,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "toml" version = "0.9.8" @@ -273,12 +452,70 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -299,3 +536,97 @@ name = "winnow" version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/okrmng/Cargo.toml b/okrmng/Cargo.toml index b4973a1..747be22 100644 --- a/okrmng/Cargo.toml +++ b/okrmng/Cargo.toml @@ -6,5 +6,8 @@ edition = "2024" [dependencies] anyhow = "1.0.100" clap = { version = "4.5.51", features = ["derive"] } +quick-xml = "0.38.3" serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +tempfile = "3.23.0" toml = "0.9.8" diff --git a/okrmng/src/cli.rs b/okrmng/src/cli.rs index 2a4b040..2c65bb3 100644 --- a/okrmng/src/cli.rs +++ b/okrmng/src/cli.rs @@ -1,9 +1,18 @@ -use std::fs; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + process::Command, +}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use clap::{Parser, Subcommand}; +use quick_xml::{Reader, events::Event}; +use serde::Serialize; -use crate::{config, defs::CONFIG_PATH}; +use crate::config::{App, Config}; +use crate::defs::{PACKAGES_XML_PATHS, SYSTEM_USER_ID, SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS}; + +const APPLICATION_INFO_FLAG_SYSTEM: i64 = 1 << 0; #[derive(Parser)] #[command(author, version = "0.1", about, long_about = None)] @@ -46,18 +55,38 @@ enum Commands { #[command(subcommand)] command: PrivApp, }, + Inspect { + #[arg(long, default_value_t = false)] + json: bool, + }, + Replace { + #[arg(long, default_value = "")] + system: String, + #[arg(long = "priv", default_value = "")] + priv_app: String, + }, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct InspectOutput { + system_app: Vec, + priv_app: Vec, + installed_user_apps: Vec, + missing_configured_apps: Vec, } pub fn run() -> Result<()> { let args = Args::parse(); - let mut config = config::Config::new()?; match args.command { Commands::PrivApp { command } => { println!("setting priv-app"); + let mut config = Config::new()?; match command { PrivApp::Add { package } => { + ensure_not_in_other_group(&config.app.system_app, &package, "system-app")?; config.app.priv_app.insert(package); println!("added new package"); } @@ -66,13 +95,16 @@ pub fn run() -> Result<()> { println!("removed package"); } } + + config.save()?; } Commands::SystemApp { command } => { println!("setting system-app"); - - + let mut config = Config::new()?; + match command { SystemApp::Add { package } => { + ensure_not_in_other_group(&config.app.priv_app, &package, "priv-app")?; config.app.system_app.insert(package); println!("added new package"); } @@ -81,13 +113,520 @@ pub fn run() -> Result<()> { println!("removed package"); } } + + config.save()?; } + Commands::Inspect { json } => { + let config = Config::new()?; + let installed_user_apps = list_installed_user_apps()?; + let inspect = build_inspect_output(&config.app, &installed_user_apps); + + if json { + println!( + "{}", + serde_json::to_string(&inspect) + .context("Failed to serialize inspect output")? + ); + } else { + println!("system_app={}", inspect.system_app.join(",")); + println!("priv_app={}", inspect.priv_app.join(",")); + println!( + "installed_user_apps={}", + inspect.installed_user_apps.join(",") + ); + println!( + "missing_configured_apps={}", + inspect.missing_configured_apps.join(",") + ); + } + } + Commands::Replace { system, priv_app } => { + let mut config = Config::new()?; + let system_packages = parse_package_csv(&system); + let priv_packages = parse_package_csv(&priv_app); + + validate_package_sets(&system_packages, &priv_packages)?; + + config.app.system_app = system_packages; + config.app.priv_app = priv_packages; + config.save()?; + println!("replaced package configuration"); + } + } + + Ok(()) +} + +fn ensure_not_in_other_group( + existing_packages: &BTreeSet, + package: &str, + group_name: &str, +) -> Result<()> { + if existing_packages.contains(package) { + bail!("Package `{package}` already exists in {group_name}"); } - fs::write( - CONFIG_PATH, - toml::to_string(&config).context("Failed to change config")?, - )?; + Ok(()) +} + +fn parse_package_csv(input: &str) -> BTreeSet { + input + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn validate_package_sets( + system_packages: &BTreeSet, + priv_packages: &BTreeSet, +) -> Result<()> { + let duplicates: Vec<_> = system_packages + .intersection(priv_packages) + .cloned() + .collect(); + + if !duplicates.is_empty() { + bail!( + "Packages cannot exist in both system and priv groups: {}", + duplicates.join(", ") + ); + } Ok(()) } + +fn list_installed_user_apps() -> Result> { + match list_installed_user_apps_from_pm() { + Ok(packages) => Ok(packages), + Err(pm_error) => list_installed_user_apps_from_packages_xml().with_context(|| { + format!( + "Failed to list installed user apps for system user {SYSTEM_USER_ID} via `pm list packages -3 --user {SYSTEM_USER_ID}` and package metadata fallback: {pm_error}" + ) + }), + } +} + +fn list_installed_user_apps_from_pm() -> Result> { + let output = Command::new("pm") + .args(["list", "packages", "-3", "--user", SYSTEM_USER_ID]) + .output() + .with_context(|| { + format!("Failed to execute `pm list packages -3 --user {SYSTEM_USER_ID}`") + })?; + + if !output.status.success() { + bail!( + "`pm list packages -3 --user {SYSTEM_USER_ID}` failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_pm_list_output(&stdout)) +} + +fn list_installed_user_apps_from_packages_xml() -> Result> { + let packages = list_known_user_apps_from_packages_xml()?; + if packages.is_empty() { + return Ok(packages); + } + + let system_user_package_states = read_system_user_package_states()?.ok_or_else(|| { + anyhow!( + "No readable package-restrictions metadata was found for system user {SYSTEM_USER_ID}" + ) + })?; + + Ok(filter_installed_for_system_user( + packages, + &system_user_package_states, + )) +} + +fn list_known_user_apps_from_packages_xml() -> Result> { + let mut last_error = None; + + for packages_xml in PACKAGES_XML_PATHS { + let path = Path::new(packages_xml); + if !path.exists() { + continue; + } + + match read_installed_user_apps_from_packages_xml(path) { + Ok(packages) => return Ok(packages), + Err(error) => { + last_error = Some(error); + } + } + } + + match last_error { + Some(error) => Err(error), + None => Ok(BTreeSet::new()), + } +} + +fn read_system_user_package_states() -> Result>> { + let mut last_error = None; + + for restrictions_xml in SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS { + let path = Path::new(restrictions_xml); + if !path.exists() { + continue; + } + + match read_package_states_from_restrictions_file(path) { + Ok(states) => return Ok(Some(states)), + Err(error) => { + last_error = Some(error); + } + } + } + + match last_error { + Some(error) => Err(error), + None => Ok(None), + } +} + +fn read_installed_user_apps_from_packages_xml(path: &Path) -> Result> { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + parse_packages_xml_user_apps(&contents) + .with_context(|| format!("Failed to parse {}", path.display())) +} + +fn read_package_states_from_restrictions_file(path: &Path) -> Result> { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + parse_package_restrictions_xml(&contents) + .with_context(|| format!("Failed to parse {}", path.display())) +} + +fn parse_pm_list_output(stdout: &str) -> BTreeSet { + stdout + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix("package:")) + .map(str::trim) + .filter(|package| !package.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn parse_packages_xml_user_apps(contents: &str) -> Result> { + let mut reader = Reader::from_str(contents); + reader.config_mut().trim_text(true); + let mut packages = BTreeSet::new(); + + loop { + match reader.read_event()? { + Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"package" => { + let mut package_name = None; + let mut code_path = None; + let mut public_flags = None; + let mut legacy_system = None; + + for attribute in event.attributes().with_checks(false) { + let attribute = attribute?; + let value = attribute + .decode_and_unescape_value(reader.decoder())? + .into_owned(); + + match attribute.key.as_ref() { + b"name" => package_name = Some(value), + b"codePath" => code_path = Some(value), + b"publicFlags" => public_flags = value.parse::().ok(), + b"system" => legacy_system = Some(value.eq_ignore_ascii_case("true")), + _ => {} + } + } + + if let (Some(package_name), Some(code_path)) = (package_name, code_path) { + if is_user_app(public_flags, legacy_system, &code_path) { + packages.insert(package_name); + } + } + } + Event::Eof => return Ok(packages), + _ => {} + } + } +} + +fn parse_package_restrictions_xml(contents: &str) -> Result> { + let mut reader = Reader::from_str(contents); + reader.config_mut().trim_text(true); + let mut states = BTreeMap::new(); + + loop { + match reader.read_event()? { + Event::Start(event) | Event::Empty(event) + if event.name().as_ref() == b"pkg" || event.name().as_ref() == b"package" => + { + let mut package_name = None; + let mut installed = true; + + for attribute in event.attributes().with_checks(false) { + let attribute = attribute?; + let value = attribute + .decode_and_unescape_value(reader.decoder())? + .into_owned(); + + match attribute.key.as_ref() { + b"name" => package_name = Some(value), + b"inst" | b"installed" => installed = !value.eq_ignore_ascii_case("false"), + _ => {} + } + } + + if let Some(package_name) = package_name { + states.insert(package_name, installed); + } + } + Event::Eof => return Ok(states), + _ => {} + } + } +} + +fn is_user_app(public_flags: Option, legacy_system: Option, code_path: &str) -> bool { + if let Some(public_flags) = public_flags { + return public_flags & APPLICATION_INFO_FLAG_SYSTEM == 0; + } + + if let Some(legacy_system) = legacy_system { + return !legacy_system; + } + + code_path.starts_with("/data/app/") + || (code_path.starts_with("/mnt/expand/") && code_path.contains("/app/")) +} + +fn filter_installed_for_system_user( + packages: BTreeSet, + system_user_package_states: &BTreeMap, +) -> BTreeSet { + packages + .into_iter() + .filter(|package| { + system_user_package_states + .get(package) + .copied() + .unwrap_or(true) + }) + .collect() +} + +fn build_inspect_output(config: &App, installed_user_apps: &BTreeSet) -> InspectOutput { + let configured: BTreeSet = config.system_app.union(&config.priv_app).cloned().collect(); + + let missing_configured_apps = configured + .difference(installed_user_apps) + .cloned() + .collect(); + + InspectOutput { + system_app: config.system_app.iter().cloned().collect(), + priv_app: config.priv_app.iter().cloned().collect(), + installed_user_apps: installed_user_apps.iter().cloned().collect(), + missing_configured_apps, + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use super::{ + App, InspectOutput, build_inspect_output, filter_installed_for_system_user, + parse_package_csv, parse_package_restrictions_xml, parse_packages_xml_user_apps, + parse_pm_list_output, read_installed_user_apps_from_packages_xml, validate_package_sets, + }; + + #[test] + fn csv_parser_trims_entries_and_ignores_empty_values() { + let parsed = parse_package_csv(" com.example.alpha ,,com.example.beta, "); + + assert_eq!( + parsed, + BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.beta".to_string(), + ]) + ); + } + + #[test] + fn pm_list_parser_extracts_user_packages() { + let parsed = parse_pm_list_output( + "package:com.example.alpha\npackage:com.example.beta\nignored-line\npackage:\n", + ); + + assert_eq!( + parsed, + BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.beta".to_string(), + ]) + ); + } + + #[test] + fn packages_xml_parser_extracts_non_system_packages() { + let parsed = parse_packages_xml_user_apps( + r#" + + + + + + + "#, + ) + .unwrap(); + + assert_eq!( + parsed, + BTreeSet::from([ + "com.example.adopted".to_string(), + "com.example.legacy".to_string(), + "com.example.user".to_string(), + ]) + ); + } + + #[test] + fn package_restrictions_parser_reads_system_user_install_states() { + let parsed = parse_package_restrictions_xml( + r#" + + + + + + + "#, + ) + .unwrap(); + + assert_eq!( + parsed, + BTreeMap::from([ + ("com.example.alpha".to_string(), true), + ("com.example.beta".to_string(), false), + ("com.example.delta".to_string(), true), + ("com.example.gamma".to_string(), false), + ]) + ); + } + + #[test] + fn system_user_filter_excludes_packages_explicitly_uninstalled_for_user_zero() { + let packages = BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.beta".to_string(), + "com.example.gamma".to_string(), + ]); + let system_user_states = BTreeMap::from([ + ("com.example.alpha".to_string(), true), + ("com.example.beta".to_string(), false), + ]); + + let filtered = filter_installed_for_system_user(packages, &system_user_states); + + assert_eq!( + filtered, + BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.gamma".to_string(), + ]) + ); + } + + #[test] + fn packages_xml_reader_keeps_current_empty_state_instead_of_falling_back_to_stale_backup() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("packages.xml"); + let backup = dir.path().join("packages-backup.xml"); + + std::fs::write(¤t, "").unwrap(); + std::fs::write( + &backup, + r#""#, + ) + .unwrap(); + + let current_packages = read_installed_user_apps_from_packages_xml(¤t).unwrap(); + let backup_packages = read_installed_user_apps_from_packages_xml(&backup).unwrap(); + + assert!(current_packages.is_empty()); + assert_eq!( + backup_packages, + BTreeSet::from(["com.example.stale".to_string()]) + ); + } + + #[test] + fn packages_xml_reader_rejects_invalid_current_file() { + let dir = tempfile::tempdir().unwrap(); + let current = dir.path().join("packages.xml"); + + std::fs::write(¤t, ", - pub priv_app: HashSet, + pub system_app: BTreeSet, + pub priv_app: BTreeSet, } impl Config { pub fn new() -> Result { - let config = Path::new(CONFIG_PATH); - if !config.exists() { - panic!("config file is no exists!!"); + Self::load_from_path(config_path()) + } + + pub fn load_from_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Ok(Self::default()); + } + + let buf = fs::read_to_string(path) + .with_context(|| format!("Failed to read config {}", path.display()))?; + let config: Self = + toml::from_str(buf.as_str()).with_context(|| "Failed to parse config".to_string())?; + Ok(config) + } + + pub fn save(&self) -> Result<()> { + self.save_to_path(config_path()) + } + + pub fn save_to_path(&self, path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create config directory {}", parent.display()) + })?; } - let buf = fs::read_to_string(config)?; - let toml: Self = toml::from_str(buf.as_str())?; - Ok(Self { app: toml.app }) + + let toml = toml::to_string(self).context("Failed to serialize config")?; + write_atomically(path, toml.as_bytes()) + } +} + +fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let mut temp = NamedTempFile::new_in(&parent) + .with_context(|| format!("Failed to create temp file in {}", parent.display()))?; + temp.write_all(contents) + .with_context(|| format!("Failed to write temp config for {}", path.display()))?; + temp.flush() + .with_context(|| format!("Failed to flush temp config for {}", path.display()))?; + + if path.exists() { + #[cfg(windows)] + { + fs::remove_file(path) + .with_context(|| format!("Failed to replace config {}", path.display()))?; + } + } + + temp.persist(path) + .map_err(|err| err.error) + .with_context(|| format!("Failed to persist config {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{App, Config}; + use std::collections::BTreeSet; + + use tempfile::tempdir; + + #[test] + fn missing_config_loads_as_default() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + let config = Config::load_from_path(&path).unwrap(); + + assert_eq!(config, Config::default()); + } + + #[test] + fn save_and_reload_round_trips_sorted_config() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let config = Config { + app: App { + system_app: BTreeSet::from([ + "com.example.beta".to_string(), + "com.example.alpha".to_string(), + ]), + priv_app: BTreeSet::from(["com.example.gamma".to_string()]), + }, + }; + + config.save_to_path(&path).unwrap(); + let reloaded = Config::load_from_path(&path).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + + assert_eq!(reloaded, config); + assert!(contents.contains("system_app = [\"com.example.alpha\", \"com.example.beta\"]")); } } diff --git a/okrmng/src/defs.rs b/okrmng/src/defs.rs index d076a52..7664995 100644 --- a/okrmng/src/defs.rs +++ b/okrmng/src/defs.rs @@ -1 +1,22 @@ +use std::path::PathBuf; + pub const CONFIG_PATH: &str = "/data/adb/modules/oukaro_manager/config.toml"; +pub const CONFIG_PATH_ENV: &str = "OUKARO_MANAGER_CONFIG_PATH"; +pub const SYSTEM_USER_ID: &str = "0"; +pub const PACKAGES_XML_PATHS: &[&str] = &[ + "/data/system/packages.xml", + "/data/system/packages.xml.reservecopy", + "/data/system/packages-backup.xml", +]; +pub const SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS: &[&str] = &[ + "/data/system/users/0/package-restrictions.xml", + "/data/system/users/0/package-restrictions.xml.reservecopy", + "/data/system/users/0/package-restrictions-backup.xml", + "/data/system/users/0/package-restrictions.xml.bak", +]; + +pub fn config_path() -> PathBuf { + std::env::var_os(CONFIG_PATH_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(CONFIG_PATH)) +} diff --git a/oukaro/Cargo.lock b/oukaro/Cargo.lock index 5965f22..83b51f2 100644 --- a/oukaro/Cargo.lock +++ b/oukaro/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -171,9 +171,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -181,10 +187,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] -name = "futures-core" -version = "0.3.31" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] [[package]] name = "hashbrown" @@ -226,28 +238,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inotify" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" -dependencies = [ - "bitflags", - "futures-core", - "inotify-sys", - "libc", - "tokio", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -312,17 +302,6 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -351,20 +330,14 @@ dependencies = [ "anyhow", "chrono", "env_logger", - "inotify", "log", - "regex", + "quick-xml", "rustix", "serde", + "tempfile", "toml", ] -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - [[package]] name = "portable-atomic" version = "1.11.1" @@ -389,6 +362,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.42" @@ -398,6 +380,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "regex" version = "1.12.2" @@ -437,7 +425,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -491,16 +479,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "syn" version = "2.0.110" @@ -513,16 +491,16 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.48.0" +name = "tempfile" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "libc", - "mio", - "pin-project-lite", - "socket2", - "windows-sys 0.61.2", + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", ] [[package]] @@ -577,10 +555,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] [[package]] name = "wasm-bindgen" @@ -686,15 +667,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -704,73 +676,14 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" diff --git a/oukaro/Cargo.toml b/oukaro/Cargo.toml index b2a89de..55947b4 100644 --- a/oukaro/Cargo.toml +++ b/oukaro/Cargo.toml @@ -7,13 +7,17 @@ edition = "2024" anyhow = "1.0.100" chrono = "0.4.42" env_logger = "0.11.8" -inotify = "0.11.0" log = "0.4.28" -regex = "1.12.2" -rustix = { version = "1.1.2", features = ["mount"] } +quick-xml = "0.38.3" serde = { version = "1.0.228", features = ["serde_derive"] } toml = "0.9.8" +[target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies] +rustix = { version = "1.1.2", features = ["mount"] } + +[dev-dependencies] +tempfile = "3.23.0" + [profile.release] overflow-checks = false codegen-units = 1 diff --git a/oukaro/src/defs.rs b/oukaro/src/defs.rs index 39b366b..aefaf4b 100644 --- a/oukaro/src/defs.rs +++ b/oukaro/src/defs.rs @@ -1,5 +1,16 @@ pub const CONFIG_PATH: &str = "/data/adb/modules/oukaro_manager/config.toml"; -pub const SYSTEM_PATH: &str = "/data/adb/modules/oukaro_manager/system"; pub const WORK_PATH: &str = "/data/adb/modules/oukaro_manager/work"; pub const LOWER_PATH: &str = "/system"; pub const UPPER_PATH: &str = "/data/adb/modules/oukaro_manager/system"; +pub const SYSTEM_USER_ID: &str = "0"; +pub const PACKAGES_XML_PATHS: &[&str] = &[ + "/data/system/packages.xml", + "/data/system/packages.xml.reservecopy", + "/data/system/packages-backup.xml", +]; +pub const SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS: &[&str] = &[ + "/data/system/users/0/package-restrictions.xml", + "/data/system/users/0/package-restrictions.xml.reservecopy", + "/data/system/users/0/package-restrictions-backup.xml", + "/data/system/users/0/package-restrictions.xml.bak", +]; diff --git a/oukaro/src/main.rs b/oukaro/src/main.rs index 26271d9..526e951 100644 --- a/oukaro/src/main.rs +++ b/oukaro/src/main.rs @@ -2,18 +2,17 @@ mod config; mod defs; mod utils; -use std::{io::Write, path::Path}; +use std::io::Write; use anyhow::Result; use env_logger::Builder; -use inotify::{Inotify, WatchMask}; use crate::{ - defs::{LOWER_PATH, SYSTEM_PATH, UPPER_PATH, WORK_PATH}, - utils::{dir_copys, find_data_path, mount_overlyfs}, + defs::{LOWER_PATH, UPPER_PATH, WORK_PATH}, + utils::{cleanup_unmanaged_packages, find_data_path, mount_overlyfs, sync_package_dir}, }; -fn run() -> Result<()> { +fn init_logger() { let mut builder = Builder::new(); builder.format(|buf, record| { let local_time = chrono::Local::now(); @@ -29,15 +28,16 @@ fn run() -> Result<()> { ) }); builder.filter_level(log::LevelFilter::Info).init(); +} +fn apply_saved_config() -> Result<()> { let mut config = config::Config::new(); - let mut inotify = Inotify::init()?; - let system_path = Path::new("/system"); - let lower = Path::new(LOWER_PATH); - let upper = Path::new(UPPER_PATH); - let work = Path::new(WORK_PATH); + let lower = std::path::Path::new(LOWER_PATH); + let upper = std::path::Path::new(UPPER_PATH); + let work = std::path::Path::new(WORK_PATH); + let system_root = upper.join("app"); + let priv_root = upper.join("priv-app"); - inotify.watches().add(SYSTEM_PATH, WatchMask::MODIFY)?; mount_overlyfs( lower.join("priv-app"), upper.join("priv-app"), @@ -48,31 +48,44 @@ fn run() -> Result<()> { lower.join("app"), upper.join("app"), work.join("app"), - "/system/", + "/system/app", )?; - loop { - config.load_config()?; - - let apps = config.get(); + config.load_config()?; + let apps = config.get(); - log::info!("handling system/priv-app"); - for app in apps.priv_app { - let data_path = find_data_path(&app)?; + cleanup_unmanaged_packages(&priv_root, &apps.priv_app)?; + cleanup_unmanaged_packages(&system_root, &apps.system_app)?; - dir_copys(data_path, system_path.join("priv-app")); - log::info!("mount successful.") + log::info!("handling system/priv-app"); + for app in apps.priv_app { + match find_data_path(&app)? { + Some(data_path) => { + sync_package_dir(data_path, &priv_root, &app)?; + log::info!("synced priv-app package {app}"); + } + None => log::warn!("package {app} is not installed; keeping config entry only"), } + } - log::info!("handling system/app"); - for app in apps.system_app { - let data_path = find_data_path(&app)?; - - dir_copys(data_path, system_path.join("app")); - log::info!("mount successful.") + log::info!("handling system/app"); + for app in apps.system_app { + match find_data_path(&app)? { + Some(data_path) => { + sync_package_dir(data_path, &system_root, &app)?; + log::info!("synced system-app package {app}"); + } + None => log::warn!("package {app} is not installed; keeping config entry only"), } - inotify.read_events_blocking(&mut [0; 1024])?; } + + Ok(()) +} + +fn run() -> Result<()> { + init_logger(); + log::info!("applying saved config during boot"); + apply_saved_config() } fn main() { diff --git a/oukaro/src/utils.rs b/oukaro/src/utils.rs index 223b11f..235e5c0 100644 --- a/oukaro/src/utils.rs +++ b/oukaro/src/utils.rs @@ -1,14 +1,31 @@ use std::{ - ffi::{CStr, CString}, + collections::{BTreeMap, HashSet}, fs, - path::Path, + io::ErrorKind, + path::{Path, PathBuf}, process::Command, }; use anyhow::{Context, Result}; -use regex::Regex; +use quick_xml::{Reader, events::Event}; +#[cfg(any(target_os = "android", target_os = "linux"))] use rustix::mount::{MountFlags, mount}; +#[cfg(any(target_os = "android", target_os = "linux"))] +use std::ffi::{CStr, CString}; +use crate::defs::{PACKAGES_XML_PATHS, SYSTEM_USER_ID, SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS}; + +#[cfg(any(target_os = "android", target_os = "linux"))] +fn is_mount_target(target: &str) -> Result { + let mountinfo = + fs::read_to_string("/proc/self/mountinfo").context("read /proc/self/mountinfo")?; + Ok(mountinfo + .lines() + .filter_map(|line| line.split_whitespace().nth(4)) + .any(|mount_point| mount_point == target)) +} + +#[cfg(any(target_os = "android", target_os = "linux"))] pub fn mount_overlyfs

(lower: P, upper: P, work: P, target: &str) -> Result<()> where P: AsRef, @@ -19,6 +36,11 @@ where fs::create_dir_all(work).context("create work")?; fs::create_dir_all(target).context("create target")?; + if is_mount_target(target)? { + log::info!("overlay already mounted on {target}, skipping"); + return Ok(()); + } + let opts = format!( "lowerdir={lower},upperdir={upper},workdir={work}", lower = lower.display(), @@ -31,41 +53,617 @@ where Ok(()) } -/// Folder Copy -/// from: source folder path -/// to: target path -pub fn dir_copys(from: impl AsRef, to: impl AsRef) { - let output = Command::new("cp") - .arg("-r") - .arg(from.as_ref()) - .arg(to.as_ref()) - .output() - .unwrap(); +#[cfg(not(any(target_os = "android", target_os = "linux")))] +pub fn mount_overlyfs

(_lower: P, _upper: P, _work: P, target: &str) -> Result<()> +where + P: AsRef, +{ + anyhow::bail!("overlay mounting is not supported on this platform: {target}"); +} - if !output.status.success() { - log::error!( - "copy files failed: stdout: {}, stderr {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - panic!(); +pub fn sync_package_dir( + from: impl AsRef, + destination_root: impl AsRef, + package: &str, +) -> Result<()> { + let from = from.as_ref(); + let destination_root = destination_root.as_ref(); + let package_dir = destination_root.join(package); + let staging_dir = destination_root.join(format!(".{package}.tmp")); + + if staging_dir.exists() { + remove_path(&staging_dir) + .with_context(|| format!("remove stale staging dir {}", staging_dir.display()))?; + } + + fs::create_dir_all(&staging_dir) + .with_context(|| format!("create staging dir {}", staging_dir.display()))?; + + if let Err(error) = copy_dir_contents(from, &staging_dir) { + let _ = remove_path(&staging_dir); + return Err(error); + } + + if package_dir.exists() { + remove_path(&package_dir) + .with_context(|| format!("remove existing package dir {}", package_dir.display()))?; } + + fs::rename(&staging_dir, &package_dir).with_context(|| { + format!( + "rename staging dir {} to {}", + staging_dir.display(), + package_dir.display() + ) + })?; + Ok(()) +} + +pub fn cleanup_unmanaged_packages( + destination_root: impl AsRef, + keep_packages: &HashSet, +) -> Result<()> { + let destination_root = destination_root.as_ref(); + fs::create_dir_all(destination_root) + .with_context(|| format!("create destination root {}", destination_root.display()))?; + + for entry in fs::read_dir(destination_root) + .with_context(|| format!("read destination root {}", destination_root.display()))? + { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let package_name = file_name.to_string_lossy(); + + if keep_packages.contains(package_name.as_ref()) { + continue; + } + + if entry.file_type()?.is_dir() { + fs::remove_dir_all(&path) + .with_context(|| format!("remove stale package dir {}", path.display()))?; + } else { + fs::remove_file(&path) + .with_context(|| format!("remove stale file {}", path.display()))?; + } + } + + Ok(()) +} + +fn copy_dir_contents(from: &Path, to: &Path) -> Result<()> { + for entry in + fs::read_dir(from).with_context(|| format!("read source dir {}", from.display()))? + { + let entry = entry?; + let source_path = entry.path(); + let destination_path = to.join(entry.file_name()); + let metadata = fs::symlink_metadata(&source_path)?; + let file_type = metadata.file_type(); + + if file_type.is_dir() { + fs::create_dir_all(&destination_path).with_context(|| { + format!("create destination dir {}", destination_path.display()) + })?; + copy_dir_contents(&source_path, &destination_path)?; + let permissions = metadata.permissions(); + fs::set_permissions(&destination_path, permissions) + .with_context(|| format!("set permissions on {}", destination_path.display()))?; + continue; + } + + if file_type.is_symlink() { + copy_symlink(&source_path, &destination_path)?; + continue; + } + + fs::copy(&source_path, &destination_path).with_context(|| { + format!( + "copy file from {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + let permissions = metadata.permissions(); + fs::set_permissions(&destination_path, permissions) + .with_context(|| format!("set permissions on {}", destination_path.display()))?; + } + + Ok(()) } /// get packge data path in =/data /// packge: packge name -pub fn find_data_path(package: &str) -> Result { - let out = Command::new("pm").args(["path", package]).output()?; +pub fn find_data_path(package: &str) -> Result> { + match is_installed_for_system_user(package) { + Ok(true) => {} + Ok(false) => { + log::info!( + "package {} is not installed for system user {}, skipping", + package, + SYSTEM_USER_ID + ); + return Ok(None); + } + Err(error) => { + log::warn!( + "could not confirm package {} for system user {}: {error:#}; skipping package for safety", + package, + SYSTEM_USER_ID + ); + return Ok(None); + } + } + + match find_data_path_from_packages_xml(package) { + Ok(Some(data_dir)) => { + log::info!( + "{} path is {} (from packages.xml)", + package, + data_dir.display() + ); + return Ok(Some(data_dir)); + } + Ok(None) => {} + Err(error) => { + log::warn!( + "failed to resolve package {} from packages.xml metadata: {error:#}", + package + ); + } + } + + let out = Command::new("pm") + .args(["path", "--user", SYSTEM_USER_ID, package]) + .output() + .with_context(|| format!("execute `pm path --user {SYSTEM_USER_ID} {package}`"))?; + + if !out.status.success() { + log::warn!( + "failed to resolve package {}: {}", + package, + String::from_utf8_lossy(&out.stderr).trim() + ); + return Ok(None); + } + let stdout = String::from_utf8_lossy(&out.stdout); - let re = Regex::new(r"^package:(.*)").unwrap(); - let caps = match re.captures(&stdout) { - Some(s) => s, - None => return Ok(String::new()), + let base_apk = match stdout + .lines() + .map(str::trim) + .find_map(|line| line.strip_prefix("package:")) + { + Some(path) if !path.is_empty() => PathBuf::from(path), + _ => return Ok(None), }; - let mut path = caps[1].to_string(); - path = path.trim_end().trim_end_matches("base.apk").to_string(); - log::info!("{} path is {}", package, path); + let data_dir = base_apk + .parent() + .map(Path::to_path_buf) + .with_context(|| format!("package {package} apk path has no parent"))?; + log::info!("{} path is {}", package, data_dir.display()); + + Ok(Some(data_dir)) +} + +fn is_installed_for_system_user(package: &str) -> Result { + match read_system_user_package_states() { + Ok(Some(states)) => Ok(states.get(package).copied().unwrap_or(true)), + Ok(None) => check_package_visible_to_system_user_with_pm(package), + Err(restrictions_error) => match check_package_visible_to_system_user_with_pm(package) { + Ok(installed) => { + log::warn!( + "failed to read package restrictions for system user {}: {restrictions_error:#}; using `pm path --user {}` fallback", + SYSTEM_USER_ID, + SYSTEM_USER_ID + ); + Ok(installed) + } + Err(pm_error) => Err(restrictions_error.context(format!( + "Fallback `pm path --user {SYSTEM_USER_ID}` probe also failed: {pm_error:#}" + ))), + }, + } +} + +fn check_package_visible_to_system_user_with_pm(package: &str) -> Result { + let out = Command::new("pm") + .args(["path", "--user", SYSTEM_USER_ID, package]) + .output() + .with_context(|| format!("execute `pm path --user {SYSTEM_USER_ID} {package}`"))?; + + if out.status.success() { + let stdout = String::from_utf8_lossy(&out.stdout); + return Ok(stdout + .lines() + .map(str::trim) + .any(|line| line.starts_with("package:"))); + } + + let stderr = String::from_utf8_lossy(&out.stderr); + let trimmed = stderr.trim(); + if trimmed.contains("not found") || trimmed.contains("Unknown package") { + return Ok(false); + } + + anyhow::bail!("`pm path --user {SYSTEM_USER_ID} {package}` failed: {trimmed}"); +} + +fn find_data_path_from_packages_xml(package: &str) -> Result> { + let mut last_error = None; + + for packages_xml in PACKAGES_XML_PATHS { + let path = Path::new(packages_xml); + if !path.exists() { + continue; + } + + match find_data_path_from_packages_xml_file(path, package) { + Ok(data_dir) => return Ok(data_dir), + Err(error) => { + log::warn!("failed to inspect {}: {error:#}", path.display()); + last_error = Some(error); + } + } + } + + match last_error { + Some(error) => Err(error), + None => Ok(None), + } +} + +fn find_data_path_from_packages_xml_file(path: &Path, package: &str) -> Result> { + let contents = fs::read_to_string(path) + .with_context(|| format!("read package settings {}", path.display()))?; + if let Some(code_path) = parse_package_code_path(&contents, package)? { + if let Some(data_dir) = normalize_code_path(code_path) { + return Ok(Some(data_dir)); + } - Ok(path) + log::warn!( + "package {} found in {}, but code path is missing on disk", + package, + path.display() + ); + } + + Ok(None) +} + +fn read_system_user_package_states() -> Result>> { + let mut last_error = None; + + for restrictions_xml in SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS { + let path = Path::new(restrictions_xml); + if !path.exists() { + continue; + } + + match read_package_states_from_restrictions_file(path) { + Ok(states) => return Ok(Some(states)), + Err(error) => { + log::warn!("failed to inspect {}: {error:#}", path.display()); + last_error = Some(error); + } + } + } + + match last_error { + Some(error) => Err(error), + None => Ok(None), + } +} + +fn read_package_states_from_restrictions_file(path: &Path) -> Result> { + let contents = fs::read_to_string(path) + .with_context(|| format!("read package restrictions {}", path.display()))?; + parse_package_restrictions_xml(&contents) + .with_context(|| format!("parse package restrictions {}", path.display())) +} + +fn parse_package_code_path(contents: &str, package: &str) -> Result> { + let mut reader = Reader::from_str(contents); + reader.config_mut().trim_text(true); + + loop { + match reader.read_event()? { + Event::Start(event) | Event::Empty(event) if event.name().as_ref() == b"package" => { + let mut package_name = None; + let mut code_path = None; + + for attribute in event.attributes().with_checks(false) { + let attribute = attribute?; + let value = attribute + .decode_and_unescape_value(reader.decoder())? + .into_owned(); + + match attribute.key.as_ref() { + b"name" => package_name = Some(value), + b"codePath" => code_path = Some(value), + _ => {} + } + } + + if package_name.as_deref() == Some(package) { + return Ok(code_path.map(PathBuf::from)); + } + } + Event::Eof => return Ok(None), + _ => {} + } + } +} + +fn parse_package_restrictions_xml(contents: &str) -> Result> { + let mut reader = Reader::from_str(contents); + reader.config_mut().trim_text(true); + let mut states = BTreeMap::new(); + + loop { + match reader.read_event()? { + Event::Start(event) | Event::Empty(event) + if event.name().as_ref() == b"pkg" || event.name().as_ref() == b"package" => + { + let mut package_name = None; + let mut installed = true; + + for attribute in event.attributes().with_checks(false) { + let attribute = attribute?; + let value = attribute + .decode_and_unescape_value(reader.decoder())? + .into_owned(); + + match attribute.key.as_ref() { + b"name" => package_name = Some(value), + b"inst" | b"installed" => installed = !value.eq_ignore_ascii_case("false"), + _ => {} + } + } + + if let Some(package_name) = package_name { + states.insert(package_name, installed); + } + } + Event::Eof => return Ok(states), + _ => {} + } + } +} + +fn normalize_code_path(code_path: PathBuf) -> Option { + if code_path.is_dir() { + return Some(code_path); + } + + if code_path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("apk")) + { + return code_path + .parent() + .filter(|parent| parent.exists()) + .map(Path::to_path_buf); + } + + if code_path.is_file() { + return code_path.parent().map(Path::to_path_buf); + } + + None +} + +fn remove_path(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() => { + fs::remove_dir_all(path).with_context(|| format!("remove directory {}", path.display())) + } + Ok(_) => fs::remove_file(path).with_context(|| format!("remove file {}", path.display())), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("read metadata {}", path.display())), + } +} + +#[cfg(unix)] +fn copy_symlink(source_path: &Path, destination_path: &Path) -> Result<()> { + use std::os::unix::fs::symlink; + + let target = fs::read_link(source_path) + .with_context(|| format!("read symlink {}", source_path.display()))?; + symlink(&target, destination_path).with_context(|| { + format!( + "create symlink from {} to {}", + destination_path.display(), + target.display() + ) + })?; + Ok(()) +} + +#[cfg(not(unix))] +fn copy_symlink(source_path: &Path, destination_path: &Path) -> Result<()> { + let target = fs::read_link(source_path) + .with_context(|| format!("read symlink {}", source_path.display()))?; + let resolved = source_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(target); + fs::copy(&resolved, destination_path).with_context(|| { + format!( + "copy symlink target from {} to {}", + resolved.display(), + destination_path.display() + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::{BTreeMap, HashSet}, + fs, + }; + + use tempfile::tempdir; + + use super::{ + cleanup_unmanaged_packages, find_data_path_from_packages_xml_file, + parse_package_restrictions_xml, sync_package_dir, + }; + + #[test] + fn sync_package_dir_copies_contents_into_named_folder() { + let source_dir = tempdir().unwrap(); + let nested_dir = source_dir.path().join("lib"); + let destination_dir = tempdir().unwrap(); + + fs::create_dir_all(&nested_dir).unwrap(); + fs::write(source_dir.path().join("base.apk"), b"apk").unwrap(); + fs::write(nested_dir.join("split_config.apk"), b"split").unwrap(); + + sync_package_dir(source_dir.path(), destination_dir.path(), "com.example.app").unwrap(); + + let package_dir = destination_dir.path().join("com.example.app"); + assert_eq!(fs::read(package_dir.join("base.apk")).unwrap(), b"apk"); + assert_eq!( + fs::read(package_dir.join("lib").join("split_config.apk")).unwrap(), + b"split" + ); + } + + #[test] + fn cleanup_unmanaged_packages_removes_directories_not_in_config() { + let destination_dir = tempdir().unwrap(); + fs::create_dir_all(destination_dir.path().join("keep.me")).unwrap(); + fs::create_dir_all(destination_dir.path().join("remove.me")).unwrap(); + + cleanup_unmanaged_packages( + destination_dir.path(), + &HashSet::from([String::from("keep.me")]), + ) + .unwrap(); + + assert!(destination_dir.path().join("keep.me").exists()); + assert!(!destination_dir.path().join("remove.me").exists()); + } + + #[test] + fn packages_xml_lookup_supports_directory_code_paths() { + let root = tempdir().unwrap(); + let package_dir = root + .path() + .join("data") + .join("app") + .join("com.example.alpha"); + fs::create_dir_all(&package_dir).unwrap(); + + let packages_xml = root.path().join("packages.xml"); + fs::write( + &packages_xml, + format!( + r#""#, + package_dir.display() + ), + ) + .unwrap(); + + let resolved = + find_data_path_from_packages_xml_file(&packages_xml, "com.example.alpha").unwrap(); + + assert_eq!(resolved, Some(package_dir)); + } + + #[test] + fn packages_xml_lookup_supports_base_apk_code_paths() { + let root = tempdir().unwrap(); + let package_dir = root + .path() + .join("data") + .join("app") + .join("com.example.beta"); + fs::create_dir_all(&package_dir).unwrap(); + let base_apk = package_dir.join("base.apk"); + fs::write(&base_apk, b"apk").unwrap(); + + let packages_xml = root.path().join("packages.xml"); + fs::write( + &packages_xml, + format!( + r#""#, + base_apk.display() + ), + ) + .unwrap(); + + let resolved = + find_data_path_from_packages_xml_file(&packages_xml, "com.example.beta").unwrap(); + + assert_eq!(resolved, Some(package_dir)); + } + + #[test] + fn current_packages_xml_missing_package_is_not_treated_as_backup_hit() { + let root = tempdir().unwrap(); + let current = root.path().join("packages.xml"); + let backup = root.path().join("packages-backup.xml"); + let package_dir = root + .path() + .join("data") + .join("app") + .join("com.example.stale"); + fs::create_dir_all(&package_dir).unwrap(); + + fs::write(¤t, "").unwrap(); + fs::write( + &backup, + format!( + r#""#, + package_dir.display() + ), + ) + .unwrap(); + + let current_resolved = + find_data_path_from_packages_xml_file(¤t, "com.example.stale").unwrap(); + let backup_resolved = + find_data_path_from_packages_xml_file(&backup, "com.example.stale").unwrap(); + + assert_eq!(current_resolved, None); + assert_eq!(backup_resolved, Some(package_dir)); + } + + #[test] + fn invalid_current_packages_xml_returns_error() { + let root = tempdir().unwrap(); + let current = root.path().join("packages.xml"); + fs::write(¤t, " + + + + + + "#, + ) + .unwrap(); + + assert_eq!( + parsed, + BTreeMap::from([ + ("com.example.alpha".to_string(), true), + ("com.example.beta".to_string(), false), + ("com.example.delta".to_string(), true), + ("com.example.gamma".to_string(), false), + ]) + ); + } } diff --git a/webui/.gitignore b/webui/.gitignore new file mode 100644 index 0000000..2012d31 --- /dev/null +++ b/webui/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +.vite +*.tsbuildinfo +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/webui/.vscode/extensions.json b/webui/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/webui/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 0000000..6736233 --- /dev/null +++ b/webui/README.md @@ -0,0 +1,12 @@ +# OukaroManager WebUI + +Vue 3 + TypeScript + Vite frontend for the KernelSU module WebUI. The UI manages apps installed for the primary Android user (user 0) and writes static output into the module `webroot/`. + +## Commands + +```sh +npm install +npm run build +``` + +`npm run build` writes the static site into `../module/webroot/`. diff --git a/webui/components.json b/webui/components.json new file mode 100644 index 0000000..1c46ffd --- /dev/null +++ b/webui/components.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "default", + "typescript": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/style.css", + "baseColor": "stone", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..68e7318 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,13 @@ + + + + + + + OukaroManager WebUI + + +

+ + + diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 0000000..8d7ca5c --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,2723 @@ +{ + "name": "webui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "webui", + "version": "0.0.0", + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "kernelsu": "^3.0.2", + "lucide-vue-next": "^1.0.0", + "reka-ui": "^2.9.2", + "tailwind-merge": "^3.5.0", + "vue": "^3.5.30", + "vue-i18n": "^11.3.0", + "vue-sonner": "^2.0.9" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.0", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.8", + "tailwindcss": "^3.4.19", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vue-tsc": "^3.2.5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@intlify/core-base": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz", + "integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-types": "11.3.0", + "@intlify/message-compiler": "11.3.0", + "@intlify/shared": "11.3.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz", + "integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.0", + "@intlify/shared": "11.3.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz", + "integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "11.3.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz", + "integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz", + "integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.23.tgz", + "integrity": "sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.31.tgz", + "integrity": "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.31", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.31.tgz", + "integrity": "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.31.tgz", + "integrity": "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.31", + "@vue/compiler-dom": "3.5.31", + "@vue/compiler-ssr": "3.5.31", + "@vue/shared": "3.5.31", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.31.tgz", + "integrity": "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.31.tgz", + "integrity": "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.31.tgz", + "integrity": "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.31", + "@vue/shared": "3.5.31" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.31.tgz", + "integrity": "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.31", + "@vue/runtime-core": "3.5.31", + "@vue/shared": "3.5.31", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.31.tgz", + "integrity": "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.31", + "@vue/shared": "3.5.31" + }, + "peerDependencies": { + "vue": "3.5.31" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.31.tgz", + "integrity": "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.326", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.326.tgz", + "integrity": "sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kernelsu": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/kernelsu/-/kernelsu-3.0.2.tgz", + "integrity": "sha512-3MTGgIbl3TWCzqnJP6Fun9tHv6VULR3O188U3raeGRSMzK+G3pldL9ySwaZWhsHCtI2BTvlQuu/3p10s4a5c3w==", + "license": "Apache-2.0" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-vue-next": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz", + "integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/reka-ui": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.2.tgz", + "integrity": "sha512-/t4e6y1hcG+uDuRfpg6tbMz3uUEvRzNco6NeYTufoJeUghy5Iosxos5YL/p+ieAsid84sdMX9OrgDqpEuCJhBw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@floating-ui/vue": "^1.1.6", + "@internationalized/date": "^3.5.0", + "@internationalized/number": "^3.5.0", + "@tanstack/vue-virtual": "^3.12.0", + "@vueuse/core": "^14.1.0", + "@vueuse/shared": "^14.1.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "ohash": "^2.0.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/zernonia" + }, + "peerDependencies": { + "vue": ">= 3.4.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.31", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.31.tgz", + "integrity": "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.31", + "@vue/compiler-sfc": "3.5.31", + "@vue/runtime-dom": "3.5.31", + "@vue/server-renderer": "3.5.31", + "@vue/shared": "3.5.31" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz", + "integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.3.0", + "@intlify/devtools-types": "11.3.0", + "@intlify/shared": "11.3.0", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-sonner": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz", + "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", + "license": "MIT", + "peerDependencies": { + "@nuxt/kit": "^4.0.3", + "@nuxt/schema": "^4.0.3", + "nuxt": "^4.0.3" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@nuxt/schema": { + "optional": true + }, + "nuxt": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..83a415b --- /dev/null +++ b/webui/package.json @@ -0,0 +1,35 @@ +{ + "name": "webui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "prebuild": "node ./scripts/clean-webroot.mjs", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "kernelsu": "^3.0.2", + "lucide-vue-next": "^1.0.0", + "reka-ui": "^2.9.2", + "tailwind-merge": "^3.5.0", + "vue": "^3.5.30", + "vue-i18n": "^11.3.0", + "vue-sonner": "^2.0.9" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.0", + "autoprefixer": "^10.4.27", + "postcss": "^8.5.8", + "tailwindcss": "^3.4.19", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.9.3", + "vite": "^8.0.1", + "vue-tsc": "^3.2.5" + } +} diff --git a/webui/postcss.config.js b/webui/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/webui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/webui/scripts/clean-webroot.mjs b/webui/scripts/clean-webroot.mjs new file mode 100644 index 0000000..897ded3 --- /dev/null +++ b/webui/scripts/clean-webroot.mjs @@ -0,0 +1,9 @@ +import { mkdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const webrootDir = path.resolve(scriptDir, '../../module/webroot') + +rmSync(webrootDir, { force: true, recursive: true }) +mkdirSync(webrootDir, { recursive: true }) diff --git a/webui/src/App.vue b/webui/src/App.vue new file mode 100644 index 0000000..4f11dba --- /dev/null +++ b/webui/src/App.vue @@ -0,0 +1,692 @@ + + + diff --git a/webui/src/assets/karo.svg b/webui/src/assets/karo.svg new file mode 100644 index 0000000..d87eb6c --- /dev/null +++ b/webui/src/assets/karo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webui/src/components/ui/alert/Alert.vue b/webui/src/components/ui/alert/Alert.vue new file mode 100644 index 0000000..3b013d5 --- /dev/null +++ b/webui/src/components/ui/alert/Alert.vue @@ -0,0 +1,43 @@ + + + diff --git a/webui/src/components/ui/alert/alert.ts b/webui/src/components/ui/alert/alert.ts new file mode 100644 index 0000000..d181e41 --- /dev/null +++ b/webui/src/components/ui/alert/alert.ts @@ -0,0 +1,19 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +export const alertVariants = cva( + 'flex gap-3 rounded-3xl border px-4 py-4 text-sm shadow-sm', + { + variants: { + variant: { + default: 'border-border/70 bg-background/80 text-foreground', + warning: 'border-primary/20 bg-primary/10 text-foreground', + destructive: 'border-destructive/20 bg-destructive/10 text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export type AlertVariants = VariantProps diff --git a/webui/src/components/ui/alert/index.ts b/webui/src/components/ui/alert/index.ts new file mode 100644 index 0000000..1c65c88 --- /dev/null +++ b/webui/src/components/ui/alert/index.ts @@ -0,0 +1,2 @@ +export { default as Alert } from './Alert.vue' +export { alertVariants } from './alert' diff --git a/webui/src/components/ui/badge/Badge.vue b/webui/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..e3b5c3b --- /dev/null +++ b/webui/src/components/ui/badge/Badge.vue @@ -0,0 +1,35 @@ + + + diff --git a/webui/src/components/ui/badge/badge.ts b/webui/src/components/ui/badge/badge.ts new file mode 100644 index 0000000..112df3f --- /dev/null +++ b/webui/src/components/ui/badge/badge.ts @@ -0,0 +1,20 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +export const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em]', + { + variants: { + variant: { + default: 'border-primary/30 bg-primary/12 text-primary', + secondary: 'border-border/70 bg-secondary text-secondary-foreground', + outline: 'border-border bg-background/70 text-foreground/80', + destructive: 'border-destructive/20 bg-destructive/10 text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export type BadgeVariants = VariantProps diff --git a/webui/src/components/ui/badge/index.ts b/webui/src/components/ui/badge/index.ts new file mode 100644 index 0000000..178fb11 --- /dev/null +++ b/webui/src/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from './Badge.vue' +export { badgeVariants } from './badge' diff --git a/webui/src/components/ui/button/Button.vue b/webui/src/components/ui/button/Button.vue new file mode 100644 index 0000000..8988275 --- /dev/null +++ b/webui/src/components/ui/button/Button.vue @@ -0,0 +1,40 @@ + + + diff --git a/webui/src/components/ui/button/button.ts b/webui/src/components/ui/button/button.ts new file mode 100644 index 0000000..875d9ef --- /dev/null +++ b/webui/src/components/ui/button/button.ts @@ -0,0 +1,29 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 rounded-full text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-[0_14px_30px_-18px_hsl(var(--primary)/0.85)] hover:-translate-y-0.5 hover:bg-primary/90', + outline: + 'border border-border bg-background/60 text-foreground hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'text-foreground hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-11 px-5', + sm: 'h-9 px-3.5 text-xs', + lg: 'h-12 px-6 text-base', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export type ButtonVariants = VariantProps diff --git a/webui/src/components/ui/button/index.ts b/webui/src/components/ui/button/index.ts new file mode 100644 index 0000000..9e1308d --- /dev/null +++ b/webui/src/components/ui/button/index.ts @@ -0,0 +1,2 @@ +export { default as Button } from './Button.vue' +export { buttonVariants } from './button' diff --git a/webui/src/components/ui/card/Card.vue b/webui/src/components/ui/card/Card.vue new file mode 100644 index 0000000..ef369d8 --- /dev/null +++ b/webui/src/components/ui/card/Card.vue @@ -0,0 +1,30 @@ + + + diff --git a/webui/src/components/ui/card/index.ts b/webui/src/components/ui/card/index.ts new file mode 100644 index 0000000..579ebc7 --- /dev/null +++ b/webui/src/components/ui/card/index.ts @@ -0,0 +1 @@ +export { default as Card } from './Card.vue' diff --git a/webui/src/components/ui/input/Input.vue b/webui/src/components/ui/input/Input.vue new file mode 100644 index 0000000..dd7f540 --- /dev/null +++ b/webui/src/components/ui/input/Input.vue @@ -0,0 +1,42 @@ + + + diff --git a/webui/src/components/ui/input/index.ts b/webui/src/components/ui/input/index.ts new file mode 100644 index 0000000..a691dd6 --- /dev/null +++ b/webui/src/components/ui/input/index.ts @@ -0,0 +1 @@ +export { default as Input } from './Input.vue' diff --git a/webui/src/components/ui/radio-group/RadioGroup.vue b/webui/src/components/ui/radio-group/RadioGroup.vue new file mode 100644 index 0000000..41970e5 --- /dev/null +++ b/webui/src/components/ui/radio-group/RadioGroup.vue @@ -0,0 +1,38 @@ + + + diff --git a/webui/src/components/ui/radio-group/index.ts b/webui/src/components/ui/radio-group/index.ts new file mode 100644 index 0000000..cbe3d99 --- /dev/null +++ b/webui/src/components/ui/radio-group/index.ts @@ -0,0 +1,2 @@ +export { default as RadioGroup } from './RadioGroup.vue' +export type { RadioOption } from './types' diff --git a/webui/src/components/ui/radio-group/types.ts b/webui/src/components/ui/radio-group/types.ts new file mode 100644 index 0000000..2df7b49 --- /dev/null +++ b/webui/src/components/ui/radio-group/types.ts @@ -0,0 +1,5 @@ +export interface RadioOption { + value: TValue + label: string + hint?: string +} diff --git a/webui/src/components/ui/scroll-area/ScrollArea.vue b/webui/src/components/ui/scroll-area/ScrollArea.vue new file mode 100644 index 0000000..c87e5d8 --- /dev/null +++ b/webui/src/components/ui/scroll-area/ScrollArea.vue @@ -0,0 +1,22 @@ + + + diff --git a/webui/src/components/ui/scroll-area/index.ts b/webui/src/components/ui/scroll-area/index.ts new file mode 100644 index 0000000..82a7ce7 --- /dev/null +++ b/webui/src/components/ui/scroll-area/index.ts @@ -0,0 +1 @@ +export { default as ScrollArea } from './ScrollArea.vue' diff --git a/webui/src/components/ui/separator/Separator.vue b/webui/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..383b262 --- /dev/null +++ b/webui/src/components/ui/separator/Separator.vue @@ -0,0 +1,22 @@ + + + diff --git a/webui/src/components/ui/separator/index.ts b/webui/src/components/ui/separator/index.ts new file mode 100644 index 0000000..2287bcb --- /dev/null +++ b/webui/src/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from './Separator.vue' diff --git a/webui/src/components/ui/toaster/Toaster.vue b/webui/src/components/ui/toaster/Toaster.vue new file mode 100644 index 0000000..77aa630 --- /dev/null +++ b/webui/src/components/ui/toaster/Toaster.vue @@ -0,0 +1,13 @@ + + + diff --git a/webui/src/components/ui/toaster/index.ts b/webui/src/components/ui/toaster/index.ts new file mode 100644 index 0000000..8b17958 --- /dev/null +++ b/webui/src/components/ui/toaster/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from './Toaster.vue' diff --git a/webui/src/lib/i18n.ts b/webui/src/lib/i18n.ts new file mode 100644 index 0000000..6a254d9 --- /dev/null +++ b/webui/src/lib/i18n.ts @@ -0,0 +1,199 @@ +import { createI18n } from 'vue-i18n' + +const messages = { + en: { + title: 'System App Workbench', + subtitle: + 'Choose apps installed for the primary Android user (user 0), place them into System or Priv mode, and save the module config for the next reboot.', + language: { + label: 'Language', + zh: '中文', + en: 'English', + }, + header: { + eyebrow: 'System app configuration console', + supported: 'KernelSU linked', + preview: 'Preview only', + reboot: 'Reboot required after save', + managedBy: 'Backed by okrmng inspect/replace', + modulePath: 'Module path', + moduleId: 'Module ID', + version: 'Version', + modulePending: 'Waiting for module metadata', + moduleUnavailable: 'Module metadata unavailable in preview mode', + }, + actions: { + save: 'Save configuration', + saving: 'Saving configuration...', + reset: 'Reset draft', + refresh: 'Reload state', + }, + list: { + title: 'Installed primary-user apps', + description: + 'Search by package name and assign exactly one target mode per app for Android user 0.', + searchLabel: 'Search packages', + searchPlaceholder: 'com.example.app', + modeLabel: 'Target mode', + empty: 'No installed primary-user apps matched the current search.', + noData: 'okrmng did not return any primary-user apps.', + packageCount: '{count} apps available', + }, + summary: { + title: 'Current draft', + description: + 'Counts cover the whole saved config, including preserved stale entries.', + installed: 'Primary-user apps', + configured: 'Configured apps', + system: 'System', + priv: 'Privileged', + none: 'Unset', + stale: 'Missing configured apps', + synced: 'Config matches disk', + dirty: 'Unsaved changes', + savedAt: 'Last saved at {time}', + }, + mode: { + none: 'None', + system: 'System', + priv: 'Priv', + noneHint: 'Keep this package out of the module config.', + systemHint: 'Mount this package under /system/app on next reboot.', + privHint: 'Mount this package under /system/priv-app on next reboot.', + }, + alerts: { + unsupportedTitle: 'KernelSU APIs are unavailable', + unsupportedBody: + 'This page can render in a normal browser for layout work, but refresh and save stay disabled until it runs inside KernelSU Manager or WebUIX.', + loadFailedTitle: 'Could not load module state', + saveFailedTitle: 'Could not save configuration', + missingTitle: 'Stale configuration preserved', + missingBody: + '{count} configured packages are no longer listed for the primary Android user. Saving keeps those stale entries unchanged.', + privTitle: 'Priv mode remains ROM-dependent', + privBody: + 'Mounting under /system/priv-app does not automatically grant privileged permissions on Android 8.0+. Many ROMs still require same-partition privapp-permissions XML allowlists.', + rebootTitle: 'Saving only updates config.toml', + rebootBody: + 'The module applies saved mounts during the next boot\'s post-mount stage. Save your draft, then reboot the device to activate it.', + }, + toasts: { + saved: 'Configuration saved. Reboot to apply.', + saveFailed: 'Could not save configuration.', + refreshed: 'Module state refreshed.', + loadFailed: 'Could not load module state.', + }, + status: { + loading: 'Loading module state...', + refreshing: 'Refreshing...', + saving: 'Saving...', + }, + labels: { + current: 'Current mode', + changed: 'Changed', + preserved: 'Preserved', + selected: 'Selected', + }, + }, + 'zh-CN': { + title: '系统应用工作台', + subtitle: + '选择主用户(user 0)已安装的应用,将它们切到 System 或 Priv 模式,并保存模块配置,等待下次重启生效。', + language: { + label: '语言', + zh: '中文', + en: 'English', + }, + header: { + eyebrow: '系统应用配置控制台', + supported: '已连接 KernelSU', + preview: '仅预览模式', + reboot: '保存后需要重启', + managedBy: '由 okrmng inspect/replace 驱动', + modulePath: '模块路径', + moduleId: '模块 ID', + version: '版本', + modulePending: '正在等待模块元数据', + moduleUnavailable: '预览模式下无法读取模块元数据', + }, + actions: { + save: '保存配置', + saving: '正在保存配置...', + reset: '重置草稿', + refresh: '重新读取状态', + }, + list: { + title: '主用户已安装应用', + description: '按包名搜索,并仅针对主用户(user 0)为每个应用选择一个目标模式。', + searchLabel: '搜索包名', + searchPlaceholder: 'com.example.app', + modeLabel: '目标模式', + empty: '当前搜索条件下没有匹配到主用户应用。', + noData: 'okrmng 没有返回任何主用户应用。', + packageCount: '共 {count} 个应用', + }, + summary: { + title: '当前草稿', + description: '统计覆盖整个保存配置,包含仍被保留的失效条目。', + installed: '主用户应用数', + configured: '已配置应用', + system: 'System', + priv: 'Priv', + none: '未设置', + stale: '失效配置条目', + synced: '当前配置已与磁盘一致', + dirty: '有未保存更改', + savedAt: '最近保存于 {time}', + }, + mode: { + none: 'None', + system: 'System', + priv: 'Priv', + noneHint: '不把这个应用写进模块配置。', + systemHint: '下次重启后把它挂载到 /system/app。', + privHint: '下次重启后把它挂载到 /system/priv-app。', + }, + alerts: { + unsupportedTitle: '当前环境没有 KernelSU API', + unsupportedBody: + '这个页面可以在普通浏览器里预览布局,但刷新和保存功能只有在 KernelSU Manager 或 WebUIX 里运行时才可用。', + loadFailedTitle: '读取模块状态失败', + saveFailedTitle: '保存配置失败', + missingTitle: '已保留失效配置', + missingBody: + '有 {count} 个已配置包名不再属于主用户(user 0)应用列表。保存时会保留这些失效条目,不会自动丢失。', + privTitle: 'Priv 模式仍然依赖 ROM 实现', + privBody: + '把应用挂到 /system/priv-app 并不等于 Android 8.0+ 一定授予特权权限。很多 ROM 仍要求同分区的 privapp-permissions XML allowlist。', + rebootTitle: '保存只会更新 config.toml', + rebootBody: + '模块会在下一次开机的 post-mount 阶段应用这些挂载。先保存草稿,再重启设备让改动生效。', + }, + toasts: { + saved: '配置已保存,请重启后生效。', + saveFailed: '保存配置失败。', + refreshed: '模块状态已刷新。', + loadFailed: '读取模块状态失败。', + }, + status: { + loading: '正在读取模块状态...', + refreshing: '正在刷新...', + saving: '正在保存...', + }, + labels: { + current: '当前模式', + changed: '已变更', + preserved: '已保留', + selected: '已选择', + }, + }, +} as const + +const locale = navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en' + +export const i18n = createI18n({ + legacy: false, + locale, + fallbackLocale: 'en', + messages, +}) diff --git a/webui/src/lib/module-api.ts b/webui/src/lib/module-api.ts new file mode 100644 index 0000000..0ce82c4 --- /dev/null +++ b/webui/src/lib/module-api.ts @@ -0,0 +1,125 @@ +import { enableEdgeToEdge, exec, moduleInfo, toast as kernelToast } from 'kernelsu' + +import type { InspectPayload, ModuleMetadata } from '@/lib/types' + +declare global { + interface Window { + ksu?: unknown + kernelsu?: unknown + } +} + +const OKRMNG_COMMAND = './okrmng' + +let moduleMetadataPromise: Promise | null = null + +function ensureKernelSuAlias() { + if (typeof window === 'undefined') { + return + } + + if (typeof window.ksu === 'undefined' && typeof window.kernelsu !== 'undefined') { + window.ksu = window.kernelsu + } +} + +function shellQuote(value: string) { + return `'${value.replace(/'/g, `'\"'\"'`)}'` +} + +function commandError(stderr: string, stdout: string, errno: number) { + const message = stderr.trim() || stdout.trim() + return new Error(message || `Command failed with exit code ${errno}`) +} + +function parseModuleMetadata(raw: string): ModuleMetadata { + const parsed = JSON.parse(raw) as ModuleMetadata + + if (!parsed.moduleDir) { + throw new Error('KernelSU did not provide a moduleDir value.') + } + + return parsed +} + +async function runOkrmng(argumentsText: string) { + const metadata = await getModuleMetadata() + + if (!metadata) { + throw new Error('KernelSU WebUI APIs are unavailable in this environment.') + } + + const result = await exec(`${OKRMNG_COMMAND} ${argumentsText}`, { + cwd: metadata.moduleDir, + }) + + if (result.errno !== 0) { + throw commandError(result.stderr, result.stdout, result.errno) + } + + return result.stdout.trim() +} + +export function isKernelSuAvailable() { + if (typeof window === 'undefined') { + return false + } + + ensureKernelSuAlias() + return typeof window.ksu !== 'undefined' +} + +export function requestEdgeToEdge() { + if (!isKernelSuAvailable()) { + return + } + + try { + enableEdgeToEdge(true) + } catch { + // Ignore optional runtime helpers when previewing in non-KernelSU environments. + } +} + +export async function getModuleMetadata() { + if (!isKernelSuAvailable()) { + return null + } + + if (!moduleMetadataPromise) { + moduleMetadataPromise = Promise.resolve() + .then(() => parseModuleMetadata(moduleInfo())) + .catch((error) => { + moduleMetadataPromise = null + throw error + }) + } + + return moduleMetadataPromise +} + +export async function inspectConfig() { + const stdout = await runOkrmng('inspect --json') + return JSON.parse(stdout) as InspectPayload +} + +export async function replaceConfig(systemPackages: string[], privPackages: string[]) { + const systemCsv = systemPackages.join(',') + const privCsv = privPackages.join(',') + + await runOkrmng( + `replace --system ${shellQuote(systemCsv)} --priv ${shellQuote(privCsv)}`, + ) +} + +export function showNativeToast(message: string) { + if (!isKernelSuAvailable()) { + return + } + + try { + kernelToast(message) + } catch { + // Ignore native toast failures and let the in-page toast handle it. + } +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts new file mode 100644 index 0000000..2c3cfd7 --- /dev/null +++ b/webui/src/lib/types.ts @@ -0,0 +1,17 @@ +export type AppMode = 'none' | 'system' | 'priv' + +export interface InspectPayload { + systemApp: string[] + privApp: string[] + installedUserApps: string[] + missingConfiguredApps: string[] +} + +export interface ModuleMetadata { + moduleDir: string + id?: string + name?: string + version?: string + versionCode?: string + description?: string +} diff --git a/webui/src/lib/utils.ts b/webui/src/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/webui/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/webui/src/main.ts b/webui/src/main.ts new file mode 100644 index 0000000..a13715c --- /dev/null +++ b/webui/src/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue' +import 'vue-sonner/style.css' + +import App from './App.vue' +import { i18n } from './lib/i18n' +import './style.css' + +createApp(App).use(i18n).mount('#app') diff --git a/webui/src/style.css b/webui/src/style.css new file mode 100644 index 0000000..63f57cf --- /dev/null +++ b/webui/src/style.css @@ -0,0 +1,63 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 98%; + --foreground: 0 0% 8%; + --card: 0 0% 100%; + --card-foreground: 0 0% 8%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 8%; + --primary: 0 0% 12%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 94%; + --secondary-foreground: 0 0% 14%; + --muted: 0 0% 94%; + --muted-foreground: 0 0% 40%; + --accent: 0 0% 92%; + --accent-foreground: 0 0% 14%; + --destructive: 0 0% 16%; + --destructive-foreground: 0 0% 100%; + --border: 0 0% 84%; + --input: 0 0% 84%; + --ring: 0 0% 12%; + --radius: 1.5rem; + } + + * { + @apply border-border; + } + + html { + font-family: Manrope, 'Noto Sans SC', system-ui, sans-serif; + } + + body { + @apply min-h-screen bg-background text-foreground antialiased; + background-color: hsl(var(--background)); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + margin: 0; + } + + button, + input { + font: inherit; + } + + #app { + min-height: 100vh; + } +} + +@layer components { + .hero-shell { + background: hsl(var(--card)); + box-shadow: 0 32px 80px -68px rgba(0, 0, 0, 0.35); + position: relative; + } +} diff --git a/webui/tailwind.config.js b/webui/tailwind.config.js new file mode 100644 index 0000000..74a38ff --- /dev/null +++ b/webui/tailwind.config.js @@ -0,0 +1,71 @@ +import tailwindcssAnimate from 'tailwindcss-animate' + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ['class'], + content: ['./index.html', './src/**/*.{ts,tsx,vue}'], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + xl: 'calc(var(--radius) + 4px)', + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + fontFamily: { + sans: ['"Manrope"', '"Noto Sans SC"', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'], + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--reka-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--reka-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [tailwindcssAnimate], +} diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts new file mode 100644 index 0000000..0eb69aa --- /dev/null +++ b/webui/tailwind.config.ts @@ -0,0 +1,71 @@ +import type { Config } from 'tailwindcss' +import tailwindcssAnimate from 'tailwindcss-animate' + +export default { + darkMode: ['class'], + content: ['./index.html', './src/**/*.{ts,tsx,vue}'], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + xl: 'calc(var(--radius) + 4px)', + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + fontFamily: { + sans: ['"Manrope"', '"Noto Sans SC"', 'system-ui', 'sans-serif'], + mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'], + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--reka-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--reka-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [tailwindcssAnimate], +} satisfies Config diff --git a/webui/tsconfig.app.json b/webui/tsconfig.app.json new file mode 100644 index 0000000..21d7834 --- /dev/null +++ b/webui/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["dist", "node_modules", "../module/webroot"] +} diff --git a/webui/tsconfig.json b/webui/tsconfig.json new file mode 100644 index 0000000..aa3c04f --- /dev/null +++ b/webui/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/webui/tsconfig.node.json b/webui/tsconfig.node.json new file mode 100644 index 0000000..8fe2930 --- /dev/null +++ b/webui/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/webui/vite.config.ts b/webui/vite.config.ts new file mode 100644 index 0000000..9428540 --- /dev/null +++ b/webui/vite.config.ts @@ -0,0 +1,19 @@ +import path from 'node:path' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + base: './', + build: { + emptyOutDir: true, + outDir: '../module/webroot', + }, + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}) From 6de1e2fc97cfa1bf8cf6e01351ca2a4f9f7e755e Mon Sep 17 00:00:00 2001 From: Eltavine Date: Fri, 27 Mar 2026 14:57:08 +0800 Subject: [PATCH 2/2] feat: harden Android package handling and refine the KernelSU/WebUIX console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework both the Rust backend and the Vue WebUI so the module behaves more predictably across modern Android package-manager states, early-boot timing windows, and mixed KernelSU/WebUIX hosts. The overall goal of this change is to remove optimistic assumptions from package discovery and WebUI runtime integration, then replace them with explicit fallbacks, stronger validation, and clearer user-facing diagnostics. Improve Android metadata decoding and shared parsing utilities. - Add shared helpers under `shared/` so both `okrmng` and `oukaro` consume the same Android-specific parsing rules instead of drifting independently. - Introduce `shared/android_xml.rs` to decode plain UTF-8 XML, UTF-8 with BOM, UTF-16 XML with or without BOM, Android binary XML (`ABX\0`), and modified-UTF / CESU-8 payloads commonly emitted by Android framework code. - Align binary XML decoding more closely with AOSP behavior by handling entity references, CDATA sections, base64-encoded byte payloads, string interning, and ART-style multi-byte modified-UTF sequences. - Add `shared/android_package.rs` to validate package names against Android application ID rules before they can enter persisted config or boot-time apply paths. - Add `shared/android_package_state.rs` to model Android user availability as `installed && !hidden`, matching the semantics used by package user state in Android rather than treating install state alone as sufficient. - Add `shared/android_install_path.rs` so fallback package classification and metadata-derived code path resolution share one strict understanding of legitimate user-app install roots. Make `okrmng inspect` substantially more resilient and more informative. - Keep the existing `config.toml` format and CLI surface area intact, but extend inspect output with `installedUserAppsSource`, `systemUserStateSource`, and `warnings` so callers can tell whether data came from `pm list packages`, `packages.xml + package-restrictions.xml`, or a best-effort metadata fallback. - Fall back from `pm list packages -3 --user 0` to package metadata instead of failing outright when the package service is unavailable, incomplete, or too early in boot to answer shell queries reliably. - Parse both `publicFlags` and legacy `flags` fields when inferring whether a package should be treated as a user app. - Mirror Android's package visibility semantics by treating `hidden="true"` and legacy `blocked="true"` restriction entries as unavailable for user 0 instead of only checking `installed`. - Tighten path-based user-app inference so fallback classification accepts only known Android user-app roots such as `/data/app`, legacy `/data/app-private`, and adopted-storage `/mnt/expand//app`. - Keep malformed configured package names visible in inspect output, but emit explicit warnings so callers understand that those entries will be ignored at apply time. - Validate package names for `system-app add`, `priv-app add`, and `replace --system/--priv` so invalid identifiers are rejected before they reach disk. Strengthen `okrmng` config persistence semantics. - Continue writing config atomically, but explicitly sync the temporary file before rename and sync the containing directory after persistence so writes more closely match Android `AtomicFile` durability expectations. - Preserve sorted output through `BTreeSet`-backed config storage so generated TOML remains deterministic and friendlier to review. Make `oukaro` more defensive during boot-time apply. - Reuse the shared Android XML decoder for `packages.xml` and `package-restrictions.xml`, which prevents failures on devices that store these files as UTF-16 or Android binary XML instead of plain UTF-8 text. - Resolve package code paths from `packages.xml` before relying on shell probes so the module can still operate in early boot phases where the package service is not yet ready. - Treat `pm path --user 0` failures with empty stderr as "package missing" when they match AOSP shell behavior, instead of escalating them into hard errors that would hide otherwise-usable metadata fallbacks. - Reuse the same `installed && !hidden` availability model that backs `okrmng inspect`, keeping boot-time apply behavior aligned with the WebUI's view of what user-0 can actually access. - Refuse metadata-derived code paths outside known Android user-app install roots, even if those paths exist on disk, so stale or malformed metadata cannot redirect mounts toward arbitrary filesystem locations. - Sanitize runtime config entries before applying them: skip malformed package names, log why they were ignored, and resolve duplicate membership deterministically in favor of `priv-app`. - Make `oukaro` honor `OUKARO_MANAGER_CONFIG_PATH` just like `okrmng`, which improves testability and keeps both binaries consistent in recovery and debugging workflows. Harden `oukaro` config creation and filesystem operations. - Replace the previous direct write path for first-run config creation with an atomic temp-file write plus sync, matching the stronger persistence model already used on the management side. - Keep overlay mount setup defensive by removing the production `unwrap()` around mount option construction and reporting a real error if encoding fails. - Preserve staging-directory cleanup in package sync flows so interrupted copy operations do not leave partially prepared trees behind. Upgrade the WebUI runtime layer for real-world host variability. - Expand `module-api.ts` so the frontend can tolerate callback-based, promise-based, and direct-return exec bridge implementations instead of assuming one KernelSU host contract. - Normalize `window.ksu` and `window.kernelsu` so the app can operate across bridge naming differences without special-case code in the view layer. - Add runtime detection for preview, KernelSU Manager, and WebUIX hosts. - Probe WebUIX-specific bridges such as `wx:module` and `wx:pm` when they are available, rather than limiting the UI to official KernelSU helpers only. - Fall back to `/.package//...` info and icon resources on compatible WebUIX hosts via the shipped `webui/public/config.json` capability flag. - Expose richer runtime capability information to the page so unsupported or partially supported environments can surface warnings instead of failing silently. Refine the WebUI experience and make state handling more robust. - Keep the page single-screen and mobile-first, but add clearer environment badges, warning alerts, reboot-required guidance, and more precise host/runtime status messaging. - Replace leftover generic template copy with Oukaro-specific wording such as "System app configuration console" / "系统应用配置控制台" so the interface reads like a real module console rather than a scaffold. - Persist the selected locale in local storage and restore it automatically on the next load. - Persist unsaved drafts and the active search term, restore them when the server-side base assignment set still matches, and notify the user with toasts when a draft was successfully recovered. - Limit the default visible package list and matching search results to 10 items at a time, then provide explicit expand/collapse controls instead of eagerly rendering the entire package set inside a mobile WebView. - Lazy-load package details only for the currently visible rows so large app lists do not immediately trigger full metadata fetches. - Preserve package-name-only operation when richer package metadata is not available, but show app labels, versions, icons, and fallback initials when the host can supply them. - Keep local draft state intact when save fails, allowing the user to inspect the error and retry without rebuilding their selections. Polish branding and mobile WebView integration. - Embed the Karo SVG as the favicon and in-page branding element so the WebUI no longer looks like a stock Vite starter. - Update the document title to `OukaroManager` and load `/internal/insets.css` with `viewport-fit=cover` so supported hosts can honor safe-area insets correctly. - Add `100dvh` and inset-aware page padding in CSS to improve layout stability on modern Android WebViews and edge-to-edge hosts. Keep documentation aligned with the implemented runtime behavior. - Update `README.md` to explicitly mention that the built WebUI now ships `webroot/config.json` in order to enable `/.package/...` package info and icon fetching on compatible WebUIX hosts. - Preserve the documented model that WebUI saves only update `config.toml`, while actual mounts are still applied during the next boot's post-mount phase. Expand regression coverage across both Rust binaries. - Add tests for Android binary XML decoding, UTF-16 XML decoding, modified-UTF handling, package-state availability semantics, package-name validation, and stricter install-root recognition. - Add tests for restriction parsing with `installed`, `hidden`, and legacy `blocked` states. - Add tests that verify current package metadata is preferred over stale backup files, invalid current XML still fails loudly, system-partition code paths are rejected during metadata fallback, and package-path parsing handles split APK output correctly. - Add coverage for atomic config creation on the `oukaro` side so default config materialization is exercised directly. Validation: - `cargo test` in `okrmng` - `cargo test` in `oukaro` --- README.md | 1 + commit.txt | 164 ++++++++ okrmng/Cargo.lock | 7 + okrmng/Cargo.toml | 1 + okrmng/src/cli.rs | 517 ++++++++++++++++++++++--- okrmng/src/config.rs | 19 + okrmng/src/main.rs | 8 + oukaro/Cargo.lock | 7 + oukaro/Cargo.toml | 5 +- oukaro/src/config.rs | 90 ++++- oukaro/src/defs.rs | 9 + oukaro/src/main.rs | 106 +++++- oukaro/src/utils.rs | 417 ++++++++++++++++---- shared/android_install_path.rs | 96 +++++ shared/android_package.rs | 59 +++ shared/android_package_state.rs | 44 +++ shared/android_xml.rs | 657 ++++++++++++++++++++++++++++++++ webui/index.html | 5 +- webui/public/config.json | 7 + webui/src/App.vue | 415 ++++++++++++++++++-- webui/src/lib/i18n.ts | 77 +++- webui/src/lib/module-api.ts | 620 ++++++++++++++++++++++++++++-- webui/src/lib/types.ts | 34 ++ webui/src/style.css | 5 + 24 files changed, 3171 insertions(+), 199 deletions(-) create mode 100644 commit.txt create mode 100644 shared/android_install_path.rs create mode 100644 shared/android_package.rs create mode 100644 shared/android_package_state.rs create mode 100644 shared/android_xml.rs create mode 100644 webui/public/config.json diff --git a/README.md b/README.md index 55f8f24..0756aac 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ okrmng replace --system "bin.mt.plus" --priv "com.termux" - 无直接系统分区修改 | No direct system partition modifications - WebUI 保存只会更新 `config.toml`,需要重启后由模块在 `post-mount` 阶段应用挂载 | Saving in WebUI only updates `config.toml`; the module applies mounts during the next boot's `post-mount` stage - WebUI 和 `okrmng inspect --json` 的“已安装用户应用”语义固定为主用户(system user / user 0),避免多用户/工作资料夹环境下的范围歧义 | The "installed user apps" view in WebUI and `okrmng inspect --json` is intentionally scoped to the primary Android user (system user / user 0) to avoid ambiguity on multi-user and work-profile devices +- WebUI 构建产物会包含 `webroot/config.json`,为兼容的 WebUIX 宿主启用 `/.package/...` 图标与信息资源获取 | The WebUI build ships `webroot/config.json` to enable `/.package/...` icon and info resource fetching on compatible WebUIX hosts - 兼容性以“尽量适配”为目标,具体表现仍取决于 Android 版本、ROM 策略和权限模型 | Compatibility is best-effort and still depends on the Android version, ROM policy, and permission model - **WebUIX兼容**,可增强模块管理体验 | **WebUIX compatible** for enhanced module management experience - WebUI 使用 `okrmng inspect --json` 读取状态,使用 `okrmng replace` 原子写回配置 | The WebUI reads state through `okrmng inspect --json` and writes config atomically with `okrmng replace` diff --git a/commit.txt b/commit.txt new file mode 100644 index 0000000..b48f16e --- /dev/null +++ b/commit.txt @@ -0,0 +1,164 @@ +feat: harden Android package handling and refine the KernelSU/WebUIX console + +Rework both the Rust backend and the Vue WebUI so the module behaves more +predictably across modern Android package-manager states, early-boot timing +windows, and mixed KernelSU/WebUIX hosts. The overall goal of this change is +to remove optimistic assumptions from package discovery and WebUI runtime +integration, then replace them with explicit fallbacks, stronger validation, +and clearer user-facing diagnostics. + +Improve Android metadata decoding and shared parsing utilities. +- Add shared helpers under `shared/` so both `okrmng` and `oukaro` consume the + same Android-specific parsing rules instead of drifting independently. +- Introduce `shared/android_xml.rs` to decode plain UTF-8 XML, UTF-8 with BOM, + UTF-16 XML with or without BOM, Android binary XML (`ABX\0`), and + modified-UTF / CESU-8 payloads commonly emitted by Android framework code. +- Align binary XML decoding more closely with AOSP behavior by handling + entity references, CDATA sections, base64-encoded byte payloads, string + interning, and ART-style multi-byte modified-UTF sequences. +- Add `shared/android_package.rs` to validate package names against Android + application ID rules before they can enter persisted config or boot-time + apply paths. +- Add `shared/android_package_state.rs` to model Android user availability as + `installed && !hidden`, matching the semantics used by package user state in + Android rather than treating install state alone as sufficient. +- Add `shared/android_install_path.rs` so fallback package classification and + metadata-derived code path resolution share one strict understanding of + legitimate user-app install roots. + +Make `okrmng inspect` substantially more resilient and more informative. +- Keep the existing `config.toml` format and CLI surface area intact, but + extend inspect output with `installedUserAppsSource`, + `systemUserStateSource`, and `warnings` so callers can tell whether data + came from `pm list packages`, `packages.xml + package-restrictions.xml`, or + a best-effort metadata fallback. +- Fall back from `pm list packages -3 --user 0` to package metadata instead of + failing outright when the package service is unavailable, incomplete, or too + early in boot to answer shell queries reliably. +- Parse both `publicFlags` and legacy `flags` fields when inferring whether a + package should be treated as a user app. +- Mirror Android's package visibility semantics by treating + `hidden="true"` and legacy `blocked="true"` restriction entries as + unavailable for user 0 instead of only checking `installed`. +- Tighten path-based user-app inference so fallback classification accepts + only known Android user-app roots such as `/data/app`, + legacy `/data/app-private`, and adopted-storage + `/mnt/expand//app`. +- Keep malformed configured package names visible in inspect output, but emit + explicit warnings so callers understand that those entries will be ignored + at apply time. +- Validate package names for `system-app add`, `priv-app add`, and + `replace --system/--priv` so invalid identifiers are rejected before they + reach disk. + +Strengthen `okrmng` config persistence semantics. +- Continue writing config atomically, but explicitly sync the temporary file + before rename and sync the containing directory after persistence so writes + more closely match Android `AtomicFile` durability expectations. +- Preserve sorted output through `BTreeSet`-backed config storage so generated + TOML remains deterministic and friendlier to review. + +Make `oukaro` more defensive during boot-time apply. +- Reuse the shared Android XML decoder for `packages.xml` and + `package-restrictions.xml`, which prevents failures on devices that store + these files as UTF-16 or Android binary XML instead of plain UTF-8 text. +- Resolve package code paths from `packages.xml` before relying on shell + probes so the module can still operate in early boot phases where the + package service is not yet ready. +- Treat `pm path --user 0` failures with empty stderr as "package missing" + when they match AOSP shell behavior, instead of escalating them into hard + errors that would hide otherwise-usable metadata fallbacks. +- Reuse the same `installed && !hidden` availability model that backs + `okrmng inspect`, keeping boot-time apply behavior aligned with the WebUI's + view of what user-0 can actually access. +- Refuse metadata-derived code paths outside known Android user-app install + roots, even if those paths exist on disk, so stale or malformed metadata + cannot redirect mounts toward arbitrary filesystem locations. +- Sanitize runtime config entries before applying them: skip malformed package + names, log why they were ignored, and resolve duplicate membership + deterministically in favor of `priv-app`. +- Make `oukaro` honor `OUKARO_MANAGER_CONFIG_PATH` just like `okrmng`, which + improves testability and keeps both binaries consistent in recovery and + debugging workflows. + +Harden `oukaro` config creation and filesystem operations. +- Replace the previous direct write path for first-run config creation with an + atomic temp-file write plus sync, matching the stronger persistence model + already used on the management side. +- Keep overlay mount setup defensive by removing the production `unwrap()` + around mount option construction and reporting a real error if encoding + fails. +- Preserve staging-directory cleanup in package sync flows so interrupted copy + operations do not leave partially prepared trees behind. + +Upgrade the WebUI runtime layer for real-world host variability. +- Expand `module-api.ts` so the frontend can tolerate callback-based, + promise-based, and direct-return exec bridge implementations instead of + assuming one KernelSU host contract. +- Normalize `window.ksu` and `window.kernelsu` so the app can operate across + bridge naming differences without special-case code in the view layer. +- Add runtime detection for preview, KernelSU Manager, and WebUIX hosts. +- Probe WebUIX-specific bridges such as `wx:module` and `wx:pm` when they are + available, rather than limiting the UI to official KernelSU helpers only. +- Fall back to `/.package//...` info and icon resources on compatible + WebUIX hosts via the shipped `webui/public/config.json` capability flag. +- Expose richer runtime capability information to the page so unsupported or + partially supported environments can surface warnings instead of failing + silently. + +Refine the WebUI experience and make state handling more robust. +- Keep the page single-screen and mobile-first, but add clearer environment + badges, warning alerts, reboot-required guidance, and more precise + host/runtime status messaging. +- Replace leftover generic template copy with Oukaro-specific wording such as + "System app configuration console" / "系统应用配置控制台" so the interface + reads like a real module console rather than a scaffold. +- Persist the selected locale in local storage and restore it automatically on + the next load. +- Persist unsaved drafts and the active search term, restore them when the + server-side base assignment set still matches, and notify the user with + toasts when a draft was successfully recovered. +- Limit the default visible package list and matching search results to 10 + items at a time, then provide explicit expand/collapse controls instead of + eagerly rendering the entire package set inside a mobile WebView. +- Lazy-load package details only for the currently visible rows so large app + lists do not immediately trigger full metadata fetches. +- Preserve package-name-only operation when richer package metadata is not + available, but show app labels, versions, icons, and fallback initials when + the host can supply them. +- Keep local draft state intact when save fails, allowing the user to inspect + the error and retry without rebuilding their selections. + +Polish branding and mobile WebView integration. +- Embed the Karo SVG as the favicon and in-page branding element so the WebUI + no longer looks like a stock Vite starter. +- Update the document title to `OukaroManager` and load + `/internal/insets.css` with `viewport-fit=cover` so supported hosts can + honor safe-area insets correctly. +- Add `100dvh` and inset-aware page padding in CSS to improve layout stability + on modern Android WebViews and edge-to-edge hosts. + +Keep documentation aligned with the implemented runtime behavior. +- Update `README.md` to explicitly mention that the built WebUI now ships + `webroot/config.json` in order to enable `/.package/...` package info and + icon fetching on compatible WebUIX hosts. +- Preserve the documented model that WebUI saves only update `config.toml`, + while actual mounts are still applied during the next boot's post-mount + phase. + +Expand regression coverage across both Rust binaries. +- Add tests for Android binary XML decoding, UTF-16 XML decoding, modified-UTF + handling, package-state availability semantics, package-name validation, and + stricter install-root recognition. +- Add tests for restriction parsing with `installed`, `hidden`, and legacy + `blocked` states. +- Add tests that verify current package metadata is preferred over stale backup + files, invalid current XML still fails loudly, system-partition code paths + are rejected during metadata fallback, and package-path parsing handles + split APK output correctly. +- Add coverage for atomic config creation on the `oukaro` side so default + config materialization is exercised directly. + +Validation: +- `cargo test` in `okrmng` +- `cargo test` in `oukaro` diff --git a/okrmng/Cargo.lock b/okrmng/Cargo.lock index 67d53c8..5914b22 100644 --- a/okrmng/Cargo.lock +++ b/okrmng/Cargo.lock @@ -64,6 +64,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -243,6 +249,7 @@ name = "okrmng" version = "0.1.0" dependencies = [ "anyhow", + "cesu8", "clap", "quick-xml", "serde", diff --git a/okrmng/Cargo.toml b/okrmng/Cargo.toml index 747be22..42cea37 100644 --- a/okrmng/Cargo.toml +++ b/okrmng/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] anyhow = "1.0.100" +cesu8 = "1.1.0" clap = { version = "4.5.51", features = ["derive"] } quick-xml = "0.38.3" serde = { version = "1.0.228", features = ["derive"] } diff --git a/okrmng/src/cli.rs b/okrmng/src/cli.rs index 2c65bb3..e9ae673 100644 --- a/okrmng/src/cli.rs +++ b/okrmng/src/cli.rs @@ -4,16 +4,60 @@ use std::{ process::Command, }; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use quick_xml::{Reader, events::Event}; use serde::Serialize; +use crate::android_install_path::has_known_user_app_prefix; +use crate::android_package::{is_valid_package_name, validate_package_name}; +use crate::android_package_state::SystemUserPackageState; +use crate::android_xml::{parse_boolish, parse_i64ish, read_xmlish_text}; use crate::config::{App, Config}; use crate::defs::{PACKAGES_XML_PATHS, SYSTEM_USER_ID, SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS}; const APPLICATION_INFO_FLAG_SYSTEM: i64 = 1 << 0; +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum InstalledUserAppsSource { + PmListPackages, + PackagesXmlAndRestrictions, + PackagesXmlBestEffort, +} + +impl InstalledUserAppsSource { + fn as_str(self) -> &'static str { + match self { + Self::PmListPackages => "pmListPackages", + Self::PackagesXmlAndRestrictions => "packagesXmlAndRestrictions", + Self::PackagesXmlBestEffort => "packagesXmlBestEffort", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum SystemUserStateSource { + PackageRestrictions, +} + +impl SystemUserStateSource { + fn as_str(self) -> &'static str { + match self { + Self::PackageRestrictions => "packageRestrictions", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct InstalledUserAppsListing { + packages: BTreeSet, + source: InstalledUserAppsSource, + system_user_state_source: Option, + warnings: Vec, +} + #[derive(Parser)] #[command(author, version = "0.1", about, long_about = None)] struct Args { @@ -74,6 +118,11 @@ struct InspectOutput { priv_app: Vec, installed_user_apps: Vec, missing_configured_apps: Vec, + installed_user_apps_source: InstalledUserAppsSource, + #[serde(skip_serializing_if = "Option::is_none")] + system_user_state_source: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + warnings: Vec, } pub fn run() -> Result<()> { @@ -86,6 +135,7 @@ pub fn run() -> Result<()> { match command { PrivApp::Add { package } => { + validate_package_name(&package)?; ensure_not_in_other_group(&config.app.system_app, &package, "system-app")?; config.app.priv_app.insert(package); println!("added new package"); @@ -104,6 +154,7 @@ pub fn run() -> Result<()> { match command { SystemApp::Add { package } => { + validate_package_name(&package)?; ensure_not_in_other_group(&config.app.priv_app, &package, "priv-app")?; config.app.system_app.insert(package); println!("added new package"); @@ -134,16 +185,26 @@ pub fn run() -> Result<()> { "installed_user_apps={}", inspect.installed_user_apps.join(",") ); + println!( + "installed_user_apps_source={}", + inspect.installed_user_apps_source.as_str() + ); + if let Some(source) = inspect.system_user_state_source { + println!("system_user_state_source={}", source.as_str()); + } println!( "missing_configured_apps={}", inspect.missing_configured_apps.join(",") ); + for warning in &inspect.warnings { + eprintln!("warning: {warning}"); + } } } Commands::Replace { system, priv_app } => { let mut config = Config::new()?; - let system_packages = parse_package_csv(&system); - let priv_packages = parse_package_csv(&priv_app); + let system_packages = parse_package_csv(&system)?; + let priv_packages = parse_package_csv(&priv_app)?; validate_package_sets(&system_packages, &priv_packages)?; @@ -169,13 +230,19 @@ fn ensure_not_in_other_group( Ok(()) } -fn parse_package_csv(input: &str) -> BTreeSet { - input +fn parse_package_csv(input: &str) -> Result> { + let mut packages = BTreeSet::new(); + + for package in input .split(',') .map(str::trim) .filter(|entry| !entry.is_empty()) - .map(ToOwned::to_owned) - .collect() + { + validate_package_name(package)?; + packages.insert(package.to_owned()); + } + + Ok(packages) } fn validate_package_sets( @@ -197,7 +264,7 @@ fn validate_package_sets( Ok(()) } -fn list_installed_user_apps() -> Result> { +fn list_installed_user_apps() -> Result { match list_installed_user_apps_from_pm() { Ok(packages) => Ok(packages), Err(pm_error) => list_installed_user_apps_from_packages_xml().with_context(|| { @@ -208,7 +275,7 @@ fn list_installed_user_apps() -> Result> { } } -fn list_installed_user_apps_from_pm() -> Result> { +fn list_installed_user_apps_from_pm() -> Result { let output = Command::new("pm") .args(["list", "packages", "-3", "--user", SYSTEM_USER_ID]) .output() @@ -224,25 +291,42 @@ fn list_installed_user_apps_from_pm() -> Result> { } let stdout = String::from_utf8_lossy(&output.stdout); - Ok(parse_pm_list_output(&stdout)) + Ok(InstalledUserAppsListing { + packages: parse_pm_list_output(&stdout), + source: InstalledUserAppsSource::PmListPackages, + system_user_state_source: None, + warnings: Vec::new(), + }) } -fn list_installed_user_apps_from_packages_xml() -> Result> { +fn list_installed_user_apps_from_packages_xml() -> Result { let packages = list_known_user_apps_from_packages_xml()?; - if packages.is_empty() { - return Ok(packages); + match read_system_user_package_states() { + Ok(Some(system_user_package_states)) => Ok(InstalledUserAppsListing { + packages: filter_installed_for_system_user(packages, &system_user_package_states), + source: InstalledUserAppsSource::PackagesXmlAndRestrictions, + system_user_state_source: Some(SystemUserStateSource::PackageRestrictions), + warnings: vec![format!( + "Package discovery fell back to packages.xml plus package-restrictions metadata because `pm list packages -3 --user {SYSTEM_USER_ID}` was unavailable." + )], + }), + Ok(None) => Ok(InstalledUserAppsListing { + packages, + source: InstalledUserAppsSource::PackagesXmlBestEffort, + system_user_state_source: None, + warnings: vec![format!( + "No package-restrictions metadata was found for system user {SYSTEM_USER_ID}; returning a packages.xml best-effort set." + )], + }), + Err(error) => Ok(InstalledUserAppsListing { + packages, + source: InstalledUserAppsSource::PackagesXmlBestEffort, + system_user_state_source: None, + warnings: vec![format!( + "Failed to read package-restrictions metadata for system user {SYSTEM_USER_ID}: {error:#}; returning a packages.xml best-effort set." + )], + }), } - - let system_user_package_states = read_system_user_package_states()?.ok_or_else(|| { - anyhow!( - "No readable package-restrictions metadata was found for system user {SYSTEM_USER_ID}" - ) - })?; - - Ok(filter_installed_for_system_user( - packages, - &system_user_package_states, - )) } fn list_known_user_apps_from_packages_xml() -> Result> { @@ -268,7 +352,7 @@ fn list_known_user_apps_from_packages_xml() -> Result> { } } -fn read_system_user_package_states() -> Result>> { +fn read_system_user_package_states() -> Result>> { let mut last_error = None; for restrictions_xml in SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS { @@ -292,15 +376,17 @@ fn read_system_user_package_states() -> Result>> { } fn read_installed_user_apps_from_packages_xml(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; + let contents = + read_xmlish_text(path).with_context(|| format!("Failed to read {}", path.display()))?; parse_packages_xml_user_apps(&contents) .with_context(|| format!("Failed to parse {}", path.display())) } -fn read_package_states_from_restrictions_file(path: &Path) -> Result> { - let contents = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read {}", path.display()))?; +fn read_package_states_from_restrictions_file( + path: &Path, +) -> Result> { + let contents = + read_xmlish_text(path).with_context(|| format!("Failed to read {}", path.display()))?; parse_package_restrictions_xml(&contents) .with_context(|| format!("Failed to parse {}", path.display())) } @@ -338,8 +424,8 @@ fn parse_packages_xml_user_apps(contents: &str) -> Result> { match attribute.key.as_ref() { b"name" => package_name = Some(value), b"codePath" => code_path = Some(value), - b"publicFlags" => public_flags = value.parse::().ok(), - b"system" => legacy_system = Some(value.eq_ignore_ascii_case("true")), + b"publicFlags" | b"flags" => public_flags = parse_i64ish(&value), + b"system" => legacy_system = parse_boolish(&value), _ => {} } } @@ -356,7 +442,9 @@ fn parse_packages_xml_user_apps(contents: &str) -> Result> { } } -fn parse_package_restrictions_xml(contents: &str) -> Result> { +fn parse_package_restrictions_xml( + contents: &str, +) -> Result> { let mut reader = Reader::from_str(contents); reader.config_mut().trim_text(true); let mut states = BTreeMap::new(); @@ -367,7 +455,7 @@ fn parse_package_restrictions_xml(contents: &str) -> Result { let mut package_name = None; - let mut installed = true; + let mut state = SystemUserPackageState::default(); for attribute in event.attributes().with_checks(false) { let attribute = attribute?; @@ -377,13 +465,18 @@ fn parse_package_restrictions_xml(contents: &str) -> Result package_name = Some(value), - b"inst" | b"installed" => installed = !value.eq_ignore_ascii_case("false"), + b"inst" | b"installed" => { + state.installed = parse_boolish(&value).unwrap_or(state.installed) + } + b"hidden" | b"blocked" => { + state.hidden = parse_boolish(&value).unwrap_or(state.hidden) + } _ => {} } } if let Some(package_name) = package_name { - states.insert(package_name, installed); + states.insert(package_name, state); } } Event::Eof => return Ok(states), @@ -401,13 +494,12 @@ fn is_user_app(public_flags: Option, legacy_system: Option, code_path return !legacy_system; } - code_path.starts_with("/data/app/") - || (code_path.starts_with("/mnt/expand/") && code_path.contains("/app/")) + has_known_user_app_prefix(Path::new(code_path)) } fn filter_installed_for_system_user( packages: BTreeSet, - system_user_package_states: &BTreeMap, + system_user_package_states: &BTreeMap, ) -> BTreeSet { packages .into_iter() @@ -415,24 +507,43 @@ fn filter_installed_for_system_user( system_user_package_states .get(package) .copied() - .unwrap_or(true) + .unwrap_or_default() + .is_available() }) .collect() } -fn build_inspect_output(config: &App, installed_user_apps: &BTreeSet) -> InspectOutput { +fn build_inspect_output( + config: &App, + installed_user_apps: &InstalledUserAppsListing, +) -> InspectOutput { let configured: BTreeSet = config.system_app.union(&config.priv_app).cloned().collect(); let missing_configured_apps = configured - .difference(installed_user_apps) + .difference(&installed_user_apps.packages) .cloned() .collect(); + let mut warnings = installed_user_apps.warnings.clone(); + let invalid_configured_packages = configured + .iter() + .filter(|package| !is_valid_package_name(package)) + .cloned() + .collect::>(); + if !invalid_configured_packages.is_empty() { + warnings.push(format!( + "Config contains invalid Android package names that will be ignored during apply: {}", + invalid_configured_packages.join(", ") + )); + } InspectOutput { system_app: config.system_app.iter().cloned().collect(), priv_app: config.priv_app.iter().cloned().collect(), - installed_user_apps: installed_user_apps.iter().cloned().collect(), + installed_user_apps: installed_user_apps.packages.iter().cloned().collect(), missing_configured_apps, + installed_user_apps_source: installed_user_apps.source, + system_user_state_source: installed_user_apps.system_user_state_source, + warnings, } } @@ -441,14 +552,17 @@ mod tests { use std::collections::{BTreeMap, BTreeSet}; use super::{ - App, InspectOutput, build_inspect_output, filter_installed_for_system_user, + App, InspectOutput, InstalledUserAppsListing, InstalledUserAppsSource, + SystemUserStateSource, build_inspect_output, filter_installed_for_system_user, parse_package_csv, parse_package_restrictions_xml, parse_packages_xml_user_apps, - parse_pm_list_output, read_installed_user_apps_from_packages_xml, validate_package_sets, + parse_pm_list_output, read_installed_user_apps_from_packages_xml, + read_package_states_from_restrictions_file, validate_package_sets, }; + use crate::android_package_state::SystemUserPackageState; #[test] fn csv_parser_trims_entries_and_ignores_empty_values() { - let parsed = parse_package_csv(" com.example.alpha ,,com.example.beta, "); + let parsed = parse_package_csv(" com.example.alpha ,,com.example.beta, ").unwrap(); assert_eq!( parsed, @@ -459,6 +573,13 @@ mod tests { ); } + #[test] + fn csv_parser_rejects_invalid_android_package_names() { + let error = parse_package_csv("com.example.valid,bad/package").unwrap_err(); + + assert!(error.to_string().contains("bad/package")); + } + #[test] fn pm_list_parser_extracts_user_packages() { let parsed = parse_pm_list_output( @@ -498,6 +619,36 @@ mod tests { ); } + #[test] + fn packages_xml_parser_supports_legacy_flags_attribute() { + let parsed = parse_packages_xml_user_apps( + r#" + + + + + "#, + ) + .unwrap(); + + assert_eq!(parsed, BTreeSet::from(["com.example.user".to_string()])); + } + + #[test] + fn packages_xml_parser_supports_legacy_data_app_private_paths() { + let parsed = parse_packages_xml_user_apps( + r#" + + + + + "#, + ) + .unwrap(); + + assert_eq!(parsed, BTreeSet::from(["com.example.locked".to_string()])); + } + #[test] fn package_restrictions_parser_reads_system_user_install_states() { let parsed = parse_package_restrictions_xml( @@ -515,10 +666,67 @@ mod tests { assert_eq!( parsed, BTreeMap::from([ - ("com.example.alpha".to_string(), true), - ("com.example.beta".to_string(), false), - ("com.example.delta".to_string(), true), - ("com.example.gamma".to_string(), false), + ( + "com.example.alpha".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + } + ), + ( + "com.example.beta".to_string(), + SystemUserPackageState { + installed: false, + hidden: false, + } + ), + ( + "com.example.delta".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + } + ), + ( + "com.example.gamma".to_string(), + SystemUserPackageState { + installed: false, + hidden: false, + } + ), + ]) + ); + } + + #[test] + fn package_restrictions_parser_reads_hidden_and_legacy_blocked_states() { + let parsed = parse_package_restrictions_xml( + r#" + + + "#, + ) + .unwrap(); + + assert_eq!( + parsed, + BTreeMap::from([ + ( + "com.example.blocked".to_string(), + SystemUserPackageState { + installed: true, + hidden: true, + } + ), + ( + "com.example.hidden".to_string(), + SystemUserPackageState { + installed: true, + hidden: true, + } + ), ]) ); } @@ -531,8 +739,20 @@ mod tests { "com.example.gamma".to_string(), ]); let system_user_states = BTreeMap::from([ - ("com.example.alpha".to_string(), true), - ("com.example.beta".to_string(), false), + ( + "com.example.alpha".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + }, + ), + ( + "com.example.beta".to_string(), + SystemUserPackageState { + installed: false, + hidden: false, + }, + ), ]); let filtered = filter_installed_for_system_user(packages, &system_user_states); @@ -546,6 +766,34 @@ mod tests { ); } + #[test] + fn system_user_filter_excludes_packages_hidden_for_user_zero() { + let packages = BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.beta".to_string(), + ]); + let system_user_states = BTreeMap::from([ + ( + "com.example.alpha".to_string(), + SystemUserPackageState { + installed: true, + hidden: true, + }, + ), + ( + "com.example.beta".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + }, + ), + ]); + + let filtered = filter_installed_for_system_user(packages, &system_user_states); + + assert_eq!(filtered, BTreeSet::from(["com.example.beta".to_string()])); + } + #[test] fn packages_xml_reader_keeps_current_empty_state_instead_of_falling_back_to_stale_backup() { let dir = tempfile::tempdir().unwrap(); @@ -579,6 +827,112 @@ mod tests { assert!(read_installed_user_apps_from_packages_xml(¤t).is_err()); } + #[test] + fn packages_xml_reader_supports_android_binary_xml() { + let dir = tempfile::tempdir().unwrap(); + let packages_xml = dir.path().join("packages.xml"); + + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(8_u16).to_be_bytes()); + abx.extend_from_slice(b"packages"); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(7_u16).to_be_bytes()); + abx.extend_from_slice(b"package"); + + abx.push(0x2F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(4_u16).to_be_bytes()); + abx.extend_from_slice(b"name"); + abx.extend_from_slice(&(16_u16).to_be_bytes()); + abx.extend_from_slice(b"com.example.user"); + + abx.push(0x2F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(8_u16).to_be_bytes()); + abx.extend_from_slice(b"codePath"); + let code_path = "/data/app/~~abc/com.example.user/base.apk"; + abx.extend_from_slice(&(code_path.len() as u16).to_be_bytes()); + abx.extend_from_slice(code_path.as_bytes()); + + abx.push(0x7F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(11_u16).to_be_bytes()); + abx.extend_from_slice(b"publicFlags"); + abx.extend_from_slice(&(0_i32).to_be_bytes()); + + abx.push(0x03); + abx.extend_from_slice(&1_u16.to_be_bytes()); + abx.push(0x03); + abx.extend_from_slice(&0_u16.to_be_bytes()); + abx.push(0x01); + + std::fs::write(&packages_xml, abx).unwrap(); + + let packages = read_installed_user_apps_from_packages_xml(&packages_xml).unwrap(); + + assert_eq!(packages, BTreeSet::from(["com.example.user".to_string()])); + } + + #[test] + fn package_restrictions_reader_supports_android_binary_xml() { + let dir = tempfile::tempdir().unwrap(); + let restrictions = dir.path().join("package-restrictions.xml"); + + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(20_u16).to_be_bytes()); + abx.extend_from_slice(b"package-restrictions"); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(3_u16).to_be_bytes()); + abx.extend_from_slice(b"pkg"); + + abx.push(0x2F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(4_u16).to_be_bytes()); + abx.extend_from_slice(b"name"); + abx.extend_from_slice(&(16_u16).to_be_bytes()); + abx.extend_from_slice(b"com.example.user"); + + abx.push(0xCF); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(4_u16).to_be_bytes()); + abx.extend_from_slice(b"inst"); + + abx.push(0x03); + abx.extend_from_slice(&1_u16.to_be_bytes()); + abx.push(0x03); + abx.extend_from_slice(&0_u16.to_be_bytes()); + abx.push(0x01); + + std::fs::write(&restrictions, abx).unwrap(); + + let states = read_package_states_from_restrictions_file(&restrictions).unwrap(); + + assert_eq!( + states, + BTreeMap::from([( + "com.example.user".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + } + )]) + ); + } + #[test] fn validate_package_sets_rejects_duplicate_membership() { let error = validate_package_sets( @@ -607,8 +961,14 @@ mod tests { "com.example.alpha".to_string(), "com.example.delta".to_string(), ]); + let installed_listing = InstalledUserAppsListing { + packages: installed, + source: InstalledUserAppsSource::PmListPackages, + system_user_state_source: None, + warnings: Vec::new(), + }; - let output = build_inspect_output(&app, &installed); + let output = build_inspect_output(&app, &installed_listing); assert_eq!( output, @@ -626,7 +986,58 @@ mod tests { "com.example.beta".to_string(), "com.example.gamma".to_string(), ], + installed_user_apps_source: InstalledUserAppsSource::PmListPackages, + system_user_state_source: None, + warnings: Vec::new(), + } + ); + } + + #[test] + fn inspect_output_preserves_metadata_fallback_details() { + let app = App { + system_app: BTreeSet::from(["com.example.alpha".to_string()]), + priv_app: BTreeSet::new(), + }; + let installed_listing = InstalledUserAppsListing { + packages: BTreeSet::from(["com.example.alpha".to_string()]), + source: InstalledUserAppsSource::PackagesXmlAndRestrictions, + system_user_state_source: Some(SystemUserStateSource::PackageRestrictions), + warnings: vec!["fallback".to_string()], + }; + + let output = build_inspect_output(&app, &installed_listing); + + assert_eq!( + output, + InspectOutput { + system_app: vec!["com.example.alpha".to_string()], + priv_app: Vec::new(), + installed_user_apps: vec!["com.example.alpha".to_string()], + missing_configured_apps: Vec::new(), + installed_user_apps_source: InstalledUserAppsSource::PackagesXmlAndRestrictions, + system_user_state_source: Some(SystemUserStateSource::PackageRestrictions), + warnings: vec!["fallback".to_string()], } ); } + + #[test] + fn inspect_output_warns_about_invalid_configured_package_names() { + let app = App { + system_app: BTreeSet::from(["../escape".to_string()]), + priv_app: BTreeSet::new(), + }; + let installed_listing = InstalledUserAppsListing { + packages: BTreeSet::new(), + source: InstalledUserAppsSource::PmListPackages, + system_user_state_source: None, + warnings: Vec::new(), + }; + + let output = build_inspect_output(&app, &installed_listing); + + assert_eq!(output.warnings.len(), 1); + assert!(output.warnings[0].contains("../escape")); + } } diff --git a/okrmng/src/config.rs b/okrmng/src/config.rs index ff134bb..29a8fcb 100644 --- a/okrmng/src/config.rs +++ b/okrmng/src/config.rs @@ -69,6 +69,9 @@ fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { .with_context(|| format!("Failed to write temp config for {}", path.display()))?; temp.flush() .with_context(|| format!("Failed to flush temp config for {}", path.display()))?; + temp.as_file() + .sync_all() + .with_context(|| format!("Failed to sync temp config for {}", path.display()))?; if path.exists() { #[cfg(windows)] @@ -81,6 +84,22 @@ fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { temp.persist(path) .map_err(|err| err.error) .with_context(|| format!("Failed to persist config {}", path.display()))?; + sync_directory(&parent) + .with_context(|| format!("Failed to sync config directory {}", parent.display()))?; + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<()> { + let directory = fs::File::open(path) + .with_context(|| format!("Failed to open config directory {}", path.display()))?; + directory + .sync_all() + .with_context(|| format!("Failed to sync config directory {}", path.display())) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<()> { Ok(()) } diff --git a/okrmng/src/main.rs b/okrmng/src/main.rs index a23682f..88f5b75 100644 --- a/okrmng/src/main.rs +++ b/okrmng/src/main.rs @@ -1,3 +1,11 @@ +#[path = "../../shared/android_install_path.rs"] +mod android_install_path; +#[path = "../../shared/android_package.rs"] +mod android_package; +#[path = "../../shared/android_package_state.rs"] +mod android_package_state; +#[path = "../../shared/android_xml.rs"] +mod android_xml; mod cli; mod config; mod defs; diff --git a/oukaro/Cargo.lock b/oukaro/Cargo.lock index 83b51f2..01459cf 100644 --- a/oukaro/Cargo.lock +++ b/oukaro/Cargo.lock @@ -104,6 +104,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -328,6 +334,7 @@ name = "oukaro" version = "0.1.0" dependencies = [ "anyhow", + "cesu8", "chrono", "env_logger", "log", diff --git a/oukaro/Cargo.toml b/oukaro/Cargo.toml index 55947b4..8c15d0e 100644 --- a/oukaro/Cargo.toml +++ b/oukaro/Cargo.toml @@ -5,19 +5,18 @@ edition = "2024" [dependencies] anyhow = "1.0.100" +cesu8 = "1.1.0" chrono = "0.4.42" env_logger = "0.11.8" log = "0.4.28" quick-xml = "0.38.3" serde = { version = "1.0.228", features = ["serde_derive"] } +tempfile = "3.23.0" toml = "0.9.8" [target.'cfg(any(target_os = "android", target_os = "linux"))'.dependencies] rustix = { version = "1.1.2", features = ["mount"] } -[dev-dependencies] -tempfile = "3.23.0" - [profile.release] overflow-checks = false codegen-units = 1 diff --git a/oukaro/src/config.rs b/oukaro/src/config.rs index b35bef1..237f105 100644 --- a/oukaro/src/config.rs +++ b/oukaro/src/config.rs @@ -1,9 +1,15 @@ -use std::{collections::HashSet, fs, io::Write, path::Path}; +use std::{ + collections::HashSet, + fs, + io::Write, + path::{Path, PathBuf}, +}; -use anyhow::Result; +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use tempfile::NamedTempFile; -use crate::defs::CONFIG_PATH; +use crate::defs::config_path; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { @@ -28,14 +34,21 @@ impl Config { /// load config pub fn load_config(&mut self) -> Result<()> { - let config = Path::new(CONFIG_PATH); + let config_path = config_path(); + let config = Path::new(&config_path); if !config.exists() { - let toml = toml::to_string(&self).unwrap(); - let mut file = fs::File::create(config)?; - file.write_all(toml.as_bytes())?; + if let Some(parent) = config.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create config directory {}", parent.display()))?; + } + + let toml = toml::to_string(&self).context("serialize default config")?; + write_atomically(config, toml.as_bytes())?; } - let buf = fs::read_to_string(config)?; - let toml: Self = toml::from_str(buf.as_str())?; + let buf = fs::read_to_string(config) + .with_context(|| format!("read config {}", config.display()))?; + let toml: Self = toml::from_str(buf.as_str()) + .with_context(|| format!("parse config {}", config.display()))?; self.app = toml.app; Ok(()) } @@ -45,3 +58,62 @@ impl Config { self.app.clone() } } + +fn write_atomically(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let mut temp = NamedTempFile::new_in(&parent) + .with_context(|| format!("create temp file in {}", parent.display()))?; + temp.write_all(contents) + .with_context(|| format!("write temp config {}", path.display()))?; + temp.flush() + .with_context(|| format!("flush temp config {}", path.display()))?; + temp.as_file() + .sync_all() + .with_context(|| format!("sync temp config {}", path.display()))?; + + if path.exists() { + #[cfg(windows)] + { + fs::remove_file(path).with_context(|| format!("replace config {}", path.display()))?; + } + } + + temp.persist(path) + .map_err(|err| err.error) + .with_context(|| format!("persist config {}", path.display()))?; + sync_directory(&parent).with_context(|| format!("sync config directory {}", parent.display())) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<()> { + let directory = fs::File::open(path) + .with_context(|| format!("open config directory {}", path.display()))?; + directory + .sync_all() + .with_context(|| format!("sync config directory {}", path.display())) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::write_atomically; + + #[test] + fn atomic_writer_creates_complete_config_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + + write_atomically(&path, b"[app]\n").unwrap(); + + assert_eq!(std::fs::read_to_string(path).unwrap(), "[app]\n"); + } +} diff --git a/oukaro/src/defs.rs b/oukaro/src/defs.rs index aefaf4b..11cb1f0 100644 --- a/oukaro/src/defs.rs +++ b/oukaro/src/defs.rs @@ -1,4 +1,7 @@ +use std::path::PathBuf; + pub const CONFIG_PATH: &str = "/data/adb/modules/oukaro_manager/config.toml"; +pub const CONFIG_PATH_ENV: &str = "OUKARO_MANAGER_CONFIG_PATH"; pub const WORK_PATH: &str = "/data/adb/modules/oukaro_manager/work"; pub const LOWER_PATH: &str = "/system"; pub const UPPER_PATH: &str = "/data/adb/modules/oukaro_manager/system"; @@ -14,3 +17,9 @@ pub const SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS: &[&str] = &[ "/data/system/users/0/package-restrictions-backup.xml", "/data/system/users/0/package-restrictions.xml.bak", ]; + +pub fn config_path() -> PathBuf { + std::env::var_os(CONFIG_PATH_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(CONFIG_PATH)) +} diff --git a/oukaro/src/main.rs b/oukaro/src/main.rs index 526e951..510c94a 100644 --- a/oukaro/src/main.rs +++ b/oukaro/src/main.rs @@ -1,17 +1,33 @@ +#[path = "../../shared/android_install_path.rs"] +mod android_install_path; +#[path = "../../shared/android_package.rs"] +mod android_package; +#[path = "../../shared/android_package_state.rs"] +mod android_package_state; +#[path = "../../shared/android_xml.rs"] +mod android_xml; mod config; mod defs; mod utils; -use std::io::Write; +use std::{collections::HashSet, io::Write}; use anyhow::Result; use env_logger::Builder; use crate::{ + android_package::validate_package_name, defs::{LOWER_PATH, UPPER_PATH, WORK_PATH}, utils::{cleanup_unmanaged_packages, find_data_path, mount_overlyfs, sync_package_dir}, }; +struct ManagedApps { + system_keep: HashSet, + priv_keep: HashSet, + system_apply: Vec, + priv_apply: Vec, +} + fn init_logger() { let mut builder = Builder::new(); builder.format(|buf, record| { @@ -30,6 +46,48 @@ fn init_logger() { builder.filter_level(log::LevelFilter::Info).init(); } +fn sanitize_managed_apps(apps: config::App) -> ManagedApps { + let mut priv_keep = HashSet::new(); + for package in apps.priv_app { + if let Err(error) = validate_package_name(&package) { + log::warn!("skipping invalid priv-app config entry `{package}`: {error}"); + continue; + } + + priv_keep.insert(package); + } + + let mut system_keep = HashSet::new(); + for package in apps.system_app { + if let Err(error) = validate_package_name(&package) { + log::warn!("skipping invalid system-app config entry `{package}`: {error}"); + continue; + } + + if priv_keep.contains(&package) { + log::warn!( + "package `{package}` is configured in both priv-app and system-app; preferring priv-app" + ); + continue; + } + + system_keep.insert(package); + } + + let mut priv_apply = priv_keep.iter().cloned().collect::>(); + priv_apply.sort(); + + let mut system_apply = system_keep.iter().cloned().collect::>(); + system_apply.sort(); + + ManagedApps { + system_keep, + priv_keep, + system_apply, + priv_apply, + } +} + fn apply_saved_config() -> Result<()> { let mut config = config::Config::new(); let lower = std::path::Path::new(LOWER_PATH); @@ -52,13 +110,13 @@ fn apply_saved_config() -> Result<()> { )?; config.load_config()?; - let apps = config.get(); + let managed_apps = sanitize_managed_apps(config.get()); - cleanup_unmanaged_packages(&priv_root, &apps.priv_app)?; - cleanup_unmanaged_packages(&system_root, &apps.system_app)?; + cleanup_unmanaged_packages(&priv_root, &managed_apps.priv_keep)?; + cleanup_unmanaged_packages(&system_root, &managed_apps.system_keep)?; log::info!("handling system/priv-app"); - for app in apps.priv_app { + for app in managed_apps.priv_apply { match find_data_path(&app)? { Some(data_path) => { sync_package_dir(data_path, &priv_root, &app)?; @@ -69,7 +127,7 @@ fn apply_saved_config() -> Result<()> { } log::info!("handling system/app"); - for app in apps.system_app { + for app in managed_apps.system_apply { match find_data_path(&app)? { Some(data_path) => { sync_package_dir(data_path, &system_root, &app)?; @@ -96,3 +154,39 @@ fn main() { eprintln!("{:#?}", e.backtrace()); }) } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::config::App; + + use super::sanitize_managed_apps; + + #[test] + fn sanitize_managed_apps_skips_invalid_entries_and_prefers_priv_app() { + let managed = sanitize_managed_apps(App { + system_app: HashSet::from([ + "com.example.shared".to_string(), + "com.example.system".to_string(), + "../escape".to_string(), + ]), + priv_app: HashSet::from([ + "com.example.shared".to_string(), + "com.example.priv".to_string(), + "single".to_string(), + ]), + }); + + assert_eq!( + managed.priv_apply, + vec![ + "com.example.priv".to_string(), + "com.example.shared".to_string(), + ] + ); + assert_eq!(managed.system_apply, vec!["com.example.system".to_string()]); + assert!(managed.priv_keep.contains("com.example.shared")); + assert!(!managed.system_keep.contains("com.example.shared")); + } +} diff --git a/oukaro/src/utils.rs b/oukaro/src/utils.rs index 235e5c0..7ff0846 100644 --- a/oukaro/src/utils.rs +++ b/oukaro/src/utils.rs @@ -13,6 +13,9 @@ use rustix::mount::{MountFlags, mount}; #[cfg(any(target_os = "android", target_os = "linux"))] use std::ffi::{CStr, CString}; +use crate::android_install_path::normalize_user_app_code_path; +use crate::android_package_state::SystemUserPackageState; +use crate::android_xml::{parse_boolish, read_xmlish_text}; use crate::defs::{PACKAGES_XML_PATHS, SYSTEM_USER_ID, SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS}; #[cfg(any(target_os = "android", target_os = "linux"))] @@ -47,7 +50,8 @@ where upper = upper.display(), work = work.display() ); - let opts: Option<&CStr> = Some(&CString::new(opts).unwrap()); + let opts = CString::new(opts).context("encode overlay mount options")?; + let opts: Option<&CStr> = Some(opts.as_c_str()); mount("overlay", target, "overlay", MountFlags::empty(), opts)?; Ok(()) @@ -175,6 +179,25 @@ fn copy_dir_contents(from: &Path, to: &Path) -> Result<()> { /// get packge data path in =/data /// packge: packge name pub fn find_data_path(package: &str) -> Result> { + let packages_xml_data_path = match find_data_path_from_packages_xml(package) { + Ok(Some(data_dir)) => { + log::info!( + "{} path is {} (from packages.xml metadata)", + package, + data_dir.display() + ); + Some(data_dir) + } + Ok(None) => None, + Err(error) => { + log::warn!( + "failed to resolve package {} from packages.xml metadata: {error:#}", + package + ); + None + } + }; + match is_installed_for_system_user(package) { Ok(true) => {} Ok(false) => { @@ -186,6 +209,16 @@ pub fn find_data_path(package: &str) -> Result> { return Ok(None); } Err(error) => { + if let Some(data_path) = packages_xml_data_path { + log::warn!( + "could not confirm package {} for system user {}: {error:#}; using packages.xml code path {} as best-effort fallback", + package, + SYSTEM_USER_ID, + data_path.display() + ); + return Ok(Some(data_path)); + } + log::warn!( "could not confirm package {} for system user {}: {error:#}; skipping package for safety", package, @@ -195,46 +228,43 @@ pub fn find_data_path(package: &str) -> Result> { } } - match find_data_path_from_packages_xml(package) { - Ok(Some(data_dir)) => { - log::info!( - "{} path is {} (from packages.xml)", - package, - data_dir.display() - ); - return Ok(Some(data_dir)); - } - Ok(None) => {} - Err(error) => { - log::warn!( - "failed to resolve package {} from packages.xml metadata: {error:#}", - package - ); - } + if let Some(data_dir) = packages_xml_data_path { + return Ok(Some(data_dir)); } let out = Command::new("pm") .args(["path", "--user", SYSTEM_USER_ID, package]) .output() .with_context(|| format!("execute `pm path --user {SYSTEM_USER_ID} {package}`"))?; + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); if !out.status.success() { + if pm_path_failure_indicates_missing_package(&stdout, &stderr) { + log::info!( + "package {} is no longer visible to system user {}, skipping", + package, + SYSTEM_USER_ID + ); + return Ok(None); + } + + let detail = stderr.trim(); log::warn!( "failed to resolve package {}: {}", package, - String::from_utf8_lossy(&out.stderr).trim() + if detail.is_empty() { + "no error output" + } else { + detail + } ); return Ok(None); } - let stdout = String::from_utf8_lossy(&out.stdout); - let base_apk = match stdout - .lines() - .map(str::trim) - .find_map(|line| line.strip_prefix("package:")) - { - Some(path) if !path.is_empty() => PathBuf::from(path), - _ => return Ok(None), + let base_apk = match parse_pm_path_output(&stdout).into_iter().next() { + Some(path) => path, + None => return Ok(None), }; let data_dir = base_apk @@ -248,7 +278,11 @@ pub fn find_data_path(package: &str) -> Result> { fn is_installed_for_system_user(package: &str) -> Result { match read_system_user_package_states() { - Ok(Some(states)) => Ok(states.get(package).copied().unwrap_or(true)), + Ok(Some(states)) => Ok(states + .get(package) + .copied() + .unwrap_or_default() + .is_available()), Ok(None) => check_package_visible_to_system_user_with_pm(package), Err(restrictions_error) => match check_package_visible_to_system_user_with_pm(package) { Ok(installed) => { @@ -271,22 +305,54 @@ fn check_package_visible_to_system_user_with_pm(package: &str) -> Result { .args(["path", "--user", SYSTEM_USER_ID, package]) .output() .with_context(|| format!("execute `pm path --user {SYSTEM_USER_ID} {package}`"))?; + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); if out.status.success() { - let stdout = String::from_utf8_lossy(&out.stdout); - return Ok(stdout - .lines() - .map(str::trim) - .any(|line| line.starts_with("package:"))); + return Ok(!parse_pm_path_output(&stdout).is_empty()); } - let stderr = String::from_utf8_lossy(&out.stderr); - let trimmed = stderr.trim(); - if trimmed.contains("not found") || trimmed.contains("Unknown package") { + if pm_path_failure_indicates_missing_package(&stdout, &stderr) { return Ok(false); } - anyhow::bail!("`pm path --user {SYSTEM_USER_ID} {package}` failed: {trimmed}"); + let trimmed = stderr.trim(); + let detail = if trimmed.is_empty() { + "no error output" + } else { + trimmed + }; + + anyhow::bail!("`pm path --user {SYSTEM_USER_ID} {package}` failed: {detail}"); +} + +fn parse_pm_path_output(stdout: &str) -> Vec { + stdout + .lines() + .map(str::trim) + .filter_map(|line| line.strip_prefix("package:")) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect() +} + +fn pm_path_failure_indicates_missing_package(stdout: &str, stderr: &str) -> bool { + if !parse_pm_path_output(stdout).is_empty() { + return false; + } + + let trimmed = stderr.trim(); + if trimmed.is_empty() { + return true; + } + + let lower = trimmed.to_ascii_lowercase(); + lower.contains("unknown package") + || lower.contains("package not found") + || lower.contains("package was not found") + || lower.contains("unable to find package") + || lower.contains("not installed for") } fn find_data_path_from_packages_xml(package: &str) -> Result> { @@ -314,7 +380,7 @@ fn find_data_path_from_packages_xml(package: &str) -> Result> { } fn find_data_path_from_packages_xml_file(path: &Path, package: &str) -> Result> { - let contents = fs::read_to_string(path) + let contents = read_xmlish_text(path) .with_context(|| format!("read package settings {}", path.display()))?; if let Some(code_path) = parse_package_code_path(&contents, package)? { if let Some(data_dir) = normalize_code_path(code_path) { @@ -331,7 +397,7 @@ fn find_data_path_from_packages_xml_file(path: &Path, package: &str) -> Result Result>> { +fn read_system_user_package_states() -> Result>> { let mut last_error = None; for restrictions_xml in SYSTEM_USER_PACKAGE_RESTRICTIONS_PATHS { @@ -355,8 +421,10 @@ fn read_system_user_package_states() -> Result>> { } } -fn read_package_states_from_restrictions_file(path: &Path) -> Result> { - let contents = fs::read_to_string(path) +fn read_package_states_from_restrictions_file( + path: &Path, +) -> Result> { + let contents = read_xmlish_text(path) .with_context(|| format!("read package restrictions {}", path.display()))?; parse_package_restrictions_xml(&contents) .with_context(|| format!("parse package restrictions {}", path.display())) @@ -395,7 +463,9 @@ fn parse_package_code_path(contents: &str, package: &str) -> Result Result> { +fn parse_package_restrictions_xml( + contents: &str, +) -> Result> { let mut reader = Reader::from_str(contents); reader.config_mut().trim_text(true); let mut states = BTreeMap::new(); @@ -406,7 +476,7 @@ fn parse_package_restrictions_xml(contents: &str) -> Result { let mut package_name = None; - let mut installed = true; + let mut state = SystemUserPackageState::default(); for attribute in event.attributes().with_checks(false) { let attribute = attribute?; @@ -416,13 +486,18 @@ fn parse_package_restrictions_xml(contents: &str) -> Result package_name = Some(value), - b"inst" | b"installed" => installed = !value.eq_ignore_ascii_case("false"), + b"inst" | b"installed" => { + state.installed = parse_boolish(&value).unwrap_or(state.installed) + } + b"hidden" | b"blocked" => { + state.hidden = parse_boolish(&value).unwrap_or(state.hidden) + } _ => {} } } if let Some(package_name) = package_name { - states.insert(package_name, installed); + states.insert(package_name, state); } } Event::Eof => return Ok(states), @@ -432,25 +507,7 @@ fn parse_package_restrictions_xml(contents: &str) -> Result Option { - if code_path.is_dir() { - return Some(code_path); - } - - if code_path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("apk")) - { - return code_path - .parent() - .filter(|parent| parent.exists()) - .map(Path::to_path_buf); - } - - if code_path.is_file() { - return code_path.parent().map(Path::to_path_buf); - } - - None + normalize_user_app_code_path(&code_path) } fn remove_path(path: &Path) -> Result<()> { @@ -503,14 +560,18 @@ mod tests { use std::{ collections::{BTreeMap, HashSet}, fs, + path::PathBuf, }; use tempfile::tempdir; use super::{ cleanup_unmanaged_packages, find_data_path_from_packages_xml_file, - parse_package_restrictions_xml, sync_package_dir, + parse_package_restrictions_xml, parse_pm_path_output, + pm_path_failure_indicates_missing_package, read_package_states_from_restrictions_file, + sync_package_dir, }; + use crate::android_package_state::SystemUserPackageState; #[test] fn sync_package_dir_copies_contents_into_named_folder() { @@ -602,6 +663,30 @@ mod tests { assert_eq!(resolved, Some(package_dir)); } + #[test] + fn packages_xml_lookup_rejects_system_partition_code_paths_even_if_they_exist() { + let root = tempdir().unwrap(); + let package_dir = root.path().join("system").join("app").join("System"); + fs::create_dir_all(&package_dir).unwrap(); + let base_apk = package_dir.join("System.apk"); + fs::write(&base_apk, b"apk").unwrap(); + + let packages_xml = root.path().join("packages.xml"); + fs::write( + &packages_xml, + format!( + r#""#, + base_apk.display() + ), + ) + .unwrap(); + + let resolved = + find_data_path_from_packages_xml_file(&packages_xml, "com.example.system").unwrap(); + + assert_eq!(resolved, None); + } + #[test] fn current_packages_xml_missing_package_is_not_treated_as_backup_hit() { let root = tempdir().unwrap(); @@ -642,6 +727,60 @@ mod tests { assert!(find_data_path_from_packages_xml_file(¤t, "com.example.app").is_err()); } + #[test] + fn packages_xml_lookup_supports_android_binary_xml() { + let root = tempdir().unwrap(); + let package_dir = root + .path() + .join("data") + .join("app") + .join("com.example.binary"); + fs::create_dir_all(&package_dir).unwrap(); + + let packages_xml = root.path().join("packages.xml"); + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(8_u16).to_be_bytes()); + abx.extend_from_slice(b"packages"); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(7_u16).to_be_bytes()); + abx.extend_from_slice(b"package"); + + abx.push(0x2F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(4_u16).to_be_bytes()); + abx.extend_from_slice(b"name"); + abx.extend_from_slice(&(18_u16).to_be_bytes()); + abx.extend_from_slice(b"com.example.binary"); + + abx.push(0x2F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(8_u16).to_be_bytes()); + abx.extend_from_slice(b"codePath"); + let code_path = package_dir.display().to_string(); + abx.extend_from_slice(&(code_path.len() as u16).to_be_bytes()); + abx.extend_from_slice(code_path.as_bytes()); + + abx.push(0x03); + abx.extend_from_slice(&1_u16.to_be_bytes()); + abx.push(0x03); + abx.extend_from_slice(&0_u16.to_be_bytes()); + abx.push(0x01); + + fs::write(&packages_xml, abx).unwrap(); + + let resolved = + find_data_path_from_packages_xml_file(&packages_xml, "com.example.binary").unwrap(); + + assert_eq!(resolved, Some(package_dir)); + } + #[test] fn package_restrictions_parser_reads_installed_state_for_system_user() { let parsed = parse_package_restrictions_xml( @@ -659,11 +798,153 @@ mod tests { assert_eq!( parsed, BTreeMap::from([ - ("com.example.alpha".to_string(), true), - ("com.example.beta".to_string(), false), - ("com.example.delta".to_string(), true), - ("com.example.gamma".to_string(), false), + ( + "com.example.alpha".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + } + ), + ( + "com.example.beta".to_string(), + SystemUserPackageState { + installed: false, + hidden: false, + } + ), + ( + "com.example.delta".to_string(), + SystemUserPackageState { + installed: true, + hidden: false, + } + ), + ( + "com.example.gamma".to_string(), + SystemUserPackageState { + installed: false, + hidden: false, + } + ), + ]) + ); + } + + #[test] + fn package_restrictions_parser_reads_hidden_and_legacy_blocked_states() { + let parsed = parse_package_restrictions_xml( + r#" + + + "#, + ) + .unwrap(); + + assert_eq!( + parsed, + BTreeMap::from([ + ( + "com.example.blocked".to_string(), + SystemUserPackageState { + installed: true, + hidden: true, + } + ), + ( + "com.example.hidden".to_string(), + SystemUserPackageState { + installed: true, + hidden: true, + } + ), ]) ); } + + #[test] + fn package_restrictions_reader_supports_android_binary_xml() { + let root = tempdir().unwrap(); + let restrictions = root.path().join("package-restrictions.xml"); + + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(20_u16).to_be_bytes()); + abx.extend_from_slice(b"package-restrictions"); + + abx.push(0x02); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(3_u16).to_be_bytes()); + abx.extend_from_slice(b"pkg"); + + abx.push(0x2F); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(4_u16).to_be_bytes()); + abx.extend_from_slice(b"name"); + abx.extend_from_slice(&(17_u16).to_be_bytes()); + abx.extend_from_slice(b"com.example.alpha"); + + abx.push(0xDF); + abx.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + abx.extend_from_slice(&(4_u16).to_be_bytes()); + abx.extend_from_slice(b"inst"); + + abx.push(0x03); + abx.extend_from_slice(&1_u16.to_be_bytes()); + abx.push(0x03); + abx.extend_from_slice(&0_u16.to_be_bytes()); + abx.push(0x01); + + fs::write(&restrictions, abx).unwrap(); + + let states = read_package_states_from_restrictions_file(&restrictions).unwrap(); + + assert_eq!( + states, + BTreeMap::from([( + "com.example.alpha".to_string(), + SystemUserPackageState { + installed: false, + hidden: false, + } + )]) + ); + } + + #[test] + fn pm_path_parser_extracts_base_and_split_paths() { + let paths = parse_pm_path_output( + "package:/data/app/~~abc/com.example/base.apk\npackage:/data/app/~~abc/com.example/split_config.arm64_v8a.apk\n", + ); + + assert_eq!( + paths, + vec![ + PathBuf::from("/data/app/~~abc/com.example/base.apk"), + PathBuf::from("/data/app/~~abc/com.example/split_config.arm64_v8a.apk"), + ] + ); + } + + #[test] + fn pm_path_missing_package_without_stderr_matches_aosp_shell_behavior() { + assert!(pm_path_failure_indicates_missing_package("", "")); + assert!(pm_path_failure_indicates_missing_package( + "", + "Error: Unknown package: com.example.missing" + )); + } + + #[test] + fn pm_path_service_failures_are_not_treated_as_missing_package() { + assert!(!pm_path_failure_indicates_missing_package( + "", + "cmd: Can't find service: package" + )); + } } diff --git a/shared/android_install_path.rs b/shared/android_install_path.rs new file mode 100644 index 0000000..0343fa9 --- /dev/null +++ b/shared/android_install_path.rs @@ -0,0 +1,96 @@ +use std::path::{Path, PathBuf}; + +pub fn has_known_user_app_prefix(path: &Path) -> bool { + let normalized = path.to_string_lossy().replace('\\', "/"); + + normalized.starts_with("/data/app/") + || normalized.starts_with("/data/app-private/") + || is_adopted_storage_app_path(&normalized) +} + +#[cfg_attr(test, allow(dead_code))] +pub fn normalize_user_app_code_path(path: &Path) -> Option { + if path.is_dir() && is_known_existing_user_app_path(path) { + return Some(path.to_path_buf()); + } + + let parent = if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("apk")) + || path.is_file() + { + path.parent()? + } else { + return None; + }; + + if parent.exists() && is_known_existing_user_app_path(parent) { + return Some(parent.to_path_buf()); + } + + None +} + +fn is_known_existing_user_app_path(path: &Path) -> bool { + has_known_user_app_prefix(path) || { + #[cfg(test)] + { + has_embedded_test_user_app_prefix(path) + } + #[cfg(not(test))] + { + false + } + } +} + +fn is_adopted_storage_app_path(normalized: &str) -> bool { + let Some(rest) = normalized.strip_prefix("/mnt/expand/") else { + return false; + }; + + let mut parts = rest.split('/'); + matches!( + (parts.next(), parts.next()), + (Some(uuid), Some("app")) if !uuid.is_empty() + ) +} + +#[cfg(test)] +fn has_embedded_test_user_app_prefix(path: &Path) -> bool { + let normalized = path.to_string_lossy().replace('\\', "/"); + normalized.contains("/data/app/") || normalized.contains("/data/app-private/") +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::has_known_user_app_prefix; + + #[test] + fn known_user_app_prefix_covers_android_install_locations() { + assert!(has_known_user_app_prefix(Path::new( + "/data/app/~~token/com.example/base.apk" + ))); + assert!(has_known_user_app_prefix(Path::new( + "/data/app-private/com.example.locked/base.apk" + ))); + assert!(has_known_user_app_prefix(Path::new( + "/mnt/expand/uuid/app/com.example/base.apk" + ))); + } + + #[test] + fn known_user_app_prefix_rejects_non_app_roots() { + assert!(!has_known_user_app_prefix(Path::new( + "/system/app/Example/Example.apk" + ))); + assert!(!has_known_user_app_prefix(Path::new( + "/mnt/expand/uuid/media/com.example/base.apk" + ))); + assert!(!has_known_user_app_prefix(Path::new( + "/data/user/0/com.example/files" + ))); + } +} diff --git a/shared/android_package.rs b/shared/android_package.rs new file mode 100644 index 0000000..5e78d44 --- /dev/null +++ b/shared/android_package.rs @@ -0,0 +1,59 @@ +use anyhow::{Result, bail}; + +pub fn is_valid_package_name(package: &str) -> bool { + validate_package_name(package).is_ok() +} + +pub fn validate_package_name(package: &str) -> Result<()> { + if package.is_empty() { + bail!("Package name must not be empty"); + } + + let segments = package.split('.').collect::>(); + if segments.len() < 2 { + bail!("Package name `{package}` must contain at least two segments"); + } + + for segment in segments { + if segment.is_empty() { + bail!("Package name `{package}` must not contain empty segments"); + } + + let mut chars = segment.chars(); + let first = chars.next().expect("segment is non-empty"); + if !first.is_ascii_alphabetic() { + bail!( + "Package name `{package}` has invalid segment `{segment}`: each segment must start with an ASCII letter" + ); + } + + if chars.any(|ch| !ch.is_ascii_alphanumeric() && ch != '_') { + bail!( + "Package name `{package}` has invalid segment `{segment}`: only ASCII letters, digits, and underscores are allowed" + ); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{is_valid_package_name, validate_package_name}; + + #[test] + fn android_package_rules_accept_standard_application_ids() { + assert!(is_valid_package_name("com.example.app")); + assert!(is_valid_package_name("com.Example_1.app_2")); + } + + #[test] + fn android_package_rules_reject_invalid_names() { + assert!(validate_package_name("").is_err()); + assert!(validate_package_name("single").is_err()); + assert!(validate_package_name("com..example").is_err()); + assert!(validate_package_name("1com.example").is_err()); + assert!(validate_package_name("com.example-app").is_err()); + assert!(validate_package_name("com.example/app").is_err()); + } +} diff --git a/shared/android_package_state.rs b/shared/android_package_state.rs new file mode 100644 index 0000000..63205ca --- /dev/null +++ b/shared/android_package_state.rs @@ -0,0 +1,44 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SystemUserPackageState { + pub installed: bool, + pub hidden: bool, +} + +impl Default for SystemUserPackageState { + fn default() -> Self { + Self { + installed: true, + hidden: false, + } + } +} + +impl SystemUserPackageState { + pub fn is_available(self) -> bool { + self.installed && !self.hidden + } +} + +#[cfg(test)] +mod tests { + use super::SystemUserPackageState; + + #[test] + fn package_state_matches_android_is_available_semantics() { + assert!(SystemUserPackageState::default().is_available()); + assert!( + !SystemUserPackageState { + installed: false, + hidden: false, + } + .is_available() + ); + assert!( + !SystemUserPackageState { + installed: true, + hidden: true, + } + .is_available() + ); + } +} diff --git a/shared/android_xml.rs b/shared/android_xml.rs new file mode 100644 index 0000000..9b8e522 --- /dev/null +++ b/shared/android_xml.rs @@ -0,0 +1,657 @@ +use std::{fmt::Write as _, fs, path::Path}; + +use anyhow::{Context, Result, anyhow, bail}; +use cesu8::from_java_cesu8; + +const ABX_MAGIC: [u8; 4] = [0x41, 0x42, 0x58, 0x00]; + +const START_DOCUMENT: u8 = 0; +const END_DOCUMENT: u8 = 1; +const START_TAG: u8 = 2; +const END_TAG: u8 = 3; +const TEXT: u8 = 4; +const CDSECT: u8 = 5; +const ENTITY_REF: u8 = 6; +const IGNORABLE_WHITESPACE: u8 = 7; +const PROCESSING_INSTRUCTION: u8 = 8; +const COMMENT: u8 = 9; +const DOCDECL: u8 = 10; +const ATTRIBUTE: u8 = 15; + +const TYPE_NULL: u8 = 1 << 4; +const TYPE_STRING: u8 = 2 << 4; +const TYPE_STRING_INTERNED: u8 = 3 << 4; +const TYPE_BYTES_HEX: u8 = 4 << 4; +const TYPE_BYTES_BASE64: u8 = 5 << 4; +const TYPE_INT: u8 = 6 << 4; +const TYPE_INT_HEX: u8 = 7 << 4; +const TYPE_LONG: u8 = 8 << 4; +const TYPE_LONG_HEX: u8 = 9 << 4; +const TYPE_FLOAT: u8 = 10 << 4; +const TYPE_DOUBLE: u8 = 11 << 4; +const TYPE_BOOLEAN_TRUE: u8 = 12 << 4; +const TYPE_BOOLEAN_FALSE: u8 = 13 << 4; + +const INTERNED_STRING_NEW_MARKER: u16 = 0xFFFF; + +pub fn read_xmlish_text(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + decode_xmlish_bytes(&bytes).with_context(|| format!("decode {}", path.display())) +} + +pub fn decode_xmlish_bytes(bytes: &[u8]) -> Result { + if bytes.starts_with(&ABX_MAGIC) { + return AbxDecoder::new(bytes)?.decode(); + } + + decode_text_xml(bytes) +} + +pub fn parse_boolish(value: &str) -> Option { + match value.trim() { + "1" => Some(true), + "0" => Some(false), + value + if value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("yes") + || value.eq_ignore_ascii_case("on") => + { + Some(true) + } + value + if value.eq_ignore_ascii_case("false") + || value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("off") => + { + Some(false) + } + _ => None, + } +} + +#[cfg_attr(not(test), allow(dead_code))] +pub fn parse_i64ish(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + let (negative, digits) = if let Some(rest) = trimmed.strip_prefix('-') { + (true, rest) + } else if let Some(rest) = trimmed.strip_prefix('+') { + (false, rest) + } else { + (false, trimmed) + }; + + let parsed = if let Some(hex) = digits + .strip_prefix("0x") + .or_else(|| digits.strip_prefix("0X")) + { + i64::from_str_radix(hex, 16).ok() + } else if digits + .bytes() + .any(|byte| matches!(byte, b'a'..=b'f' | b'A'..=b'F')) + { + i64::from_str_radix(digits, 16).ok() + } else { + digits.parse::().ok() + }?; + + Some(if negative { -parsed } else { parsed }) +} + +fn decode_text_xml(bytes: &[u8]) -> Result { + if let Some(bytes) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8(bytes.to_vec()).context("decode UTF-8 XML with BOM"); + } + + if let Some(bytes) = bytes.strip_prefix(&[0xFF, 0xFE]) { + return decode_utf16_xml(bytes, true); + } + + if let Some(bytes) = bytes.strip_prefix(&[0xFE, 0xFF]) { + return decode_utf16_xml(bytes, false); + } + + if let Some(little_endian) = sniff_utf16_xml_without_bom(bytes) { + return decode_utf16_xml(bytes, little_endian); + } + + match String::from_utf8(bytes.to_vec()) { + Ok(text) => Ok(text), + Err(_) => decode_modified_utf8(bytes), + } +} + +fn sniff_utf16_xml_without_bom(bytes: &[u8]) -> Option { + let prefix = bytes.get(..4)?; + + match prefix { + [b'<', 0x00, _, 0x00] => Some(true), + [0x00, b'<', 0x00, _] => Some(false), + _ => None, + } +} + +fn decode_utf16_xml(bytes: &[u8], little_endian: bool) -> Result { + if bytes.len() % 2 != 0 { + bail!("UTF-16 XML byte length is not even"); + } + + let code_units = bytes + .chunks_exact(2) + .map(|chunk| { + if little_endian { + u16::from_le_bytes([chunk[0], chunk[1]]) + } else { + u16::from_be_bytes([chunk[0], chunk[1]]) + } + }) + .collect::>(); + + String::from_utf16(&code_units).context("decode UTF-16 XML") +} + +fn decode_modified_utf8(bytes: &[u8]) -> Result { + if let Ok(text) = std::str::from_utf8(bytes) { + return Ok(text.to_owned()); + } + + from_java_cesu8(bytes) + .map(|value| value.into_owned()) + .map_err(|_| anyhow!("decode modified UTF-8 XML")) +} + +struct AbxDecoder<'a> { + bytes: &'a [u8], + pos: usize, + interned_strings: Vec, +} + +impl<'a> AbxDecoder<'a> { + fn new(bytes: &'a [u8]) -> Result { + if !bytes.starts_with(&ABX_MAGIC) { + bail!("missing Android binary XML magic"); + } + + Ok(Self { + bytes, + pos: ABX_MAGIC.len(), + interned_strings: Vec::new(), + }) + } + + fn decode(&mut self) -> Result { + let mut xml = String::new(); + + while self.pos < self.bytes.len() { + if !self.process_token(&mut xml)? { + break; + } + } + + Ok(xml) + } + + fn process_token(&mut self, xml: &mut String) -> Result { + let token = self.read_u8()?; + let command = token & 0x0F; + let type_info = token & 0xF0; + + match command { + START_DOCUMENT => Ok(true), + END_DOCUMENT => Ok(false), + START_TAG => { + let tag_name = self.read_interned_string()?; + xml.push('<'); + xml.push_str(&tag_name); + + while self.peek_command() == Some(ATTRIBUTE) { + let attribute_token = self.read_u8()?; + self.write_attribute(xml, attribute_token)?; + } + + xml.push('>'); + Ok(true) + } + END_TAG => { + let tag_name = self.read_interned_string()?; + xml.push_str("'); + Ok(true) + } + TEXT | IGNORABLE_WHITESPACE => { + let value = self.read_typed_value(type_info)?; + push_escaped_text(xml, &value); + Ok(true) + } + CDSECT => { + let value = self.read_typed_value(type_info)?; + push_cdata_or_escaped_text(xml, &value); + Ok(true) + } + ENTITY_REF => { + let entity = self.read_typed_value(type_info)?; + let value = resolve_entity_ref(&entity)?; + push_escaped_text(xml, &value); + Ok(true) + } + PROCESSING_INSTRUCTION | COMMENT | DOCDECL => { + let _ = self.read_typed_value(type_info)?; + Ok(true) + } + other => bail!("unsupported Android binary XML token {other}"), + } + } + + fn write_attribute(&mut self, xml: &mut String, token: u8) -> Result<()> { + let type_info = token & 0xF0; + let name = self.read_interned_string()?; + let value = self.read_typed_value(type_info)?; + + xml.push(' '); + xml.push_str(&name); + xml.push_str("=\""); + push_escaped_attribute(xml, &value); + xml.push('"'); + Ok(()) + } + + fn read_typed_value(&mut self, type_info: u8) -> Result { + match type_info { + TYPE_NULL => Ok(String::new()), + TYPE_STRING => self.read_utf_string(), + TYPE_STRING_INTERNED => self.read_interned_string(), + TYPE_BYTES_HEX => { + let length = usize::from(self.read_u16()?); + let bytes = self.read_slice(length)?; + Ok(bytes_to_hex(bytes)) + } + TYPE_BYTES_BASE64 => { + let length = usize::from(self.read_u16()?); + let bytes = self.read_slice(length)?; + Ok(bytes_to_base64(bytes)) + } + TYPE_INT => Ok(self.read_i32()?.to_string()), + TYPE_INT_HEX => Ok(format!("0x{:x}", self.read_i32()? as u32)), + TYPE_LONG => Ok(self.read_i64()?.to_string()), + TYPE_LONG_HEX => Ok(format!("0x{:x}", self.read_i64()? as u64)), + TYPE_FLOAT => Ok(self.read_f32()?.to_string()), + TYPE_DOUBLE => Ok(self.read_f64()?.to_string()), + TYPE_BOOLEAN_TRUE => Ok(String::from("true")), + TYPE_BOOLEAN_FALSE => Ok(String::from("false")), + _ => bail!("unsupported Android binary XML type 0x{type_info:02x}"), + } + } + + fn read_interned_string(&mut self) -> Result { + let index = self.read_u16()?; + if index == INTERNED_STRING_NEW_MARKER { + let value = self.read_utf_string()?; + self.interned_strings.push(value.clone()); + return Ok(value); + } + + self.interned_strings + .get(usize::from(index)) + .cloned() + .ok_or_else(|| anyhow!("invalid Android binary XML string pool index {index}")) + } + + fn read_utf_string(&mut self) -> Result { + let length = usize::from(self.read_u16()?); + let bytes = self.read_slice(length)?; + decode_modified_utf8(bytes) + } + + fn peek_command(&self) -> Option { + self.bytes.get(self.pos).map(|token| token & 0x0F) + } + + fn read_u8(&mut self) -> Result { + let byte = self + .bytes + .get(self.pos) + .copied() + .ok_or_else(|| anyhow!("unexpected end of Android binary XML"))?; + self.pos += 1; + Ok(byte) + } + + fn read_u16(&mut self) -> Result { + let bytes = self.read_slice(2)?; + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) + } + + fn read_i32(&mut self) -> Result { + let bytes = self.read_slice(4)?; + Ok(i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_i64(&mut self) -> Result { + let bytes = self.read_slice(8)?; + Ok(i64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) + } + + fn read_f32(&mut self) -> Result { + Ok(f32::from_bits(self.read_i32()? as u32)) + } + + fn read_f64(&mut self) -> Result { + Ok(f64::from_bits(self.read_i64()? as u64)) + } + + fn read_slice(&mut self, len: usize) -> Result<&'a [u8]> { + let end = self + .pos + .checked_add(len) + .ok_or_else(|| anyhow!("Android binary XML offset overflow"))?; + + if end > self.bytes.len() { + bail!("unexpected end of Android binary XML"); + } + + let slice = &self.bytes[self.pos..end]; + self.pos = end; + Ok(slice) + } +} + +fn push_escaped_text(xml: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => xml.push_str("&"), + '<' => xml.push_str("<"), + '>' => xml.push_str(">"), + _ => xml.push(ch), + } + } +} + +fn push_cdata_or_escaped_text(xml: &mut String, value: &str) { + if value.contains("]]>") { + push_escaped_text(xml, value); + return; + } + + xml.push_str(""); +} + +fn push_escaped_attribute(xml: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => xml.push_str("&"), + '<' => xml.push_str("<"), + '>' => xml.push_str(">"), + '"' => xml.push_str("""), + '\'' => xml.push_str("'"), + _ => xml.push(ch), + } + } +} + +fn resolve_entity_ref(entity: &str) -> Result { + match entity { + "" => Ok(String::new()), + "lt" => Ok(String::from("<")), + "gt" => Ok(String::from(">")), + "amp" => Ok(String::from("&")), + "apos" => Ok(String::from("'")), + "quot" => Ok(String::from("\"")), + _ => { + let code_point = if let Some(hex) = entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + { + u32::from_str_radix(hex, 16) + .with_context(|| format!("decode hex XML entity reference `{entity}`"))? + } else if let Some(decimal) = entity.strip_prefix('#') { + decimal + .parse::() + .with_context(|| format!("decode decimal XML entity reference `{entity}`"))? + } else { + bail!("unknown XML entity reference `{entity}`"); + }; + + let ch = char::from_u32(code_point) + .ok_or_else(|| anyhow!("invalid XML entity code point U+{code_point:04X}"))?; + Ok(ch.to_string()) + } + } +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +fn bytes_to_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + + for chunk in bytes.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + let triple = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2); + + out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char); + out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char); + + if chunk.len() > 1 { + out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char); + } else { + out.push('='); + } + + if chunk.len() > 2 { + out.push(ALPHABET[(triple & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::{decode_xmlish_bytes, parse_boolish, parse_i64ish}; + + fn push_utf(buf: &mut Vec, value: &str) { + let len = u16::try_from(value.len()).unwrap(); + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(value.as_bytes()); + } + + fn push_utf_bytes(buf: &mut Vec, value: &[u8]) { + let len = u16::try_from(value.len()).unwrap(); + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(value); + } + + fn push_new_interned(buf: &mut Vec, value: &str) { + buf.extend_from_slice(&0xFFFF_u16.to_be_bytes()); + push_utf(buf, value); + } + + fn push_interned_ref(buf: &mut Vec, index: u16) { + buf.extend_from_slice(&index.to_be_bytes()); + } + + #[test] + fn parse_boolish_handles_common_android_representations() { + assert_eq!(parse_boolish("true"), Some(true)); + assert_eq!(parse_boolish("False"), Some(false)); + assert_eq!(parse_boolish("1"), Some(true)); + assert_eq!(parse_boolish("0"), Some(false)); + assert_eq!(parse_boolish("maybe"), None); + } + + #[test] + fn parse_i64ish_understands_decimal_and_hex() { + assert_eq!(parse_i64ish("16"), Some(16)); + assert_eq!(parse_i64ish("0x10"), Some(16)); + assert_eq!(parse_i64ish("ff"), Some(255)); + assert_eq!(parse_i64ish("-0x10"), Some(-16)); + } + + #[test] + fn android_binary_xml_is_decoded_to_text_xml() { + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + push_new_interned(&mut abx, "packages"); + + abx.push(0x02); + push_new_interned(&mut abx, "package"); + + abx.push(0x2F); + push_new_interned(&mut abx, "name"); + push_utf(&mut abx, "com.example.app"); + + abx.push(0x2F); + push_new_interned(&mut abx, "codePath"); + push_utf(&mut abx, "/data/app/~~abc/com.example.app/base.apk"); + + abx.push(0x7F); + push_new_interned(&mut abx, "publicFlags"); + abx.extend_from_slice(&(0x10_i32).to_be_bytes()); + + abx.push(0x03); + push_interned_ref(&mut abx, 1); + + abx.push(0x03); + push_interned_ref(&mut abx, 0); + + abx.push(0x01); + + let decoded = decode_xmlish_bytes(&abx).unwrap(); + + assert!(decoded.contains(""#)); + } + + #[test] + fn text_xml_utf16le_without_bom_is_detected() { + let bytes = b"ok" + .iter() + .flat_map(|byte| [*byte, 0x00]) + .collect::>(); + + let decoded = decode_xmlish_bytes(&bytes).unwrap(); + + assert_eq!(decoded, "ok"); + } + + #[test] + fn android_binary_xml_supports_art_modified_utf_with_four_byte_sequences() { + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + push_new_interned(&mut abx, "emoji"); + + abx.push(0x24); + push_utf_bytes(&mut abx, "😀".as_bytes()); + + abx.push(0x03); + push_interned_ref(&mut abx, 0); + abx.push(0x01); + + let decoded = decode_xmlish_bytes(&abx).unwrap(); + + assert_eq!(decoded, "😀"); + } + + #[test] + fn android_binary_xml_resolves_entity_refs_like_android() { + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + push_new_interned(&mut abx, "root"); + + abx.push(0x26); + push_utf(&mut abx, "amp"); + + abx.push(0x03); + push_interned_ref(&mut abx, 0); + abx.push(0x01); + + let decoded = decode_xmlish_bytes(&abx).unwrap(); + + assert_eq!(decoded, "&"); + } + + #[test] + fn android_binary_xml_base64_attributes_are_rendered_as_base64_text() { + let mut abx = Vec::new(); + abx.extend_from_slice(b"ABX\0"); + abx.push(0x00); + + abx.push(0x02); + push_new_interned(&mut abx, "root"); + + abx.push(0x5F); + push_new_interned(&mut abx, "blob"); + abx.extend_from_slice(&(3_u16).to_be_bytes()); + abx.extend_from_slice(&[0x01, 0x02, 0x03]); + + abx.push(0x03); + push_interned_ref(&mut abx, 0); + abx.push(0x01); + + let decoded = decode_xmlish_bytes(&abx).unwrap(); + + assert_eq!(decoded, r#""#); + } +} diff --git a/webui/index.html b/webui/index.html index 68e7318..c26bd11 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2,9 +2,10 @@ - + + - OukaroManager WebUI + OukaroManager
diff --git a/webui/public/config.json b/webui/public/config.json new file mode 100644 index 0000000..bedacd3 --- /dev/null +++ b/webui/public/config.json @@ -0,0 +1,7 @@ +{ + "extra": { + "pm": { + "allowUrlPackageFetch": true + } + } +} diff --git a/webui/src/App.vue b/webui/src/App.vue index 4f11dba..b2fe898 100644 --- a/webui/src/App.vue +++ b/webui/src/App.vue @@ -1,5 +1,5 @@ @@ -358,8 +621,9 @@ onMounted(() => {

- {{ kernelSupported ? t('header.supported') : t('header.preview') }} + {{ runtimeName }} + {{ runtimeCapabilityLabel }} {{ t('header.reboot') }} {{ t('header.managedBy') }}
@@ -369,15 +633,18 @@ onMounted(() => {

- {{ t('language.label') }} + {{ t('header.runtime') }}

- {{ kernelSupported ? t('header.supported') : t('header.preview') }} + {{ runtimeName }}

+

+ {{ t('language.label') }} +

@@ -513,7 +828,7 @@ onMounted(() => {
@@ -527,10 +842,40 @@ onMounted(() => { {{ t('labels.changed') }} + + {{ item.versionLabel }} + +
+
+
+ + + {{ (item.appLabel || item.packageName).slice(0, 1) }} + +
+ +
+

+ {{ item.appLabel || item.packageName }} +

+

+ {{ item.packageName }} +

+
-

- {{ item.packageName }} -

@@ -542,6 +887,12 @@ onMounted(() => { + +
+ +
@@ -657,6 +1008,14 @@ onMounted(() => {
+
+

+ {{ t('header.runtime') }} +

+

+ {{ runtimeName }} +

+

{{ t('header.moduleId') }} diff --git a/webui/src/lib/i18n.ts b/webui/src/lib/i18n.ts index 6a254d9..6a44f64 100644 --- a/webui/src/lib/i18n.ts +++ b/webui/src/lib/i18n.ts @@ -1,5 +1,7 @@ import { createI18n } from 'vue-i18n' +const LOCALE_STORAGE_KEY = 'oukaro.webui.locale' + const messages = { en: { title: 'System App Workbench', @@ -14,6 +16,12 @@ const messages = { eyebrow: 'System app configuration console', supported: 'KernelSU linked', preview: 'Preview only', + runtime: 'Runtime', + runtimePreview: 'Preview browser', + runtimeKernelSu: 'KernelSU Manager', + runtimeWebUiX: 'WebUIX', + capabilityFull: 'Exec and module metadata available', + capabilityLimited: 'Bridge is limited in this environment', reboot: 'Reboot required after save', managedBy: 'Backed by okrmng inspect/replace', modulePath: 'Module path', @@ -27,6 +35,8 @@ const messages = { saving: 'Saving configuration...', reset: 'Reset draft', refresh: 'Reload state', + showMore: 'Show all', + showLess: 'Show less', }, list: { title: 'Installed primary-user apps', @@ -35,9 +45,13 @@ const messages = { searchLabel: 'Search packages', searchPlaceholder: 'com.example.app', modeLabel: 'Target mode', + detailsLoading: 'Loading package details', + detailsReady: 'Package details ready', + limitedCount: 'Showing {shown} of {total} matches', empty: 'No installed primary-user apps matched the current search.', noData: 'okrmng did not return any primary-user apps.', packageCount: '{count} apps available', + sourceLabel: 'Discovery source: {source}', }, summary: { title: 'Current draft', @@ -65,8 +79,16 @@ const messages = { unsupportedTitle: 'KernelSU APIs are unavailable', unsupportedBody: 'This page can render in a normal browser for layout work, but refresh and save stay disabled until it runs inside KernelSU Manager or WebUIX.', + limitedBridgeTitle: 'Bridge support is partial', + limitedBridgeBody: + 'This runtime exposed only part of the KernelSU bridge. Loading or saving may stay unavailable until both exec and moduleInfo are present.', loadFailedTitle: 'Could not load module state', saveFailedTitle: 'Could not save configuration', + inspectFallbackTitle: 'Android package discovery is in fallback mode', + inspectFallbackBody: + 'Installed app discovery is using {source}. {details}', + inspectFallbackDefault: + 'The package list was reconstructed from Android package settings metadata instead of `pm list packages`.', missingTitle: 'Stale configuration preserved', missingBody: '{count} configured packages are no longer listed for the primary Android user. Saving keeps those stale entries unchanged.', @@ -82,6 +104,7 @@ const messages = { saveFailed: 'Could not save configuration.', refreshed: 'Module state refreshed.', loadFailed: 'Could not load module state.', + draftRestored: 'Restored an unsaved draft from the last session.', }, status: { loading: 'Loading module state...', @@ -94,6 +117,11 @@ const messages = { preserved: 'Preserved', selected: 'Selected', }, + sources: { + pmListPackages: 'pm list packages', + packagesXmlAndRestrictions: 'packages.xml + package-restrictions.xml', + packagesXmlBestEffort: 'packages.xml best effort', + }, }, 'zh-CN': { title: '系统应用工作台', @@ -108,6 +136,12 @@ const messages = { eyebrow: '系统应用配置控制台', supported: '已连接 KernelSU', preview: '仅预览模式', + runtime: '运行环境', + runtimePreview: '预览浏览器', + runtimeKernelSu: 'KernelSU Manager', + runtimeWebUiX: 'WebUIX', + capabilityFull: '已具备 exec 与 moduleInfo 能力', + capabilityLimited: '当前环境桥接能力不完整', reboot: '保存后需要重启', managedBy: '由 okrmng inspect/replace 驱动', modulePath: '模块路径', @@ -121,6 +155,8 @@ const messages = { saving: '正在保存配置...', reset: '重置草稿', refresh: '重新读取状态', + showMore: '展开全部', + showLess: '收起列表', }, list: { title: '主用户已安装应用', @@ -128,9 +164,13 @@ const messages = { searchLabel: '搜索包名', searchPlaceholder: 'com.example.app', modeLabel: '目标模式', + detailsLoading: '正在读取应用详情', + detailsReady: '应用详情已就绪', + limitedCount: '当前显示 {shown} / {total} 条匹配结果', empty: '当前搜索条件下没有匹配到主用户应用。', noData: 'okrmng 没有返回任何主用户应用。', packageCount: '共 {count} 个应用', + sourceLabel: '发现来源:{source}', }, summary: { title: '当前草稿', @@ -157,8 +197,16 @@ const messages = { unsupportedTitle: '当前环境没有 KernelSU API', unsupportedBody: '这个页面可以在普通浏览器里预览布局,但刷新和保存功能只有在 KernelSU Manager 或 WebUIX 里运行时才可用。', + limitedBridgeTitle: '桥接能力不完整', + limitedBridgeBody: + '当前运行环境只暴露了部分 KernelSU 桥接接口。只有当 exec 和 moduleInfo 都可用时,读取和保存才能稳定工作。', loadFailedTitle: '读取模块状态失败', saveFailedTitle: '保存配置失败', + inspectFallbackTitle: 'Android 包发现当前处于回退模式', + inspectFallbackBody: + '当前已安装应用列表使用 {source} 得出。{details}', + inspectFallbackDefault: + '当前列表并非来自 `pm list packages`,而是由 Android 包设置元数据重建得到。', missingTitle: '已保留失效配置', missingBody: '有 {count} 个已配置包名不再属于主用户(user 0)应用列表。保存时会保留这些失效条目,不会自动丢失。', @@ -174,6 +222,7 @@ const messages = { saveFailed: '保存配置失败。', refreshed: '模块状态已刷新。', loadFailed: '读取模块状态失败。', + draftRestored: '已恢复上次未保存的草稿。', }, status: { loading: '正在读取模块状态...', @@ -186,14 +235,38 @@ const messages = { preserved: '已保留', selected: '已选择', }, + sources: { + pmListPackages: 'pm list packages', + packagesXmlAndRestrictions: 'packages.xml + package-restrictions.xml', + packagesXmlBestEffort: 'packages.xml 尽力推断', + }, }, } as const -const locale = navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en' +function detectInitialLocale() { + try { + const savedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY) + if (savedLocale === 'zh-CN' || savedLocale === 'en') { + return savedLocale + } + } catch { + // Ignore storage access failures and fall back to navigator.language. + } + + return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en' +} export const i18n = createI18n({ legacy: false, - locale, + locale: detectInitialLocale(), fallbackLocale: 'en', messages, }) + +export function persistLocale(nextLocale: string) { + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale) + } catch { + // Ignore storage failures in restricted WebView environments. + } +} diff --git a/webui/src/lib/module-api.ts b/webui/src/lib/module-api.ts index 0ce82c4..97bde37 100644 --- a/webui/src/lib/module-api.ts +++ b/webui/src/lib/module-api.ts @@ -1,18 +1,66 @@ -import { enableEdgeToEdge, exec, moduleInfo, toast as kernelToast } from 'kernelsu' - -import type { InspectPayload, ModuleMetadata } from '@/lib/types' +import type { + InspectPayload, + ModuleMetadata, + PackageInfoSummary, + RuntimeCapabilities, +} from '@/lib/types' declare global { interface Window { - ksu?: unknown - kernelsu?: unknown + ksu?: KernelSuBridge + kernelsu?: KernelSuBridge + [key: string]: unknown } } -const OKRMNG_COMMAND = './okrmng' +interface KernelSuBridge { + exec?: (command: string, options?: string | null, callbackFunc?: string) => unknown + moduleInfo?: () => unknown + toast?: (message: string) => unknown + enableEdgeToEdge?: (enable: boolean) => unknown + mmrl?: () => unknown + listPackages?: (type: string) => unknown + getPackagesInfo?: (packages: string | string[]) => unknown + exit?: () => unknown +} + +interface WxuGlobalBridge { + require?: (module: string) => unknown +} + +interface WxuModuleBridge { + getId?: () => unknown + getModuleDir?: () => unknown +} + +interface WxuPackageManagerBridge { + getApplicationIcon?: (packageName: string, flags?: number, userId?: number) => unknown + getApplicationInfo?: (packageName: string, flags?: number, userId?: number) => unknown +} + +interface ExecOptions { + cwd?: string + env?: Record +} + +interface ExecResult { + errno: number + stdout: string + stderr: string +} + +const INSPECT_TIMEOUT_MS = 15_000 +const REPLACE_TIMEOUT_MS = 20_000 +const URL_FETCH_TIMEOUT_MS = 5_000 +const SYSTEM_USER_ID = 0 +const APPLICATION_INFO_FLAG_SYSTEM = 1 << 0 let moduleMetadataPromise: Promise | null = null +function getGlobalScope() { + return window as Window & { global?: WxuGlobalBridge } +} + function ensureKernelSuAlias() { if (typeof window === 'undefined') { return @@ -21,37 +69,417 @@ function ensureKernelSuAlias() { if (typeof window.ksu === 'undefined' && typeof window.kernelsu !== 'undefined') { window.ksu = window.kernelsu } + + if (typeof window.kernelsu === 'undefined' && typeof window.ksu !== 'undefined') { + window.kernelsu = window.ksu + } +} + +function getBridge(): KernelSuBridge | null { + if (typeof window === 'undefined') { + return null + } + + ensureKernelSuAlias() + + if (window.ksu && typeof window.ksu === 'object') { + return window.ksu + } + + if (window.kernelsu && typeof window.kernelsu === 'object') { + return window.kernelsu + } + + return null +} + +function getWxuGlobal(): WxuGlobalBridge | null { + if (typeof window === 'undefined') { + return null + } + + const globalBridge = getGlobalScope().global + if (globalBridge && typeof globalBridge.require === 'function') { + return globalBridge + } + + return null +} + +function getWxuModule(): WxuModuleBridge | null { + const globalBridge = getWxuGlobal() + if (!globalBridge?.require) { + return null + } + + try { + const moduleBridge = globalBridge.require('wx:module') + if (moduleBridge && typeof moduleBridge === 'object') { + return moduleBridge as WxuModuleBridge + } + } catch { + // Ignore optional WebUIX utility plugin failures. + } + + return null +} + +function getWxuPackageManager(): WxuPackageManagerBridge | null { + const globalBridge = getWxuGlobal() + if (!globalBridge?.require) { + return null + } + + try { + const packageManager = globalBridge.require('wx:pm') + if (packageManager && typeof packageManager === 'object') { + return packageManager as WxuPackageManagerBridge + } + } catch { + // Ignore optional WebUIX utility plugin failures. + } + + return null +} + +function detectRuntime(bridge: KernelSuBridge | null) { + if (!bridge) { + return 'preview' as const + } + + try { + if (typeof bridge.mmrl === 'function' && bridge.mmrl() === true) { + return 'webuix' as const + } + } catch { + // Ignore runtime probe failures and fall back to generic KernelSU mode. + } + + return 'kernelsu' as const } function shellQuote(value: string) { return `'${value.replace(/'/g, `'\"'\"'`)}'` } +function errorToMessage(error: unknown) { + if (error instanceof Error) { + return error.message + } + + return String(error) +} + function commandError(stderr: string, stdout: string, errno: number) { const message = stderr.trim() || stdout.trim() return new Error(message || `Command failed with exit code ${errno}`) } -function parseModuleMetadata(raw: string): ModuleMetadata { - const parsed = JSON.parse(raw) as ModuleMetadata +function parseModuleMetadata(raw: unknown): ModuleMetadata { + const parsed = + typeof raw === 'string' + ? (JSON.parse(raw) as ModuleMetadata) + : (raw as ModuleMetadata | null) - if (!parsed.moduleDir) { + if (!parsed?.moduleDir) { throw new Error('KernelSU did not provide a moduleDir value.') } return parsed } -async function runOkrmng(argumentsText: string) { - const metadata = await getModuleMetadata() +function parsePackageInfoList(raw: unknown) { + if (typeof raw === 'string') { + return JSON.parse(raw) as PackageInfoSummary[] + } + + if (Array.isArray(raw)) { + return raw as PackageInfoSummary[] + } + + throw new Error('KernelSU did not provide a valid package info payload.') +} + +function parseJsonObject(raw: unknown) { + if (typeof raw === 'string') { + try { + return parseJsonObject(JSON.parse(raw)) + } catch { + return null + } + } + + if (raw && typeof raw === 'object') { + return raw as Record + } + + return null +} - if (!metadata) { - throw new Error('KernelSU WebUI APIs are unavailable in this environment.') +function pickString(...values: unknown[]) { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } } - const result = await exec(`${OKRMNG_COMMAND} ${argumentsText}`, { - cwd: metadata.moduleDir, + return undefined +} + +function pickNumber(...values: unknown[]) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + } + + return undefined +} + +function guessBase64ImageMime(base64: string) { + if (base64.startsWith('iVBOR')) { + return 'image/png' + } + + if (base64.startsWith('/9j/')) { + return 'image/jpeg' + } + + if (base64.startsWith('UklGR')) { + return 'image/webp' + } + + return 'image/png' +} + +function base64ToDataUrl(raw: unknown) { + if (typeof raw !== 'string') { + return null + } + + const normalized = raw.trim() + if (!normalized) { + return null + } + + if (normalized.startsWith('data:')) { + return normalized + } + + return `data:${guessBase64ImageMime(normalized)};base64,${normalized}` +} + +function normalizePackageInfoSummary( + raw: unknown, + fallbackPackageName: string, + iconUrl?: string | null, +): PackageInfoSummary | null { + const parsed = parseJsonObject(raw) + if (!parsed) { + return null + } + + const packageName = pickString(parsed.packageName) ?? fallbackPackageName + const appLabel = pickString( + parsed.appLabel, + parsed.label, + parsed.nonLocalizedLabel, + parsed.name, + ) + const versionName = pickString(parsed.versionName) + const versionCode = pickNumber(parsed.versionCode, parsed.longVersionCode) + const uid = pickNumber(parsed.uid) + const isSystem = + typeof parsed.isSystem === 'boolean' + ? parsed.isSystem + : (() => { + const flags = pickNumber(parsed.flags) + if (typeof flags === 'number') { + return (flags & APPLICATION_INFO_FLAG_SYSTEM) !== 0 + } + + return null + })() + + const detail: PackageInfoSummary = { + packageName, + appLabel, + versionName, + versionCode, + iconUrl: iconUrl ?? null, + isSystem, + uid: typeof uid === 'number' ? uid : null, + } + + return detail +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof (value as PromiseLike).then === 'function' + ) +} + +function normalizeExecResult( + errno: unknown, + stdout: unknown, + stderr: unknown, +): ExecResult { + const normalizedErrno = + typeof errno === 'number' && Number.isFinite(errno) ? errno : Number.parseInt(String(errno), 10) + + return { + errno: Number.isFinite(normalizedErrno) ? normalizedErrno : -1, + stdout: typeof stdout === 'string' ? stdout : String(stdout ?? ''), + stderr: typeof stderr === 'string' ? stderr : String(stderr ?? ''), + } +} + +function parseDirectExecResult(raw: unknown): ExecResult | null { + if (typeof raw === 'string') { + try { + return parseDirectExecResult(JSON.parse(raw)) + } catch { + return null + } + } + + if (!raw || typeof raw !== 'object') { + return null + } + + const candidate = raw as Partial + if (!('errno' in candidate) && !('stdout' in candidate) && !('stderr' in candidate)) { + return null + } + + return normalizeExecResult(candidate.errno, candidate.stdout, candidate.stderr) +} + +function resolveRuntimeAssetUrl(relativePath: string) { + return new URL(relativePath, window.location.href).toString() +} + +function getWebUiXPackageIconUrl(packageName: string) { + return resolveRuntimeAssetUrl(`.package/${encodeURIComponent(packageName)}/icon.png`) +} + +async function fetchJsonWithTimeout(url: string, timeoutMs: number) { + if (typeof fetch !== 'function') { + return null + } + + const controller = new AbortController() + const timer = window.setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { signal: controller.signal }) + if (!response.ok) { + return null + } + + return await response.json() + } catch { + return null + } finally { + window.clearTimeout(timer) + } +} + +async function execBridge( + command: string, + options: ExecOptions = {}, + timeoutMs = INSPECT_TIMEOUT_MS, +) { + const bridge = getBridge() + if (!bridge?.exec) { + throw new Error('KernelSU exec API is unavailable in this environment.') + } + + const callbackName = `__oukaro_exec_${Date.now()}_${Math.random().toString(16).slice(2)}` + + return new Promise((resolve, reject) => { + let settled = false + + const finalizeResolve = (result: ExecResult) => { + if (settled) { + return + } + + settled = true + window.clearTimeout(timer) + cleanup() + resolve(result) + } + + const finalizeReject = (error: unknown) => { + if (settled) { + return + } + + settled = true + window.clearTimeout(timer) + cleanup() + reject(error) + } + + const cleanup = () => { + delete window[callbackName] + } + + const timer = window.setTimeout(() => { + finalizeReject(new Error(`KernelSU exec for \`${command}\` timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + window[callbackName] = (errno: unknown, stdout: unknown, stderr: unknown) => { + finalizeResolve(normalizeExecResult(errno, stdout, stderr)) + } + + try { + const result = bridge.exec?.(command, JSON.stringify(options), callbackName) + if (isPromiseLike(result)) { + void Promise.resolve(result).then( + (value) => { + const directResult = parseDirectExecResult(value) + if (directResult) { + finalizeResolve(directResult) + } + }, + (error) => { + finalizeReject(error) + }, + ) + return + } + + const directResult = parseDirectExecResult(result) + if (directResult) { + finalizeResolve(directResult) + } + } catch (error) { + finalizeReject(error) + } }) +} + +async function runOkrmng(argumentsText: string, timeoutMs: number) { + const metadata = await getModuleMetadata() + const moduleDir = metadata.moduleDir.replace(/\/+$/, "") + const okrmngPath = `${moduleDir}/okrmng` + + const result = await execBridge(`${shellQuote(okrmngPath)} ${argumentsText}`, { + cwd: moduleDir, + }, timeoutMs) if (result.errno !== 0) { throw commandError(result.stderr, result.stdout, result.errno) @@ -60,35 +488,72 @@ async function runOkrmng(argumentsText: string) { return result.stdout.trim() } -export function isKernelSuAvailable() { - if (typeof window === 'undefined') { - return false +export function getRuntimeCapabilities(): RuntimeCapabilities { + const bridge = getBridge() + const runtime = detectRuntime(bridge) + const wxuModule = getWxuModule() + const wxuPackageManager = getWxuPackageManager() + + return { + runtime, + hasBridge: bridge !== null, + hasExec: typeof bridge?.exec === 'function', + hasModuleInfo: typeof bridge?.moduleInfo === 'function', + hasWxuModule: wxuModule !== null, + hasWxuPackageManager: wxuPackageManager !== null, + hasToast: typeof bridge?.toast === 'function', + hasEdgeToEdge: typeof bridge?.enableEdgeToEdge === 'function', + hasListPackages: typeof bridge?.listPackages === 'function' || runtime === 'webuix', + hasPackageInfo: + typeof bridge?.getPackagesInfo === 'function' || wxuPackageManager !== null || runtime === 'webuix', + hasExit: typeof bridge?.exit === 'function', } +} - ensureKernelSuAlias() - return typeof window.ksu !== 'undefined' +export function isKernelSuAvailable() { + const capabilities = getRuntimeCapabilities() + return capabilities.hasExec && (capabilities.hasModuleInfo || capabilities.hasWxuModule) } export function requestEdgeToEdge() { - if (!isKernelSuAvailable()) { + const bridge = getBridge() + + if (!bridge?.enableEdgeToEdge) { return } try { - enableEdgeToEdge(true) + bridge.enableEdgeToEdge(true) } catch { // Ignore optional runtime helpers when previewing in non-KernelSU environments. } } export async function getModuleMetadata() { - if (!isKernelSuAvailable()) { - return null + const capabilities = getRuntimeCapabilities() + if (!capabilities.hasModuleInfo && !capabilities.hasWxuModule) { + throw new Error('KernelSU/WebUIX module metadata APIs are unavailable in this environment.') } if (!moduleMetadataPromise) { moduleMetadataPromise = Promise.resolve() - .then(() => parseModuleMetadata(moduleInfo())) + .then(async () => { + const bridgeMetadata = getBridge()?.moduleInfo?.() + if (typeof bridgeMetadata !== 'undefined') { + return parseModuleMetadata(await Promise.resolve(bridgeMetadata)) + } + + const wxuModule = getWxuModule() + const moduleDir = pickString(wxuModule?.getModuleDir?.()) + if (!moduleDir) { + throw new Error('WebUIX did not provide a moduleDir value.') + } + + return { + id: pickString(wxuModule?.getId?.()), + moduleDir, + } satisfies ModuleMetadata + }) .catch((error) => { moduleMetadataPromise = null throw error @@ -99,7 +564,7 @@ export async function getModuleMetadata() { } export async function inspectConfig() { - const stdout = await runOkrmng('inspect --json') + const stdout = await runOkrmng('inspect --json', INSPECT_TIMEOUT_MS) return JSON.parse(stdout) as InspectPayload } @@ -109,17 +574,116 @@ export async function replaceConfig(systemPackages: string[], privPackages: stri await runOkrmng( `replace --system ${shellQuote(systemCsv)} --priv ${shellQuote(privCsv)}`, + REPLACE_TIMEOUT_MS, ) } +export async function getPackagesInfo(packageNames: string[]) { + if (packageNames.length === 0) { + return [] as PackageInfoSummary[] + } + + const bridge = getBridge() + if (bridge?.getPackagesInfo) { + try { + const payload = await Promise.resolve(bridge.getPackagesInfo(packageNames)) + return parsePackageInfoList(payload) + } catch { + try { + const payload = await Promise.resolve(bridge.getPackagesInfo(JSON.stringify(packageNames))) + return parsePackageInfoList(payload) + } catch { + // Fall through to WebUIX-specific paths below. + } + } + } + + const wxuPackageManager = getWxuPackageManager() + if (wxuPackageManager?.getApplicationInfo) { + const details = packageNames + .map((packageName) => { + try { + const info = wxuPackageManager.getApplicationInfo?.(packageName, 0, SYSTEM_USER_ID) + const iconBase64 = wxuPackageManager.getApplicationIcon?.(packageName, 0, SYSTEM_USER_ID) + + return normalizePackageInfoSummary(info, packageName, base64ToDataUrl(iconBase64)) + } catch { + return null + } + }) + .filter((detail): detail is PackageInfoSummary => detail !== null) + + if (details.length > 0) { + return details + } + } + + if (getRuntimeCapabilities().runtime === 'webuix') { + const details = ( + await Promise.all( + packageNames.map(async (packageName) => { + const info = await fetchJsonWithTimeout( + resolveRuntimeAssetUrl(`.package/${encodeURIComponent(packageName)}/info.json`), + URL_FETCH_TIMEOUT_MS, + ) + + return normalizePackageInfoSummary(info, packageName, getWebUiXPackageIconUrl(packageName)) + }), + ) + ).filter((detail): detail is PackageInfoSummary => detail !== null) + + if (details.length > 0) { + return details + } + } + + return null +} + +export function getPackageIconUrl(packageName: string) { + const capabilities = getRuntimeCapabilities() + if (!capabilities.hasListPackages) { + return null + } + + if (capabilities.runtime === 'kernelsu') { + return `ksu://icon/${encodeURIComponent(packageName)}` + } + + if (capabilities.runtime === 'webuix') { + return getWebUiXPackageIconUrl(packageName) + } + + return null +} + export function showNativeToast(message: string) { - if (!isKernelSuAvailable()) { + const bridge = getBridge() + if (!bridge?.toast) { return } try { - kernelToast(message) + bridge.toast(message) } catch { // Ignore native toast failures and let the in-page toast handle it. } } + +export function exitWebUi() { + const bridge = getBridge() + if (!bridge?.exit) { + return false + } + + try { + bridge.exit() + return true + } catch { + return false + } +} + +export function formatBridgeError(error: unknown) { + return errorToMessage(error) +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 2c3cfd7..835547f 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -1,10 +1,19 @@ export type AppMode = 'none' | 'system' | 'priv' +export type ModuleRuntime = 'preview' | 'kernelsu' | 'webuix' +export type InspectUserAppsSource = + | 'pmListPackages' + | 'packagesXmlAndRestrictions' + | 'packagesXmlBestEffort' +export type InspectSystemUserStateSource = 'packageRestrictions' export interface InspectPayload { systemApp: string[] privApp: string[] installedUserApps: string[] missingConfiguredApps: string[] + installedUserAppsSource?: InspectUserAppsSource + systemUserStateSource?: InspectSystemUserStateSource + warnings?: string[] } export interface ModuleMetadata { @@ -15,3 +24,28 @@ export interface ModuleMetadata { versionCode?: string description?: string } + +export interface RuntimeCapabilities { + runtime: ModuleRuntime + hasBridge: boolean + hasExec: boolean + hasModuleInfo: boolean + hasWxuModule: boolean + hasWxuPackageManager: boolean + hasToast: boolean + hasEdgeToEdge: boolean + hasListPackages: boolean + hasPackageInfo: boolean + hasExit: boolean +} + +export interface PackageInfoSummary { + packageName: string + versionName?: string + versionCode?: number + appLabel?: string + iconUrl?: string | null + isSystem?: boolean | null + uid?: number | null + error?: string +} diff --git a/webui/src/style.css b/webui/src/style.css index 63f57cf..e662db4 100644 --- a/webui/src/style.css +++ b/webui/src/style.css @@ -42,6 +42,10 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; margin: 0; + padding-top: var(--window-inset-top, 0px); + padding-right: var(--window-inset-right, 0px); + padding-bottom: var(--window-inset-bottom, 0px); + padding-left: var(--window-inset-left, 0px); } button, @@ -51,6 +55,7 @@ #app { min-height: 100vh; + min-height: 100dvh; } }