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..0756aac 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,14 @@ 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 +- 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` ## 📱 WebUI访问选项 | WebUI Access Options @@ -85,20 +99,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 +122,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/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/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..5914b22 100644 --- a/okrmng/Cargo.lock +++ b/okrmng/Cargo.lock @@ -58,6 +58,24 @@ 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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.5.51" @@ -110,6 +128,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 +184,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 +197,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 +208,78 @@ 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", + "cesu8", "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 +289,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 +307,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 +362,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 +401,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 +459,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 +543,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..42cea37 100644 --- a/okrmng/Cargo.toml +++ b/okrmng/Cargo.toml @@ -5,6 +5,10 @@ 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"] } +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..e9ae673 100644 --- a/okrmng/src/cli.rs +++ b/okrmng/src/cli.rs @@ -1,9 +1,62 @@ -use std::fs; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + process::Command, +}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; +use quick_xml::{Reader, events::Event}; +use serde::Serialize; -use crate::{config, defs::CONFIG_PATH}; +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)] @@ -46,18 +99,44 @@ 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, + 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<()> { 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 } => { + 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"); } @@ -66,13 +145,17 @@ 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 } => { + 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"); } @@ -81,13 +164,880 @@ 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!( + "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)?; + + 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"); } } - fs::write( - CONFIG_PATH, - toml::to_string(&config).context("Failed to change config")?, - )?; + 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}"); + } Ok(()) } + +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()) + { + validate_package_name(package)?; + packages.insert(package.to_owned()); + } + + Ok(packages) +} + +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(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 { + let packages = list_known_user_apps_from_packages_xml()?; + 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." + )], + }), + } +} + +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 = + 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 = + 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())) +} + +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" | b"flags" => public_flags = parse_i64ish(&value), + b"system" => legacy_system = parse_boolish(&value), + _ => {} + } + } + + 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 state = SystemUserPackageState::default(); + + 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" => { + 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, state); + } + } + 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; + } + + has_known_user_app_prefix(Path::new(code_path)) +} + +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_default() + .is_available() + }) + .collect() +} + +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.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.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, + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use super::{ + 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, + 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, ").unwrap(); + + assert_eq!( + parsed, + BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.beta".to_string(), + ]) + ); + } + + #[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( + "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 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( + r#" + + + + + + + "#, + ) + .unwrap(); + + assert_eq!( + parsed, + BTreeMap::from([ + ( + "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 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(), + 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); + + assert_eq!( + filtered, + BTreeSet::from([ + "com.example.alpha".to_string(), + "com.example.gamma".to_string(), + ]) + ); + } + + #[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(); + 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 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()))?; + temp.as_file() + .sync_all() + .with_context(|| format!("Failed to sync temp config for {}", path.display()))?; + + if path.exists() { + #[cfg(windows)] + { + fs::remove_file(path) + .with_context(|| format!("Failed to replace config {}", path.display()))?; } - let buf = fs::read_to_string(config)?; - let toml: Self = toml::from_str(buf.as_str())?; - Ok(Self { app: toml.app }) + } + + 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(()) +} + +#[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/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 5965f22..01459cf 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]] @@ -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" @@ -171,9 +177,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 +193,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 +244,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 +308,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" @@ -349,22 +334,17 @@ name = "oukaro" version = "0.1.0" dependencies = [ "anyhow", + "cesu8", "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 +369,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 +387,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 +432,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -491,16 +486,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 +498,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 +562,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 +674,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 +683,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..8c15d0e 100644 --- a/oukaro/Cargo.toml +++ b/oukaro/Cargo.toml @@ -5,15 +5,18 @@ edition = "2024" [dependencies] anyhow = "1.0.100" +cesu8 = "1.1.0" 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"] } +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"] } + [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 39b366b..11cb1f0 100644 --- a/oukaro/src/defs.rs +++ b/oukaro/src/defs.rs @@ -1,5 +1,25 @@ +use std::path::PathBuf; + 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 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"; +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/src/main.rs b/oukaro/src/main.rs index 26271d9..510c94a 100644 --- a/oukaro/src/main.rs +++ b/oukaro/src/main.rs @@ -1,19 +1,34 @@ +#[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, path::Path}; +use std::{collections::HashSet, 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}, + android_package::validate_package_name, + defs::{LOWER_PATH, UPPER_PATH, WORK_PATH}, + utils::{cleanup_unmanaged_packages, find_data_path, mount_overlyfs, sync_package_dir}, }; -fn run() -> Result<()> { +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| { let local_time = chrono::Local::now(); @@ -29,15 +44,58 @@ fn run() -> Result<()> { ) }); 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 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 +106,44 @@ fn run() -> Result<()> { lower.join("app"), upper.join("app"), work.join("app"), - "/system/", + "/system/app", )?; - loop { - config.load_config()?; + config.load_config()?; + let managed_apps = sanitize_managed_apps(config.get()); - let apps = config.get(); + 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 { - let data_path = find_data_path(&app)?; - - dir_copys(data_path, system_path.join("priv-app")); - log::info!("mount successful.") + log::info!("handling system/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)?; + 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 managed_apps.system_apply { + 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() { @@ -83,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 223b11f..7ff0846 100644 --- a/oukaro/src/utils.rs +++ b/oukaro/src/utils.rs @@ -1,14 +1,34 @@ 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::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"))] +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,53 +39,912 @@ 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(), 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(()) } -/// 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> { + 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) => { + log::info!( + "package {} is not installed for system user {}, skipping", + package, + SYSTEM_USER_ID + ); + 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, + SYSTEM_USER_ID + ); + return Ok(None); + } + } + + 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 re = Regex::new(r"^package:(.*)").unwrap(); - let caps = match re.captures(&stdout) { - Some(s) => s, - None => return Ok(String::new()), + 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, + if detail.is_empty() { + "no error output" + } else { + detail + } + ); + return Ok(None); + } + + let base_apk = match parse_pm_path_output(&stdout).into_iter().next() { + Some(path) => path, + None => 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_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) => { + 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}`"))?; + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + if out.status.success() { + return Ok(!parse_pm_path_output(&stdout).is_empty()); + } + + if pm_path_failure_indicates_missing_package(&stdout, &stderr) { + return Ok(false); + } + + 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> { + 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 = 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) { + return Ok(Some(data_dir)); + } + + 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 = 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())) +} + +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(); - Ok(path) + 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 state = SystemUserPackageState::default(); + + 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" => { + 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, state); + } + } + Event::Eof => return Ok(states), + _ => {} + } + } +} + +fn normalize_code_path(code_path: PathBuf) -> Option { + normalize_user_app_code_path(&code_path) +} + +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, + path::PathBuf, + }; + + use tempfile::tempdir; + + use super::{ + cleanup_unmanaged_packages, find_data_path_from_packages_xml_file, + 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() { + 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 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(); + 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(), + 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/.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..c26bd11 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,14 @@ + + + + + + + + OukaroManager + + +

+ + + 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/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/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..b2fe898 --- /dev/null +++ b/webui/src/App.vue @@ -0,0 +1,1051 @@ + + + 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..6a44f64 --- /dev/null +++ b/webui/src/lib/i18n.ts @@ -0,0 +1,272 @@ +import { createI18n } from 'vue-i18n' + +const LOCALE_STORAGE_KEY = 'oukaro.webui.locale' + +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', + 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', + 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', + showMore: 'Show all', + showLess: 'Show less', + }, + 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', + 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', + 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.', + 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.', + 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.', + draftRestored: 'Restored an unsaved draft from the last session.', + }, + status: { + loading: 'Loading module state...', + refreshing: 'Refreshing...', + saving: 'Saving...', + }, + labels: { + current: 'Current mode', + changed: 'Changed', + preserved: 'Preserved', + selected: 'Selected', + }, + sources: { + pmListPackages: 'pm list packages', + packagesXmlAndRestrictions: 'packages.xml + package-restrictions.xml', + packagesXmlBestEffort: 'packages.xml best effort', + }, + }, + 'zh-CN': { + title: '系统应用工作台', + subtitle: + '选择主用户(user 0)已安装的应用,将它们切到 System 或 Priv 模式,并保存模块配置,等待下次重启生效。', + language: { + label: '语言', + zh: '中文', + en: 'English', + }, + header: { + eyebrow: '系统应用配置控制台', + supported: '已连接 KernelSU', + preview: '仅预览模式', + runtime: '运行环境', + runtimePreview: '预览浏览器', + runtimeKernelSu: 'KernelSU Manager', + runtimeWebUiX: 'WebUIX', + capabilityFull: '已具备 exec 与 moduleInfo 能力', + capabilityLimited: '当前环境桥接能力不完整', + reboot: '保存后需要重启', + managedBy: '由 okrmng inspect/replace 驱动', + modulePath: '模块路径', + moduleId: '模块 ID', + version: '版本', + modulePending: '正在等待模块元数据', + moduleUnavailable: '预览模式下无法读取模块元数据', + }, + actions: { + save: '保存配置', + saving: '正在保存配置...', + reset: '重置草稿', + refresh: '重新读取状态', + showMore: '展开全部', + showLess: '收起列表', + }, + list: { + title: '主用户已安装应用', + description: '按包名搜索,并仅针对主用户(user 0)为每个应用选择一个目标模式。', + searchLabel: '搜索包名', + searchPlaceholder: 'com.example.app', + modeLabel: '目标模式', + detailsLoading: '正在读取应用详情', + detailsReady: '应用详情已就绪', + limitedCount: '当前显示 {shown} / {total} 条匹配结果', + empty: '当前搜索条件下没有匹配到主用户应用。', + noData: 'okrmng 没有返回任何主用户应用。', + packageCount: '共 {count} 个应用', + sourceLabel: '发现来源:{source}', + }, + 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 里运行时才可用。', + limitedBridgeTitle: '桥接能力不完整', + limitedBridgeBody: + '当前运行环境只暴露了部分 KernelSU 桥接接口。只有当 exec 和 moduleInfo 都可用时,读取和保存才能稳定工作。', + loadFailedTitle: '读取模块状态失败', + saveFailedTitle: '保存配置失败', + inspectFallbackTitle: 'Android 包发现当前处于回退模式', + inspectFallbackBody: + '当前已安装应用列表使用 {source} 得出。{details}', + inspectFallbackDefault: + '当前列表并非来自 `pm list packages`,而是由 Android 包设置元数据重建得到。', + 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: '读取模块状态失败。', + draftRestored: '已恢复上次未保存的草稿。', + }, + status: { + loading: '正在读取模块状态...', + refreshing: '正在刷新...', + saving: '正在保存...', + }, + labels: { + current: '当前模式', + changed: '已变更', + preserved: '已保留', + selected: '已选择', + }, + sources: { + pmListPackages: 'pm list packages', + packagesXmlAndRestrictions: 'packages.xml + package-restrictions.xml', + packagesXmlBestEffort: 'packages.xml 尽力推断', + }, + }, +} as const + +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: 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 new file mode 100644 index 0000000..97bde37 --- /dev/null +++ b/webui/src/lib/module-api.ts @@ -0,0 +1,689 @@ +import type { + InspectPayload, + ModuleMetadata, + PackageInfoSummary, + RuntimeCapabilities, +} from '@/lib/types' + +declare global { + interface Window { + ksu?: KernelSuBridge + kernelsu?: KernelSuBridge + [key: string]: unknown + } +} + +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 + } + + 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: unknown): ModuleMetadata { + const parsed = + typeof raw === 'string' + ? (JSON.parse(raw) as ModuleMetadata) + : (raw as ModuleMetadata | null) + + if (!parsed?.moduleDir) { + throw new Error('KernelSU did not provide a moduleDir value.') + } + + return parsed +} + +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 +} + +function pickString(...values: unknown[]) { + for (const value of values) { + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + + 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) + } + + return result.stdout.trim() +} + +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', + } +} + +export function isKernelSuAvailable() { + const capabilities = getRuntimeCapabilities() + return capabilities.hasExec && (capabilities.hasModuleInfo || capabilities.hasWxuModule) +} + +export function requestEdgeToEdge() { + const bridge = getBridge() + + if (!bridge?.enableEdgeToEdge) { + return + } + + try { + bridge.enableEdgeToEdge(true) + } catch { + // Ignore optional runtime helpers when previewing in non-KernelSU environments. + } +} + +export async function getModuleMetadata() { + 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(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 + }) + } + + return moduleMetadataPromise +} + +export async function inspectConfig() { + const stdout = await runOkrmng('inspect --json', INSPECT_TIMEOUT_MS) + 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)}`, + 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) { + const bridge = getBridge() + if (!bridge?.toast) { + return + } + + try { + 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 new file mode 100644 index 0000000..835547f --- /dev/null +++ b/webui/src/lib/types.ts @@ -0,0 +1,51 @@ +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 { + moduleDir: string + id?: string + name?: string + version?: string + 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/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..e662db4 --- /dev/null +++ b/webui/src/style.css @@ -0,0 +1,68 @@ +@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; + 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, + input { + font: inherit; + } + + #app { + min-height: 100vh; + min-height: 100dvh; + } +} + +@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'), + }, + }, +})