Initial DruksCode V2: fresh fork of web-to-app (f3b488e) with DruksCode identity
Some checks failed
Android CI Build / check (push) Has been cancelled
Android CI Build / package-apks (push) Has been cancelled
Docs Deploy / build (push) Has been cancelled
Module Market Check / Validate modules/ (push) Has been cancelled
Module Market Publish / Generate submissions.json (push) Has been cancelled
Docs Deploy / deploy (push) Has been cancelled

- Package rename com.webtoapp -> br.com.drukstech.codeapp
- Product rename WebToApp/web-to-app -> DruksCode/drukscode
- Design system Wta* -> Dkc*
- applicationId br.com.drukstech.codeapp (.play for gplay flavor)
- rootProject.name DruksCode
- Fresh git history (upstream kept as remote for sync)
This commit is contained in:
OpenCode AI 2026-09-12 02:42:02 +02:00
commit e9899d2e6d
5002 changed files with 967647 additions and 0 deletions

449
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,449 @@
# Contributing to DruksCode
Thanks for taking the time to help. DruksCode moves fastest when contributions
stay **small and well-scoped** — pick one of the lanes below and ignore the
rest.
> **English** · [简体中文](#贡献-webtoapp中文)
This guide targets **DruksCode 2.6.0** (`versionCode 62`).
---
## Lanes
| You want to… | Go to | Effort |
| --- | --- | --- |
| Publish a JS/CSS module to the in-app **Module Market** | [`modules/README.md`](../modules/README.md) | hours |
| File a bug, request a feature, or ask a question | [GitHub Issues](https://github.com/shiaho777/web-to-app/issues) | minutes |
| Fix a bug or build a feature in the Android client | This guide ↓ | days |
If you're not sure which lane fits, open an issue or a discussion first. There
is no need to write code before there's agreement on the shape of the change.
---
## Module Market submissions
The fastest way to ship something useful to every DruksCode user is to publish
a module. The canonical Module Market guide lives in
[`modules/README.md`](../modules/README.md). Use that file for the full
schema, field rules, reviewer checklist, and CI validation details. The short
version here is only meant to help you pick the right contribution lane:
1. Fork the repo.
2. Add `modules/<your-module>/module.json` and `main.js` (plus `style.css` if
you need CSS).
3. Add an entry to `modules/registry.json`.
4. Open a PR.
The market has **no backend**. Clients read `registry.json` and
`submissions.json`, and only merged modules show up in the catalog. Module
changes are validated in CI by `.github/scripts/ci/validate_modules.py`:
```bash
python3 .github/scripts/ci/validate_modules.py
```
---
## Code contributions
> The canonical working guide for AI coding agents and deep code
> contributors is [`AGENTS.md`](../AGENTS.md) — it documents how the editor,
> export pipeline, shell template, and runtime connect, the dual preview/export
> paths, config-field drift rules, packaging constraints, and common change
> recipes. Read it before any non-trivial change to the build, shell sync,
> export packaging, or config fields.
### Before you write code
- Search [issues](https://github.com/shiaho777/web-to-app/issues) for prior
discussion of the same idea.
- For non-trivial changes, open an issue first describing the problem and the
approach you have in mind. This is much cheaper than rewriting after review.
- **Avoid adding new dependencies.** The lists in `app/build.gradle.kts` and
`shell/build.gradle.kts` are intentionally restrained — the project signs and
packages APKs in-process, and the shell template pins `targetSdk = 28` on
purpose because generated apps rely on fork+exec native runtimes. New
dependencies need a strong justification, and host-only dependencies must
never leak into the shell template.
### Local setup
You'll need:
- Android Studio Hedgehog or newer
- JDK 17
- The Gradle wrapper pins Gradle 9.4.1 — no system Gradle install required
```bash
git clone https://github.com/shiaho777/web-to-app.git
cd drukscode
./gradlew assembleDebug
```
The repo has three Gradle modules: **`app`** (the full builder and host),
**`shell`** (the runtime template embedded in generated APKs), and
**`clone-host`** (the host code used by app cloning). Shared runtime code is
authored **only** under `app/` and synchronized into `shell/` at build time
(`shell/build/generated/` — never edit `shell/src` by hand; it is
regenerated and partly git-ignored).
Run the checks before submitting:
```bash
./gradlew :app:compileStandardDebugKotlin -x syncCloneHostDex --no-configuration-cache
./gradlew :app:testStandardDebugUnitTest --no-configuration-cache -PskipShellTemplateSync=true
./gradlew :app:checkConfigFieldDrift --no-configuration-cache
```
If you touched shell-synced runtime code or export packaging, also rebuild the
template you touched:
```bash
./gradlew :shell:assembleRelease :app:syncShellTemplateApk --no-configuration-cache
```
> Native code (`node_launcher`, `go_exec_loader`, the APK optimizer) builds
> via CMake per-ABI and needs the Android NDK + CMake installed through the
> SDK Manager. CI installs `cmake;3.22.1` and `ndk;28.2.13676358`.
### Where things live
| Area | Path |
| --- | --- |
| App types & central config (`AppType`, `WebApp`) | `app/src/main/java/br/com/drukstech/codeapp/data/model/` |
| On-device APK builder / signer | `app/src/main/java/br/com/drukstech/codeapp/core/apkbuilder/` |
| Server runtimes (Node / PHP / Python / Go / WordPress) | `app/src/main/java/br/com/drukstech/codeapp/core/{nodejs,php,python,golang,wordpress}/` |
| WebView engine, native bridge, fingerprint disguise | `app/src/main/java/br/com/drukstech/codeapp/core/{webview,engine,appearance}/` |
| Shell config + generated-APK entry points | `app/src/main/java/br/com/drukstech/codeapp/core/shell/` |
| Preview players (`ui/gallery/`, `ui/media/`) vs packaged players (`ui/shell/`) | `app/src/main/java/br/com/drukstech/codeapp/ui/` |
| Extension modules, Module Market, Agent | `app/src/main/java/br/com/drukstech/codeapp/core/{extension,market,agent}/` |
| Compose UI screens & design system | `app/src/main/java/br/com/drukstech/codeapp/ui/` |
| DI graph (source of truth) | `app/src/main/java/br/com/drukstech/codeapp/di/AppModule.kt` |
### Preview ≠ export: the rule behind most rejections
The host app (preview) and generated APKs (export) run **different code**:
| | Preview (`:app`) | Exported APK |
| --- | --- | --- |
| UI | `ui/gallery/`, `ui/media/`, `WebViewManager` live | `ui/shell/*` synced copy |
| Config | Editor / in-memory models | JSON assets via `ShellModeManager` |
A feature is done only when it works on **both** paths. Concretely, an editor
setting that must affect generated APKs has to flow through the full chain —
model → `ApkConfig` JSON → shell config → shell-synced runtime — and a
player-screen behavior has to be implemented in **both** the preview and the
shell composable. Three gates enforce this; run them, don't argue with them:
- `checkConfigFieldDrift` — model/JSON/shell field names must match
(Gson silently drops mismatches).
- `WebViewConfigBooleanCoverageTest` — every new `WebViewConfig` Boolean must
be listed in `flipAllBooleans()` and survive the export round-trip.
- `ShellUiParityTest` — every model field read by host player UI must also be
read by shell player UI (or carry an explicit `allow` reason). Add your new
per-type player pair to it.
### Coding conventions
DruksCode leans on the Kotlin and Jetpack Compose patterns already in the
codebase. A few rules worth calling out:
- **Comments explain *why*, not *what*.** Non-obvious decisions, cross-file
contracts, and issue references (`#781`) get a short comment; clear names
and small functions carry the rest. Don't narrate readable code.
- **Build new UI on the Dkc design system.** Everything renders through
`br.com.drukstech.codeapp.ui.design`. The older `Premium*` / `Enhanced*` / `Settings*`
components are retained as permanent alias layers over the Dkc internals —
don't add new ones, and you don't need to rip them out. A build-time audit
(`.github/scripts/audit_ui_design_system.py`, wired into `build.gradle.kts`) tracks
legacy UI debt against `.github/scripts/ui_design_allowlist.txt`.
- **Reuse the design tokens** in `ui/design/DkcTokens.kt` for spacing, radius,
alpha, and elevation. Don't hard-code numbers. Editor config cards share one
layout grammar (see `AGENTS.md` recipe 12) — copy the neighbouring cards,
don't invent your own, and verify on the emulator, not just by compiling.
- **Strings must cover all 10 supported languages.** UI copy lives in
`core/i18n/Strings.kt` (facade `object Strings` + in-file `StringsA`…`StringsE`,
split only for the JVM constant pool). Supported: Chinese, English, Arabic,
Portuguese, Spanish, French, German, Russian, Japanese, Korean.
Every new or changed user-visible `when (Strings.lang)` block **must** have
real translations for **all 10** branches with no `else ->` — do not leave
pt/es/fr/de/ru/ja/ko as English placeholders. Brand names and pure format
tokens may match English. If you truly cannot translate one language, use
English there and flag it in the PR.
- **Never load user-visible text from `R.string`.** Resource files only cover
zh/en/ar, so lookups silently fall back to Chinese for the other 7 locales.
`R.string` is reserved for non-localised resources.
- **No new top-level singletons** unless you discuss it first. The DI graph in
`di/AppModule.kt` is the source of truth.
- **The Native Bridge is capability-gated.** Any method exposed to web content
via `@JavascriptInterface` must be guarded by the per-capability allow-list
(`NativeBridgeCapabilities`). Never expose a native capability to arbitrary
pages without a gate. PRs that bypass this will be rejected.
- **Preview and packaged runtime are two paths.** See *Preview ≠ export*
above. Shared runtime sources are authored only under `app/` and synced into
`shell/` at build time — do not hand-edit anything under `shell/src`.
- **Avoid catching `Exception` to silence errors.** If recovery is impossible,
log via `AppLogger` and re-throw or return a failed `Result`.
### Security
Anything touching the WebView, file IO, APK signing, or the native bridge has
downstream impact on **every generated app**, so it gets extra scrutiny.
Specifically, PRs will be declined if they:
- store or expose credentials/secrets to web content,
- widen the native surface exposed to arbitrary pages without a capability
gate,
- weaken APK signing, isolation, or the fingerprint-disguise defaults,
- bundle unrelated changes inside a large diff.
### Commit messages
We don't enforce Conventional Commits, but a clear subject line and a wrapped
body that explains *why* the change exists make reviews much faster. Example:
```
ModuleMarket: cache registry.json for an hour
Repeatedly hitting raw.githubusercontent.com on every screen open is wasteful
and triggers GitHub's anonymous rate limit on slow connections. Cache the
parsed registry under cache/module_market/ and treat anything fresher than
an hour as authoritative; the refresh button always bypasses the cache.
```
### Pull requests
- Branch from `main`. Keep PRs focused — one logical change per PR.
- **Write Issues and PRs in English** — titles, bodies, and review threads.
- Describe the user-visible effect in the PR body, not just the code change.
- The standard loop is Issue → branch → PR (`Fixes #N`) → green CI → merge.
Issues close on merge, never on PR open or red CI.
- If your PR touches the build system, native code, or APK packaging, attach
the output of the template rebuild above (or note the failure if it fails
on your machine).
- CI runs on every PR. A green CI is required before merge.
- Never commit secrets, keystores, `local.properties`, or IDE/cache junk.
### Reviewer expectations
Maintainer reviews look for:
- **Correctness.** Does the change do what the PR description says? Was it
verified by execution (tests, build output), not just by reading?
- **Safety.** See *Security* above.
- **Scope discipline.** Drive-by refactors expand the review surface. Land
them in a separate PR.
- **Style fit.** See *Coding conventions* above.
- **Both paths.** Preview-only features and export-only wiring are the two
classic failure modes — see *Preview ≠ export* above.
---
## Code of conduct
Feedback is required to be precise. "This code is garbage" is acceptable only
when accompanied by which code, why, and what would be less so. The maintainer
(**shiaho**) has on file a standing waiver covering criticism of himself, his
judgment, and his life choices. Praise unaccompanied by a working patch is
logged and otherwise disregarded.
---
<a id="贡献-webtoapp中文"></a>
## 贡献 DruksCode(中文)
非常感谢你愿意花时间。DruksCode 的迭代速度取决于"小而聚焦"的贡献——下面三条路
里挑一条走,其他的先忽略。
本指南对应 **DruksCode 2.6.0**(`versionCode 62`)。
### 你想做什么?
| 你想…… | 路径 | 投入 |
| --- | --- | --- |
| 给应用内的 **模块市场** 提交一个 JS/CSS 模块 | [`modules/README.md`](../modules/README.md) | 几小时 |
| 报 Bug、提 Feature、问问题 | [GitHub Issues](https://github.com/shiaho777/web-to-app/issues) | 几分钟 |
| 修 Bug 或在 Android 客户端里做新功能 | 见下方代码贡献小节 | 几天 |
不确定走哪条,先开 issue 或 discussion——动手前对齐方向,比写完再返工便宜
得多。
### 模块市场贡献
让你的工作触达每一个用户最快的方式就是提一个模块。Schema、审核 Checklist 和
CI 校验细节的主文档是 [`modules/README.md`](../modules/README.md)。这里仅保
留一个极简入口,方便你先判断自己是不是走这条贡献路线:
1. Fork 本仓库
2. 新建 `modules/<你的模块>/module.json` 和 `main.js`(需要 CSS 时再加
`style.css`)
3. 在 `modules/registry.json` 里加一行索引
4. 提 PR
市场**没有后端**。客户端读取 `registry.json` 和 `submissions.json`,只有已合
并的模块才会在市场出现。模块改动会经过 CI 的
`.github/scripts/ci/validate_modules.py` 校验,提 PR 前建议先本地跑:
```bash
python3 .github/scripts/ci/validate_modules.py
```
### 代码贡献
> 面向 AI 编码助手与深度代码贡献者的权威工作指南是
> [`AGENTS.md`](../AGENTS.md) —— 它讲清了编辑器、导出流水线、shell 模板与运行时
> 之间如何连接,以及双路径预览 / 导出、配置字段漂移规则、打包约束和常见改动
> 配方。对构建、shell 同步、导出打包或配置字段的任何非平凡改动,动手前请先读。
**动手前**
- 在 [issues](https://github.com/shiaho777/web-to-app/issues) 里先搜一下
类似讨论
- 较大的改动请先开 issue 说明要解决的问题和方案
- **谨慎引入新依赖**。`app/build.gradle.kts` 与 `shell/build.gradle.kts` 的依
赖列表刻意保持精简——本项目全程在设备内签名打包 APK,shell 模板也特意把
`targetSdk` 锁在 28,因为生成应用依赖 `fork`、`exec` 原生运行时。新依赖需
要充分理由,且宿主专用依赖绝不能漏进 shell 模板。
**本地环境**
- Android Studio Hedgehog 或更新版本
- JDK 17
- Gradle wrapper 已锁定 Gradle 9.4.1,无需系统安装 Gradle
```bash
git clone https://github.com/shiaho777/web-to-app.git
cd drukscode
./gradlew assembleDebug
```
仓库有三个 Gradle 模块:**`app`**(完整构建器和宿主)、**`shell`**(嵌入生
成 APK 的运行时模板)和 **`clone-host`**(应用克隆使用的宿主代码)。共享运
行时代码以 `app/` 为唯一事实来源,构建时同步到 `shell/`(`shell/build/` 下,
不要手改 `shell/src`——它是再生成的产物,部分被 git 忽略)。
提交前请跑通:
```bash
./gradlew :app:compileStandardDebugKotlin -x syncCloneHostDex --no-configuration-cache
./gradlew :app:testStandardDebugUnitTest --no-configuration-cache -PskipShellTemplateSync=true
./gradlew :app:checkConfigFieldDrift --no-configuration-cache
```
动到 shell 同步的运行时代码或导出打包,还要重建你碰过的模板:
```bash
./gradlew :shell:assembleRelease :app:syncShellTemplateApk --no-configuration-cache
```
> 原生代码(`node_launcher`、`go_exec_loader`、APK 优化器)按 ABI 经 CMake
> 编译,需要通过 SDK Manager 安装 Android NDK + CMake。CI 安装的是
> `cmake;3.22.1` 与 `ndk;28.2.13676358`。
**代码大致位置**
| 区域 | 路径 |
| --- | --- |
| 应用类型与核心配置(`AppType`、`WebApp`) | `app/src/main/java/br/com/drukstech/codeapp/data/model/` |
| 设备端 APK 打包 / 签名 | `app/src/main/java/br/com/drukstech/codeapp/core/apkbuilder/` |
| 服务端运行时(Node / PHP / Python / Go / WordPress) | `app/src/main/java/br/com/drukstech/codeapp/core/{nodejs,php,python,golang,wordpress}/` |
| WebView 引擎、原生桥、指纹伪装 | `app/src/main/java/br/com/drukstech/codeapp/core/{webview,engine,appearance}/` |
| Shell 配置与生成 APK 入口 | `app/src/main/java/br/com/drukstech/codeapp/core/shell/` |
| 预览播放器(`ui/gallery/`、`ui/media/`)vs 打包播放器(`ui/shell/`) | `app/src/main/java/br/com/drukstech/codeapp/ui/` |
| 扩展模块、模块市场、Agent | `app/src/main/java/br/com/drukstech/codeapp/core/{extension,market,agent}/` |
| Compose UI 与设计系统 | `app/src/main/java/br/com/drukstech/codeapp/ui/` |
| DI 依赖图(单一事实来源) | `app/src/main/java/br/com/drukstech/codeapp/di/AppModule.kt` |
**预览 ≠ 导出:大多数打回的原因**
宿主应用(预览)和生成的 APK(导出)跑的是**两套代码**:
| | 预览(`:app`) | 导出的 APK |
| --- | --- | --- |
| 界面 | `ui/gallery/`、`ui/media/`、实时 `WebViewManager` | `ui/shell/*` 的同步副本 |
| 配置 | 编辑器 / 内存模型 | 经 `ShellModeManager` 读取的 JSON 资源 |
一个功能只有两边都通才算做完。编辑器开关要影响生成 APK,必须走完整条
链——模型 → `ApkConfig` JSON → shell 配置 → shell 同步的运行时代码;而播放
器界面的行为要在预览和壳两套 composable 里各实现一遍。三道门禁负责兜底,
跑过它们,别跟它们争:
- `checkConfigFieldDrift`——模型 / JSON / shell 字段名必须一致(Gson 会静默
丢掉对不上的字段)。
- `WebViewConfigBooleanCoverageTest`——`WebViewConfig` 每新增一个 Boolean 都
必须进 `flipAllBooleans()` 并走完导出往返。
- `ShellUiParityTest`——宿主播放器读的每个模型字段,壳播放器也必须读(或写
明 `allow` 理由)。新增每类播放器时照样子扩展。
**代码风格**
- **注释解释 *为什么*,不复述 *干了什么*。** 反直觉的决策、跨文件约定、
issue 引用(`#781`)值得一句短注释;命名清晰的小函数不需要解说。不要给
易读的代码写旁白。
- **新 UI 一律构建在 Dkc 设计系统之上**——所有界面通过 `br.com.drukstech.codeapp.ui.design`
渲染。旧的 `Premium*` / `Enhanced*` / `Settings*` 组件作为 Dkc 内部实现的
永久别名层保留——不要再新增,也无需强行替换。构建期有一个审计脚本
(`.github/scripts/audit_ui_design_system.py`,接进 `build.gradle.kts`)按
`.github/scripts/ui_design_allowlist.txt` 跟踪历史 UI 债务。
- 复用 `ui/design/DkcTokens.kt` 里的设计 token(间距、圆角、透明度、高度),
别硬编码数字。编辑器配置卡片共用一套排版语法(见 `AGENTS.md` recipe 12)——
照抄相邻卡片,不要自创,跑模拟器验效果,不要只编译。
- **字符串必须覆盖全部 10 种已支持语言**:文案在 `core/i18n/Strings.kt`
(facade `object Strings` + 同文件 `StringsA`…`StringsE`,拆分只为常量池)。
已支持:中 / 英 / 阿 / 葡 / 西 / 法 / 德 / 俄 / 日 / 韩。
新增或修改面向用户的 `when (Strings.lang)` **必须**为 10 个分支写真实翻译,
且不许写 `else ->`;禁止把 pt/es/fr/de/ru/ja/ko 继续当英文占位。品牌名、
纯格式符可与英文相同。某语实在不会翻可暂填英文并在 PR 标明。
- **面向用户的文案绝不用 `R.string`。** 资源文件只有 zh/en/ar 三套,其他 7
语会静默回落到中文。`R.string` 只保留给无需本地化的资源。
- 引入新的全局单例前请先讨论;`di/AppModule.kt` 是单一事实来源
- **原生桥是按能力门禁的**:任何通过 `@JavascriptInterface` 暴露给网页的方法,
都必须经过逐能力白名单(`NativeBridgeCapabilities`)。绝不要在没有门禁的
情况下把原生能力暴露给任意页面。绕过门禁的 PR 会被拒
- **预览和打包运行时是两条路径**:见上文《预览 ≠ 导出》。共享运行时代码只改
`app/`,构建时同步进 `shell/`;不要手改 `shell/src` 下的任何东西
- 不要 catch 然后吞掉异常;用 `AppLogger` 记录后重新抛出或返回失败的 `Result`
**安全**
动到 WebView、文件 IO、APK 签名或原生桥的改动会影响**每一个生成出来的
应用**,因此审核更严。出现以下情况的 PR 会被拒:
- 把凭据 / 密钥存储或暴露给网页内容
- 在没有能力门禁的情况下扩大暴露给任意页面的原生面
- 削弱 APK 签名、隔离或指纹伪装的默认强度
- 在一个大 diff 里夹带不相关的改动
**Commit 消息**
不强制 Conventional Commits,但请把"为什么"写进正文。示例见英文段。
**Pull Request**
- 从 `main` 分出分支,每个 PR 只解决一件事
- **Issue 与 PR 请用英文写**——标题、正文、评审讨论
- PR 描述写"用户看得到的效果",不只是代码 diff
- 标准流程是 Issue → 分支 → PR(`Fixes #N`)→ CI 变绿 → 合并。
Issue 只在合并时关闭,开 PR 时不关、CI 红时不关
- 改动涉及构建系统、原生代码或 APK 打包时,附上上面模板重建命令的结果
- CI 必须绿色才会合并
- 不要提交密钥、keystore、`local.properties` 或 IDE / 缓存垃圾
**Review 标准**
- **正确性**——是否真的做了 PR 描述里写的事;是否经过执行验证(测试、
构建产物),而不只是"读起来对"
- **安全**——见上面《安全》小节
- **聚焦**——顺手做的重构请单独发 PR
- **风格**——参考上面那条
- **两条路径**——只在预览生效的功能和只在导出接线的配置是两类经典翻车——见
上文《预览 ≠ 导出》
### 行为准则
反馈须精准。"这代码是坨屎"仅在同时说明"哪段代码、为何如此、怎样才不至于"
时方予受理。维护者(**shiaho**)已就针对其本人、其判断力及其人生选择的批评
出具长期豁免一份并存档。未附可用补丁的赞美,予以记录,余不受理。

BIN
.github/assets/social-preview.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

365
.github/docs/README_CN.md vendored Normal file
View File

@ -0,0 +1,365 @@
<div align="center">
<img src="../../app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" width="96" alt="DruksCode 图标" />
# DruksCode
### 在手机上把 Web 项目打包成可安装的 Android APK。
**一个运行在设备端的 APK 工作台,远不只是套个 WebView —— 它能在手机上 fork+exec 完整的服务端运行时,内置硬核反审查网络栈,能在设备内签名上架 Google Play 的 AAB,还能跑 MV3 浏览器扩展,全程不需要电脑或远程构建服务器。**
[English](../../README.md) · **简体中文**
[![Stars](https://img.shields.io/github/stars/shiaho777/drukscode?style=for-the-badge)](https://github.com/shiaho777/web-to-app/stargazers)
[![Forks](https://img.shields.io/github/forks/shiaho777/drukscode?style=for-the-badge)](https://github.com/shiaho777/web-to-app/network/members)
[![License](https://img.shields.io/badge/License-Unlicense-blue?style=for-the-badge)](../../LICENSE)
[![Android](https://img.shields.io/badge/Android-23%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](#)
</div>
<p align="center">
<a href="https://www.swiftproxy.net/?ref=shiaho">
<img src="../assets/sponsors/swiftproxy-logo-full.png" alt="Swiftproxy" width="220" />
</a><br/>
<a href="https://www.swiftproxy.net/?ref=shiaho"><b>由 Swiftproxy 赞助</b></a> —— 高质量纯净住宅IP提供商
</p>
<p align="center">
<a href="#webtoapp-有什么不同">有什么不同</a> ·
<a href="#能力速览">能力速览</a> ·
<a href="#能打包什么">能打包什么</a> ·
<a href="#完整能力地图">完整能力地图</a> ·
<a href="#模块市场">模块市场</a> ·
<a href="#文档站点">文档站点</a> ·
<a href="#架构说明">架构说明</a> ·
<a href="#从源码构建">构建</a>
</p>
---
<div align="center">
<img src="../assets/social-preview.png" width="90%" alt="DruksCode:我的应用首页、应用列表与主工具箱(Agent、扩展模块、端口管理器)、单个应用的 APK 操作(编辑、创建快捷方式、构建/分享/导出 APK),以及关于页面——全部运行在 Android 手机上" />
</div>
### 演示视频
<details>
<summary><b>观看:在设备上构建并打包一个应用的快速演示</b></summary>
<video src="https://github.com/user-attachments/assets/4e1a476d-2687-4d97-90a9-d077a15ebe31" controls muted loop playsinline>
您的浏览器不支持 video 标签。
</video>
</details>
---
## DruksCode 有什么不同
绝大多数「网站转 App」工具到「套一个 WebView」就结束了。DruksCode 更像一个放在手机里的 APK 工作台,而真正难的地方,正是它和那些工具的分界线:
- **在设备上跑真实的服务端运行时。** Node.js、PHP、Python、Go、WordPress 以原生二进制的形式直接从 app 存储 fork+exec —— 像 Termux 那样,但被打进一个可安装的 APK。URL 套壳工具根本做不到。
- **内置硬核、反审查的网络栈。** DNS-over-HTTPS、TLS 指纹伪装(Chrome / Firefox / Safari 的 JA3 模板,经本地 MITM 桥接)、双引擎 ECH(加密 SNI)、每应用代理、以及针对受限 SPA 的 CORS 绕过。
- **整个构建自包含。** 二进制 AXML/ARSC 修补、权限裁剪、V1/V2/V3 签名、可直接上架 Google Play 的 AAB 导出,全部在 app 内通过 `apksig` 完成 —— 不排远程队列、不需要电脑。
- **发布后仍可扩展。** 通过 JS/CSS 模块、Tampermonkey 风格油猴脚本,或 MV3 Chrome 扩展(可在应用内实时搜索 Chrome 网上应用店并安装)给应用补能力,不必重新发布宿主。
- **宿主 UI 原生支持 10 种语言。** 中文、English、العربية(RTL)、Português、Español、Français、Deutsch、Русский、日本語、한국어 —— 设置里随时切换;新增界面文案按 10 语维护。
---
## 能力速览
一眼看清里面有什么。每项都对应下方完整能力地图。
| 方向 | 亮点 |
| --- | --- |
| **构建目标** | Web · HTML · 前端 · WordPress · Node.js · PHP · Python · Go · 图片 · 视频 · 图库 · 多网站 |
| **浏览器引擎** | 默认系统 WebView;可选 GeckoView(Firefox)运行时 |
| **网络与反审查** | DoH(7 个服务商)、TLS 指纹伪装 + MITM 桥、ECH、静态/PAC/SOCKS5 代理、CORS 绕过 |
| **隐私与加固** | 50+ 维浏览器指纹伪装、资源加密(AES-256-GCM)、反调试、激活码门控 |
| **本地运行时** | 原生 Node.js 18.20、PHP 8.4 + Composer 2.10、Python 3.14、官方 Go 1.26、WordPress 7.x over SQLite |
| **扩展能力** | 内置模块、`GM_*` 油猴脚本、MV3 Chrome 扩展、Chrome 网上应用店实时搜索 |
| **APK/AAB 产物** | 设备端 V1/V2/V3 签名、Google Play AAB 导出(自动改写 targetSdk)、密钥库管理 |
| **Agent** | 通过最多 57 个工具全面操控应用:生成、构建、导出、端口/引擎/运行时管理、应用克隆、广告拦截、配置模板等;429/5xx 自动重试 |
| **宿主语言** | **10 种界面语言** —— 中文 · English · العربية · Português · Español · Français · Deutsch · Русский · 日本語 · 한국어(阿语 RTL) |
---
## 能打包什么
| 输入 | 输出 | 适合场景 |
| --- | --- | --- |
| 网站 URL | 基于 WebView 的 APK | 官网、工具、后台、文档、内部系统 |
| HTML / 静态前端 | 走 localhost 的 APK | React、Vue、Vite、静态构建、离线 Web 应用 |
| Node.js / PHP / Python / Go | 带设备端本地服务的 APK | 小型服务端应用、管理工具、演示、原型 |
| WordPress | 本地 PHP + SQLite 承载的 APK | 便携站点、主题/插件演示、本地内容包 |
| 图片 / 视频 / 图库 | 媒体型 APK | 相册、课程材料、作品集、离线浏览 |
| 多个网站 | 标签/卡片/信息流/抽屉布局 APK | 导航合集、门户、应用集合 |
| 已安装 APK | 重命名克隆或桌面快捷方式伪装 | 图标/名称/包名实验、APK 重打包研究 |
---
## 完整能力地图
DruksCode 的开关非常多。下面按使用场景分组,并用可折叠区段保持页面顶部清爽可读。
<details>
<summary><b>🌐 浏览器引擎与网络</b></summary>
- **双引擎** —— 默认系统 WebView,可选 GeckoView(Firefox)运行时(首次使用时下载)。
- **内核风味伪装** —— 对外表现为 Chrome、Edge、Samsung Internet、Firefox 或 Safari 风格,但真实引擎不变。
- **桌面模式**、自定义 User-Agent,以及 document-start / end / idle 三种时机的 JS/CSS 注入。
- **弹窗处理** —— 当前窗口、外部浏览器、弹窗窗口或直接拦截。
- **代理** —— 静态 HTTP/HTTPS/SOCKS5、PAC、身份验证、绕过规则和本地 HTTP-to-SOCKS 桥。
- **DNS-over-HTTPS** —— Cloudflare、Google、AdGuard、NextDNS、CleanBrowsing、Quad9、Mullvad,以及自定义 endpoint;支持 strict / automatic 模式。
- **ECH(Encrypted Client Hello)** —— 在**两种引擎**上加密 TLS 握手中的 SNI:GeckoView 经 TRR + 自身 ECH 配置;系统 WebView 经本地 MITM 桥的 Chromium 上行链路(首次使用自动下载组件,导出的 APK 内置)。开关启用时自动联动 DoH;与 SOCKS 上游代理互斥。
- **TLS 指纹伪装** —— 模拟 Chrome 131 / Firefox 133 / Safari 18 的 JA3 指纹(或自定义密码套件),经本地 TLS-MITM 桥接,让发出的 ClientHello 与真实浏览器一致。
- **CORS 绕过** —— 默认开启,让受限静态 SPA 能调用原本被 CORS 拦截的外部 API;同源请求不会被误拦,仅需 CORS/内网桥接时可用轻量 `PrivateNetworkNativeBridgeAdapter`,不必挂完整 Native Bridge。
- **回退链** —— 主目标不可达时自动回退到镜像 URL。
- **PWA** 离线缓存策略、自定义错误页、每应用 hosts 覆写和支付协议处理。
- **兼容开关** —— blob 下载拦截、滚动记忆、图片修复、剪贴板 / 方向 / 通知 polyfill、内网桥接和 Native Bridge 能力门控。
- **下载位置** —— 系统 Downloads、应用私有目录,或用户用 SAF 自选文件夹,整条打包透传链已接通。
</details>
<details>
<summary><b>🛡️ 隐私、指纹防御与加固</b></summary>
- **跨 50+ 维的浏览器指纹伪装** —— User-Agent、WebGL、Canvas、AudioContext、ClientRects、时区、语言、内存、媒体设备、WebRTC、字体、电池、权限、性能、存储、通知、CSS media、iframe 传播和错误栈清理。
- **hosts 规则广告拦截** + cosmetic MutationObserver 过滤,**内置 20 个社区过滤源**(EasyList、uBlock Origin、AdGuard、AdAway + 8 个语言列表),支持逐源启用/停用/删除,自定义导入的过滤源带名称、以卡片管理并可按应用勾选,以及打包进 APK 的自定义订阅规则。
- **资源加密**(PBKDF2 + AES-256-GCM)覆盖打包进去的配置、HTML、媒体和 BGM;可设置自定义加密密码,比默认的包名/证书派生密钥更能抵御逆向提取。
- **运行时加固**(开启加密后可用)—— 反调试、反 Frida、DEX 篡改检测;威胁响应可选只记录、静默退出或随机崩溃。
- **WebView/内容隔离**覆盖存储、WebRTC、Canvas、Audio、WebGL、字体、请求头和 IP 暴露面。
- **激活码门控** —— 本地验证,或接入你自己的 HTTPS 接口并用 EC P-256 验签。接口契约见 [remote activation 文档](remote-activation.md)。
</details>
<details>
<summary><b>📦 设备端服务器运行时(fork + exec)</b></summary>
- **Node.js**(18.20.x)跑在独立 `:nodejs` OS 进程中,底层由原生 `node_launcher` 加载 `libnode.so`;支持自定义原生 `.node` 扩展。
- **PHP 8.4** 取自 `pmmp/PHP-Binaries`,首次使用时下载,支持 Composer 2.10.x 和自定义原生扩展(`zend_extension`、`.so`)。
- **Python 3.14** —— Flask、Django、FastAPI/uvicorn、Tornado、内置 HTTP server;pip 依赖解析到 `.pypackages`,支持自定义原生扩展;二进制名按版本生成,后续升级不必硬编码路径。
- **Go 1.26** —— 官方 Linux arm64 工具链(`.tar.gz` 来自 `dl.google.com`,国内走 USTC 镜像),设备端 `go build` / `go mod` / `go run`、`vendor/` 离线构建、静态文件服务和原生 `go_exec_loader` 包装层;DNS 与 CA 信任走与 PHP 相同的本机 JVM 桥接。
- **WordPress 7.x** 跑在本地 PHP 之上,用 `sqlite-database-integration` 接 SQLite,支持主题和插件导入。
- **Linux Environment** 页面管理 Node、PHP、Python 的工具链和依赖。
- **Port Manager** 通过广播 receiver 协调不同生成应用之间的运行时端口。
- **本地 DNS 桥接代理**(运行在 Android JVM 的 HTTP CONNECT)在 musl/打包二进制无法访问系统 DNS 时,为运行时提供可用的 DNS 解析和出站 HTTP。
</details>
<details>
<summary><b>🧩 扩展与自动化</b></summary>
- **内置模块** —— 视频下载(YouTube / B 站 / 抖音 / 小红书提取器)、带 YouTube 净化(跳过广告、最高画质、后台播放、SponsorBlock)的视频增强、网页分析、页内查找、暗色模式、隐私工具、内容增强、元素拦截和 YouTube 启动器。
- **油猴脚本** —— Greasemonkey/Tampermonkey 风格的 `.user.js`,配套 `GM_*` 桥接(存储、请求、样式、菜单命令)和按授权开放的 Promise 风格 `GM.*` API。
- **MV3 Chrome 扩展运行时**,支持 manifest 内容脚本在 isolated 或 main world 注入,并提供覆盖 runtime、storage、tabs、scripting 和 declarative network request 解析的 `chrome.*` polyfill。
- **应用内 Chrome 网上应用店搜索** —— 按关键词浏览并安装浏览器扩展(也可粘贴商店链接 / 扩展 ID),离线时回退到手动导入。
- **分享码**(`DKC1:` gzip + Base64)和 ZXing 二维码传播。
- **Agent** —— 工具调用型助手,内置最多 57 个工具覆盖应用全部功能:创建/编辑/构建/导出应用、管理端口与浏览器引擎、安装/清理运行时、广告拦截 hosts 规则、通用配置模板、使用统计、应用克隆、批量导入、Play 合规检查、模块开发。计划模式等待用户确认;对 429/5xx 自动退避重试。
</details>
<details>
<summary><b>📱 应用体验</b></summary>
- **启动屏** —— 图片或视频,支持跳过、视频裁剪区间和固定方向。
- **背景音乐** —— 播放列表 + LRC 同步歌词、歌词动画、自定义字体/颜色/描边/阴影和在线音乐搜索。
- **工具栏、状态栏(亮色/暗色)、导航栏、悬浮窗模式和长按菜单样式。** 浏览器工具栏是一个主开关(默认关闭),可逐项控制标题/URL/后退/前进/刷新按钮,并带原生 **页内查找** 底栏(工具栏查找按钮)和 **控制台** 面板,便于在设备上调试。状态栏颜色可跟随主题、自定义色、全透明,或 **PAGE_TOP**(采样页面顶部像素,让系统栏跟内容同色)。
- **下载位置模式** —— 系统 Downloads、应用私有目录,或用户用 SAF 自选文件夹。
- **公告模板**,可在启动、定时或无网络时触发。
- **宿主应用语言** —— 整个打包器界面可在 10 种语言间切换(中文 / English / العربية / Português / Español / Français / Deutsch / Русский / 日本語 / 한국어);阿拉伯语完整 RTL。
- **页内翻译覆盖层** —— 20 种目标语言,支持 Google、MyMemory、LibreTranslate、Lingva 引擎,并在引擎间自动故障转移(给*生成应用内容*做页内翻译,与宿主 UI 语言是两套能力)。
- **打印桥接** —— 拦截 `window.print()` 和 blob/data-URL 的 PDF,交给 Android 打印框架 / 导出 PDF(onPageStarted 会再注入一次,避免晚导航丢 hook)。
- **媒体会话桥接** —— 把网页媒体接入系统媒体通知和锁屏控制,支持蓝牙耳机和 Android Auto。
- **通知** —— Web Notification polyfill、定时与持久化通知(含进度更新)、URL 轮询前台服务、深链、开机自启、定时启动和后台运行服务。
- **每个 APK 的使用统计**、Vico 图表和 URL 健康监测。
</details>
<details>
<summary><b>🔧 APK / AAB 导出与签名</b></summary>
- **自定义包名**、`versionName`、`versionCode`、图标、名称、架构目标和导出格式。
- **按生成 APK 的实际勾选注入权限**,并从模板 manifest 中裁剪未使用权限。
- **一键 AAB 导出** —— 按需自动构建 APK,转换成可直接上架的签名 AAB(自动把 `targetSdk` 改写到 Play 要求的级别,目前为 36,并在本地生成 protobuf 元数据);支持中途取消。
- **密钥库管理** —— 创建、导入、导出、删除和证书指纹查看;支持 PKCS12/PFX/JKS/BKS 导入,包括 Android Studio upload key 那种 store 密码和 key 密码不同的情况。
- **签名方案** —— V1、V2、V3 独立控制,可对旧证书兼容性自动回退;自定义 V1 签名文件名,对应 `META-INF/<name>.SF` / `.RSA`。
- **性能选项** —— 图片压缩、WebP 转换、代码压缩、懒加载、DNS 预取、preload 提示。
- **完整项目备份/恢复和应用数据备份/恢复。**
</details>
<details>
<summary><b>🗂 文件管理与项目工具</b></summary>
- **文件管理** —— 一个界面统一查看、分享、安装、打开和清空构建产物(APK 构建、AAB 导出、应用克隆、构建日志)和用户文件目录,并提供只读的构建日志查看器。
- **网站爬虫**用于生成离线包 —— HTML、CSS、JS、图片、字体、`url()`、`srcset`、`@import`、路径重写、同域限制、深度限制和体积限制;并行流式 worker 池,进度回调回到主线程。
- **多网站应用** —— 标签、卡片、信息流、抽屉布局,每站点独立图标/主题色/提取选择器/刷新间隔和共享 JS/CSS。
- **图库应用** —— 媒体分类、网格/列表/时间线视图、随机/单循、排序、缩略图条、浮层、视频自动下一个和播放记忆。
- **应用修改器** —— 桌面快捷方式伪装,或真正的二进制克隆、manifest/资源修补和重签名。
</details>
<details>
<summary><b>🔬 专项工具与研究功能</b></summary>
- **设备伪装**属于技术演示能力,必须在用户知情同意下使用。
</details>
---
## Agent
DruksCode 内置了一个 AI Agent(从 **⋮ → Agent** 打开),可以通过自然语言对话操控整个应用。你只需描述想做什么,Agent 就会通过工具调用循环在设备端执行,后端模型由你在 AI 设置中配置的任意受支持接口提供——Chat Completions、Anthropic Messages、OpenAI Responses 三种 API 格式,以及 Google Gemini 与 Ollama / LM Studio / VLLM 本地模型。
**工作原理:**
1. 你发送消息(也可附带图片作为视觉上下文)。
2. LLM 推理后发出 `tool_calls`。
3. Agent 在设备端执行每个工具 —— 只读工具直接运行;写入工具会先弹出权限确认对话框。
4. 结果回传给 LLM,LLM 继续执行直到任务完成或向你提出澄清问题。
**最多 57 个内置工具,按功能域分组(3 个图像工具仅在配置了图像模型时加载):**
| 功能域 | 示例 |
| --- | --- |
| 文件 | 读取、写入、编辑、删除、列出、glob、grep 项目文件 |
| 应用 | 列出、查看、创建、更新应用配置 |
| 应用生命周期 | 构建 APK/AAB、导出、分享、创建快捷方式、复制、删除、移动分类 |
| 端口与引擎 | 扫描/终止端口,查看/切换/删除浏览器引擎(WebView、GeckoView) |
| 运行时 | Node.js、PHP、Python、Go、WordPress、Linux 环境的状态查看、安装和缓存清理 |
| 广告拦截 | 规则数量统计,导入/删除/启用/停用 hosts 订阅 |
| 统计与健康 | 使用统计、URL 健康检查 |
| 应用修改器 | 列出已安装应用、克隆/换壳、批量导入、导出模板 |
| 构建环境与合规 | 初始化 Linux 构建环境、安装组件、Google Play 合规检查 |
| 模块 | 列出、创建、更新扩展模块 |
| 交互 | 向用户提问(多选)、计划模式(提议→批准→执行)、待办跟踪 |
---
## 模块市场
DruksCode 有一个由 GitHub 驱动的模块市场,用来分发社区贡献的 JS/CSS 扩展模块。目录本质上就是这个仓库里的文件,所以贡献流程就是普通 PR。
```
modules/
├── registry.json # App 读取的目录
├── submissions.json # CI 生成的 PR / 贡献者元数据
├── README.md # 贡献者指南
└── <模块文件夹>/ # 每个模块
```
App 会同时拉取 `registry.json` 和 `submissions.json`,只展示两边都存在的模块,保证应用内市场和已经合并的 PR 对齐。submissions 文件还会记录每个模块的全部贡献者,因此应用内会以叠加头像的形式展示所有参与过该模块的人,并按贡献模块数排出贡献者榜单。目录文件和模块图标会优先走全球镜像加速,raw.githubusercontent.com 和 jsDelivr 作为自动回退,因此商店在全球(含中国大陆)都能快速加载。
- 用户打开 **扩展模块** 页面,点击右上角商店图标即可安装。
- 贡献者在 `modules/` 下添加文件夹,更新 `registry.json`,然后提交 PR。
- 客户端默认缓存 1 小时,模块合并后不需要发新版 App。
这里保留的是模块市场的高层说明;真正的投稿规则、字段 schema、审核 Checklist 和 CI 校验细节统一写在 [`modules/README.md`](../../modules/README.md)。
社区市场只承载 JS/CSS 扩展模块。**浏览器扩展(MV3)**不再是社区目录 —— **浏览器扩展** Tab 直接实时搜索 Chrome 网上应用店:输入关键词、浏览结果、通过现有的 CRX 下载链路按需安装。如果实时搜索不可达,也可以粘贴商店链接或 32 位扩展 ID 直接安装。实时搜索需要能访问 Google 的网络。
## 文档站点
官方文档站点发布在 **[shiaho777.github.io/web-to-app](https://shiaho777.github.io/web-to-app/)**,中英双语:
| 板块 | 地址 | 内容 |
| --- | --- | --- |
| 使用指南 | [/zh/guide/introduction](https://shiaho777.github.io/web-to-app/zh/guide/introduction) | 快速上手、主界面、应用类型、单应用操作与通用配置、FAQ |
| 开发者文档 | [/zh/developer/](https://shiaho777.github.io/web-to-app/zh/developer/) | 架构、导出管线、shell 同步、配置漂移、i18n、常见改动配方 |
| 扩展开发 | [/zh/extensions/](https://shiaho777.github.io/web-to-app/zh/extensions/) | JS/CSS 模块、油猴脚本、Chrome MV3、API 参考、发布流程 |
| English | [/](https://shiaho777.github.io/web-to-app/) | 上述全部页面的英文原版 |
## 架构说明
- 仓库有**三个 Gradle 模块**:`app`(完整构建器和宿主)、`shell`(嵌入生成 APK 的运行时宿主)、`clone-host`(应用克隆的宿主代码 —— 编译提取 `classes.jar`,经 d8 转 DEX,作为 asset 供 `AppCloner` 使用)。
- 运行时代码以 `app` 为唯一事实来源,再同步到 `shell`,所以共享 WebView/运行时行为只维护一份(`core/shell`、`core/webview`、`core/engine`、`core/extension`、`ui/shell` 等)。
- APK 构建器在二进制 AXML/ARSC 层修补模板 APK,注入配置与资源,裁剪权限,并用 `apksig` 签名。另有独立的加密构建路径(`EncryptedApkBuilder`)提供资源加密、加壳和完整性校验。
- 生成 APK(经 shell 模板)特意把 `targetSdk` 钉在 28 —— 这是让它们能从 app 存储 `fork`、`exec` 原生运行时(Node.js、PHP、Python、Go、WordPress)的关键,网址转 APK 类工具做不到这点。宿主应用自身以 35 为目标(杀毒引擎会把低 targetSdk 构建误判为旧木马);该级别的 SELinux W^X 会拦截宿主侧基于 exec 的运行时预览,它们会以明确提示优雅降级 —— Node.js 预览(JNI)和所有导出的应用均不受影响。导出 AAB 时会单独把 `targetSdk` 改写以满足 Google Play 上架要求;纯 WebView 应用类型(Web/HTML/Frontend/Gallery/Media/MultiWeb)还可在 APK 导出面板选择提高独立 APK 的 `targetSdk`(34/35/36),服务端运行时类型固定 28,因为 `targetSdk >= 29` 的 W^X 会破坏内置二进制的 fork+exec。
- 服务端运行时和可选 GeckoView 原生库(`.so` + `omni.ja`)不会打进基础 APK,而是在首次使用时下载;GeckoView 的 API 类来自 gradle 依赖,而体积大的原生制品按需拉取。
- 配置中心是 `WebApp`(`data/model/WebApp.kt`)及其各 `*Config` 类 —— 所有功能配置的单一事实来源,经一条完整的打包透传链带进生成的 APK。
## 技术栈
- Kotlin、Jetpack Compose、Material 3
- Koin 依赖注入
- Room 2.7.2 + KSP 数据持久化
- OkHttp 4.12.0 + `okhttp-dnsoverhttps`
- `com.android.tools.build:apksig` 8.3.0 用于 APK 签名
- BouncyCastle 1.78.1 用于加密与签名
- `protobuf-javalite` 3.25.5 用于 AAB 元数据
- Firebase Cloud Messaging(需自行配置 Firebase)用于推送通知
- GeckoView 作为可选浏览器引擎(原生库在首次使用时下载)
- Coil 负责图片、视频、GIF 加载
- Haze 用于毛玻璃 / 玻璃拟态 UI
- Gson 用于 JSON 序列化(shell 配置链路)
- AndroidX Security Crypto + DataStore 存储密钥
- Vico Compose-M3 绘制图表
- ZXing 用于二维码分享
- Apache Commons Compress + xz 用于项目导入和网站爬虫
- JNI 原生 C++ 目标:`node_launcher` 和 `go_exec_loader`
- Robolectric 单元测试
完整依赖见 [app/build.gradle.kts](../../app/build.gradle.kts)。
## 从源码构建
要求:Android Studio Hedgehog 或更新版本,JDK 17。Gradle wrapper 已锁定 Gradle 9.4.1。
```bash
git clone https://github.com/shiaho777/web-to-app.git
cd drukscode
./gradlew assembleDebug
```
Release 构建请通过 `local.properties` 和 `app/build.gradle.kts` 配置签名。
## 参与贡献
| 路径 | 内容 | 指南 |
| --- | --- | --- |
| `modules/` | 给应用内市场提交一个社区模块 | [modules/README.md](../../modules/README.md) |
| Issues | 报告 Bug 或申请功能 | [GitHub Issues](https://github.com/shiaho777/web-to-app/issues) |
| 代码 | 修 Bug 或在 Android 客户端做新功能 | [CONTRIBUTING.md](../CONTRIBUTING.md) |
| `AGENTS.md` | 面向 AI 编码助手与深度贡献者的权威指南(架构连线、shell 同步、导出流水线、配置漂移、打包约束) | [AGENTS.md](../../AGENTS.md) |
## 联系方式
开发者:**shiaho**。
| 平台 | 链接 |
| --- | --- |
| GitHub | [github.com/shiaho777/web-to-app](https://github.com/shiaho777/web-to-app) |
| Telegram | [t.me/webtoapp777](https://t.me/webtoapp777) |
| X (Twitter) | [@shiaho777](https://x.com/shiaho777) |
| Bilibili | [b23.tv/8mGDo2N](https://b23.tv/8mGDo2N) |
| QQ 群 | 1041130206 |
## 许可证
[The Unlicense](../../LICENSE)。
设备伪装等高级功能仅用于技术演示,必须在用户知情同意下使用。
## 鸣谢
[daoxe.com](https://daoxe.com) — 自用中转站。
<p align="center">
<a href="https://www.swiftproxy.net/?ref=shiaho">
<img src="../assets/sponsors/swiftproxy-banner-zh.png" width="640" alt="Swiftproxy —— 全球高质量纯净住宅IP,动态流量不过期,支持HTTP(S)/SOCKS5,九折优惠码:PROXY90" />
</a>
</p>
<p align="center">
<a href="https://www.swiftproxy.net/?ref=shiaho"><b>由 Swiftproxy 赞助</b></a> —— Swiftproxy 提供 9000万+ 纯净住宅 IP,覆盖全球 220+ 个国家和地区,支持 HTTP(S)/SOCKS5、动态轮换、Sticky Session 及精准地域定位。适用于 Web App、自动化、网页抓取及全球网络访问等场景,提供稳定可靠的代理服务。支持免费测试,使用优惠码 <code>PROXY90</code> 可享 9 折优惠。
</p>
---
<div align="center">
**开源 · 为 Android 高阶用户打造 · Star 一下支持项目**
</div>

331
.github/docs/remote-activation.md vendored Normal file
View File

@ -0,0 +1,331 @@
# Remote Activation — Server Contract
DruksCode can verify activation codes against **your own HTTPS endpoint**
instead of (or in addition to) the codes baked into the APK. This lets you
revoke codes, issue them dynamically, and control usage centrally — without
rebuilding the app.
> **Security note.** The host is open source and verification runs on the
> client, so a determined attacker can still patch the check out. Remote
> verification *raises the bar* and gives you revocation/rotation; it is not
> an anti-cracking guarantee. The response signature below stops a fake server
> or a man-in-the-middle from forging an `ok: true`, which is the part that
> matters most.
## How it is configured in the app
When building an app (or in App Modifier), enable **Online Verification** under
the activation-code section and fill in:
| Field | Meaning |
| --- | --- |
| Verification endpoint | Your `https://…` URL. Plain `http://` is rejected. |
| Signature public key | An **EC P-256** public key in Base64 (SPKI / `BEGIN PUBLIC KEY`). The app verifies every response with it. |
| Offline policy | `ALLOW_CACHED` (default): allow the last successful result until it expires. `DENY`: block when offline. `ALLOW`: always allow when offline (insecure). |
| Deliver target URL | When on, the target URL is delivered by the server at activation time (not packaged in the APK). See [Dynamic URL delivery](#dynamic-url-delivery). |
| Encrypt target URL | When on (requires Deliver URL), the server must encrypt the URL with AES-256-GCM before signing. See [URL Encryption](#url-encryption-aes-256-gcm). |
| AES-256 key | 32-byte Base64 key shared with the server. Required when encryption is enabled. |
## Request (app → your server)
```
POST <your verification endpoint>
Content-Type: application/json; charset=utf-8
Accept: application/json
```
```json
{
"code": "ABC123",
"deviceId": "f3a9…",
"packageName": "com.example.app",
"nonce": "Base64-random-24-bytes",
"ts": 1733270400000,
"deviceBound": false
}
```
- `code` is normalised (uppercased, trimmed) before sending.
- `deviceId` is a per-device identifier (not a hardware ID; it is a salted hash).
- `nonce` is fresh per request — you **must** echo it back unchanged (replay
protection).
- `ts` is the client clock in epoch milliseconds.
- `deviceBound` is `true` when the app has the **Device binding (one-time codes)**
toggle enabled in the editor's remote-activation section (it is always `false`
for purely local verification — see below). For such codes your server should
enforce per-device binding: record the first `deviceId` that activates a given
`code`, and reject the same `code` from any different `deviceId` (return
`{ "ok": false }`). This is the only way to truly restrict a device-bound code
to one device — the app itself has no shared state between devices, so a purely
local device-bound code can only prevent re-activation on the same device
after a local reset or hardware change.
The reference worker in `examples/remote-activation-worker` implements this as
seats: `maxDevices` (default `1`) devices may claim a code; a code with
`maxDevices: 1` behaves as a **one-time / single-device code**. The claimed
device id survives uninstall + reinstall on the same device (it derives from
`ANDROID_ID`, stable per signing key), so a seat is only released when you edit
the KV record. To free a seat manually, remove the `deviceId` entry from
`record.devices` (or delete `code:<CODE>`).
## Response (your server → app)
```json
{
"ok": true,
"expiresAt": 1735862400000,
"remainingUses": 5,
"message": "",
"nonce": "Base64-random-24-bytes",
"sig": "Base64-ECDSA-signature",
"url": "https://example.com/app"
}
```
| Field | Type | Notes |
| --- | --- | --- |
| `ok` | boolean | `true` grants activation. |
| `expiresAt` | number \| null | Epoch ms. If present and in the past, the app treats it as expired. Use `0`/omit for "never expires". |
| `remainingUses` | number \| null | Informational. Use `-1`/omit when not tracking. |
| `message` | string | Shown to the user on rejection. |
| `nonce` | string | Must equal the request nonce. |
| `sig` | string | Base64 ECDSA (`SHA256withECDSA`, DER-encoded) over the canonical payload below. |
| `url` | string \| null | Optional. The target URL to load. Only used (and signature-bound) when the app has **Deliver target URL from server** enabled — see [Dynamic URL delivery](#dynamic-url-delivery). When **Encrypt target URL** is also enabled, this field contains the AES-256-GCM ciphertext (see [URL Encryption](#url-encryption-aes-256-gcm)). Omit otherwise. |
### Canonical payload that gets signed
The app rebuilds this exact JSON string and verifies `sig` against it. **Key
order and formatting matter** — it is compact JSON with these four keys, in
this order:
```
{"ok":<true|false>,"expiresAt":<number>,"remainingUses":<number>,"nonce":"<nonce>"}
```
- `expiresAt` falls back to `0` when you omit it.
- `remainingUses` falls back to `-1` when you omit it.
- No spaces, booleans unquoted, numbers unquoted.
When **Deliver target URL from server** is enabled, a fifth key `url` is
appended (after `nonce`), and the signature must cover it too:
```
{"ok":<true|false>,"expiresAt":<number>,"remainingUses":<number>,"nonce":"<nonce>","url":"<url>"}
```
`url` falls back to `""` when you omit it. Apps that do not enable URL delivery
verify the legacy four-key payload, so existing servers keep working unchanged.
Example of the precise bytes to sign:
```
{"ok":true,"expiresAt":1735862400000,"remainingUses":5,"nonce":"k7Q…"}
```
## Dynamic URL delivery
When **Deliver target URL from server** is enabled in the app, the target URL
is not packaged into the APK. Instead the app loads the URL you return in the
`url` field after a successful activation (and caches it for offline launches
under `ALLOW_CACHED`). This lets you change the URL without repackaging, and
keeps the URL out of the APK so it cannot be extracted statically.
- Return the URL in `url` and include it in the signed payload (fifth key).
- The URL is cached on the device after a successful online activation; offline
launches reuse the cached URL per the offline policy.
- The URL is delivered in the clear inside the signed response (over HTTPS).
This raises the bar against casual extraction but is not DRM-grade — a
determined attacker can still recover it from the response or memory.
## URL Encryption (AES-256-GCM)
When both **Deliver target URL from server** and **Encrypt target URL** are
enabled, the server must encrypt the URL with AES-256-GCM **before** signing.
The ciphertext goes into the `url` field and the signed payload.
| Parameter | Value |
| --- | --- |
| Algorithm | AES-256-GCM (authenticated encryption) |
| Key | 32 bytes, shared between app config and server |
| IV | 12 random bytes per encryption |
| Wire format | `Base64(IV[12B] \|\| ciphertext \|\| GCM tag[16B])` |
**Why encrypt the URL when the response is already over HTTPS and ECDSA-signed?**
Defense in depth. If an attacker compromises the TLS layer (corporate MITM
proxy with a user-installed CA, misconfigured certificate pinning bypass) or
dumps the response from memory, the URL stays unreadable without the AES key.
The signature still prevents tampering, and the encryption prevents reading.
### Generating an AES-256 key
```bash
# Generate a 32-byte random key and Base64-encode it
openssl rand -base64 32
```
Paste the same Base64 string into both the app's **AES-256 key** field and
your server configuration.
### Server-side encryption (Node.js)
```js
const crypto = require("crypto");
function encryptUrl(plaintext, aesKeyBase64) {
const key = Buffer.from(aesKeyBase64, "base64"); // 32 bytes
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plaintext, "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]);
const tag = cipher.getAuthTag(); // 16 bytes
// Wire format: IV || ciphertext || tag
return Buffer.concat([iv, encrypted, tag]).toString("base64");
}
```
### Signing flow with encryption
1. Encrypt the URL → Base64 ciphertext blob.
2. Build the canonical payload with the ciphertext blob as the `url` value.
3. Sign the canonical payload with ECDSA.
4. Return the ciphertext blob in the response `url` field and the signature in `sig`.
The client verifies the signature first (which covers the ciphertext), then
decrypts `url` with the shared AES key to recover the plaintext URL. An
attacker who tampers with the ciphertext breaks the signature; an attacker who
reads the response sees only encrypted bytes.
## Generating a key pair
```bash
# private key (keep on your server)
openssl ecparam -name prime256v1 -genkey -noout -out ec_private.pem
# public key (paste into the app's "Signature public key" field, header lines optional)
openssl ec -in ec_private.pem -pubout -out ec_public.pem
```
## Ready-to-deploy option (Cloudflare Worker)
If you would rather not run a server at all, there is a complete reference
worker in
[`examples/remote-activation-worker/`](../../examples/remote-activation-worker/README.md):
codes held in Workers KV (revocable without rebuilding the APK), signed
responses, device binding, optional AES-256-GCM URL delivery, and a
`GET /` self-check that hands you the public key to paste into the app. It
deploys with `npx wrangler deploy` and ships a test that verifies responses the
way the client does.
One caveat it documents, because it applies to any server you write: **the
request does not say whether the app expects a delivered URL**, so the server
cannot discover it. A code that carries a `url` must be paired with an app that
has "Deliver target URL" enabled, and a code without one with an app that has it
disabled — otherwise the two sides sign different payloads and every activation
fails.
The Node example below is the same contract for your own infrastructure.
## Reference server (Node.js, no framework)
```js
const http = require("http");
const crypto = require("crypto");
const fs = require("fs");
const privateKey = crypto.createPrivateKey(fs.readFileSync("ec_private.pem"));
// Your own source of truth. Revoke by removing/flipping entries here.
// `url` is optional: set it (and enable "Deliver target URL from server" in the
// app) to deliver the target URL dynamically instead of packaging it.
// When URL encryption is enabled, encrypt `url` with the shared AES key before
// returning it (see the `encryptUrl` helper below).
const CODES = {
"ABC123": { expiresAt: 1735862400000, remainingUses: 5, url: "https://example.com/app" },
};
// ---- AES-256-GCM encryption (only needed when "Encrypt target URL" is on) ----
const AES_KEY_BASE64 = process.env.AES_KEY_BASE64 || ""; // same as in the app config
const AES_KEY = AES_KEY_BASE64 ? Buffer.from(AES_KEY_BASE64, "base64") : null;
function encryptUrl(plaintext) {
if (!AES_KEY || AES_KEY.length !== 32) return plaintext; // passthrough if not configured
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", AES_KEY, iv);
let encrypted = cipher.update(plaintext, "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, encrypted, tag]).toString("base64");
}
// -------------------------------------------------------------------------
function signedPayload({ ok, expiresAt, remainingUses, nonce, url }) {
// Must match the app's canonical order exactly.
const payload = {
ok,
expiresAt: expiresAt ?? 0,
remainingUses: remainingUses ?? -1,
nonce,
};
// Include url only when delivering it; it becomes the fifth signed key.
// When encryption is on, `url` is already the ciphertext blob here.
if (url !== undefined) {
payload.url = url ?? "";
}
return JSON.stringify(payload);
}
function sign(payloadString) {
return crypto
.sign("sha256", Buffer.from(payloadString, "utf8"), privateKey)
.toString("base64");
}
http
.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400);
return res.end();
}
const code = String(parsed.code || "").trim().toUpperCase();
const nonce = String(parsed.nonce || "");
const entry = CODES[code];
const result = entry
? { ok: true, expiresAt: entry.expiresAt, remainingUses: entry.remainingUses, message: "" }
: { ok: false, expiresAt: 0, remainingUses: -1, message: "Code not recognised" };
// Encrypt the URL if AES key is configured; the ciphertext goes into
// both the signed payload and the response.
const urlPlain = entry && entry.url ? entry.url : undefined;
const urlEnc = urlPlain ? encryptUrl(urlPlain) : undefined;
const sig = sign(signedPayload({ ...result, nonce, url: urlEnc }));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ...result, nonce, sig, url: urlEnc }));
});
})
.listen(8443);
```
Serve it over HTTPS (behind a reverse proxy with a TLS cert, or with
`https.createServer`). The app refuses non-HTTPS endpoints.
## Offline behaviour
After a successful online check, the app caches the result (bound to the code
and its `expiresAt`). With `ALLOW_CACHED`, a later offline launch is allowed
until that expiry, then it prompts again. With `DENY`, any offline launch is
blocked. With `ALLOW`, offline launches always pass — only use this if losing
the gate offline is acceptable.
## Privacy
When online verification is on, the app sends the activation code and a device
identifier to the endpoint you configure. Disclose this to your users.

View File

@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Audit Compose UI files for design-system migration debt.
This script is intentionally non-failing by default. It reports legacy patterns
so migrations can reduce the count over time without blocking unrelated work.
Use --enforce-baseline with .github/scripts/ui_design_allowlist.txt to block new debt
while allowing documented historical debt to remain during migration.
"""
from __future__ import annotations
import argparse
import re
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_TARGETS = (
REPO_ROOT / "app/src/main/java/br/com/drukstech/codeapp/ui/screens",
REPO_ROOT / "app/src/main/java/br/com/drukstech/codeapp/ui/components",
)
DEFAULT_ALLOWLIST = REPO_ROOT / ".github/scripts/ui_design_allowlist.txt"
@dataclass(frozen=True)
class Rule:
key: str
label: str
pattern: re.Pattern[str]
RULES = (
Rule(
key="hardcoded_color",
label="Direct Color(0x...)",
pattern=re.compile(r"\bColor\s*\(\s*0x[0-9A-Fa-f_]+"),
),
Rule(
key="raw_corner_shape",
label="Raw RoundedCornerShape(dp)",
pattern=re.compile(r"\bRoundedCornerShape\s*\(\s*\d+(?:\.\d+)?\.dp"),
),
Rule(
key="raw_card",
label="Raw Card/ElevatedCard/OutlinedCard",
pattern=re.compile(r"(?<!Enhanced)(?<!Dkc)\b(?:Card|ElevatedCard|OutlinedCard)\s*\("),
),
Rule(
key="raw_surface",
label="Raw Surface",
pattern=re.compile(r"(?<!Dkc)\bSurface\s*\("),
),
Rule(
key="raw_button",
label="Raw Button",
pattern=re.compile(r"(?<!Icon)(?<!Text)(?<!Outlined)(?<!Tonal)(?<!Premium)(?<!Dkc)\bButton\s*\("),
),
)
def iter_kotlin_files(target: Path) -> list[Path]:
if target.is_file():
return [target]
return sorted(target.rglob("*.kt"))
def resolve_target(target_text: str) -> Path:
target = Path(target_text)
if not target.is_absolute():
target = REPO_ROOT / target
return target
def display_targets(targets: list[Path]) -> str:
labels = []
for target in targets:
labels.append(
str(target.relative_to(REPO_ROOT))
if target.is_relative_to(REPO_ROOT)
else str(target)
)
return ", ".join(labels)
def strip_line_comment(line: str) -> str:
return line.split("//", 1)[0]
def audit_file(path: Path) -> dict[str, list[int]]:
findings: dict[str, list[int]] = defaultdict(list)
try:
lines = path.read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
lines = path.read_text(errors="ignore").splitlines()
for line_no, line in enumerate(lines, start=1):
candidate = strip_line_comment(line)
for rule in RULES:
if rule.pattern.search(candidate):
findings[rule.key].append(line_no)
return findings
def relative_repo_path(path: Path) -> str:
if path.is_relative_to(REPO_ROOT):
return path.relative_to(REPO_ROOT).as_posix()
return path.as_posix()
def load_allowlist(path: Path) -> dict[str, Counter[str]]:
allowlist: dict[str, Counter[str]] = defaultdict(Counter)
if not path.exists():
return allowlist
for line_no, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|")
if len(parts) != 3:
raise ValueError(f"Invalid allowlist line {line_no}: {raw_line}")
rel_path, rule_key, count_text = parts
if rule_key not in {rule.key for rule in RULES}:
raise ValueError(f"Unknown rule '{rule_key}' on allowlist line {line_no}")
try:
count = int(count_text)
except ValueError as exc:
raise ValueError(f"Invalid count '{count_text}' on allowlist line {line_no}") from exc
if count < 0:
raise ValueError(f"Negative count on allowlist line {line_no}")
allowlist[rel_path][rule_key] = count
return allowlist
def write_allowlist(path: Path, current_counts: dict[str, Counter[str]]) -> None:
lines = [
"# DruksCode UI design-system historical debt baseline.",
"# Format: relative/path.kt|rule_key|allowed_count",
"# Regenerate only after intentionally accepting a new migration checkpoint.",
]
for rel_path in sorted(current_counts):
counts = current_counts[rel_path]
for rule in RULES:
count = counts[rule.key]
if count > 0:
lines.append(f"{rel_path}|{rule.key}|{count}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def find_baseline_violations(
current_counts: dict[str, Counter[str]],
allowlist: dict[str, Counter[str]],
) -> list[tuple[str, str, int, int]]:
violations: list[tuple[str, str, int, int]] = []
for rel_path in sorted(current_counts):
counts = current_counts[rel_path]
allowed = allowlist.get(rel_path, Counter())
for rule in RULES:
current = counts[rule.key]
baseline = allowed[rule.key]
if current > baseline:
violations.append((rel_path, rule.key, current, baseline))
return violations
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"targets",
nargs="*",
help="Files or directories to scan. Defaults to app ui/screens and ui/components.",
)
parser.add_argument(
"--fail-on-findings",
action="store_true",
help="Exit with status 1 if any finding is detected.",
)
parser.add_argument(
"--enforce-baseline",
action="store_true",
help="Exit with status 1 only when current findings exceed the allowlist baseline.",
)
parser.add_argument(
"--allowlist",
default=str(DEFAULT_ALLOWLIST),
help="Baseline allowlist file for --enforce-baseline.",
)
parser.add_argument(
"--write-allowlist",
help="Write the current finding counts as an allowlist baseline.",
)
parser.add_argument(
"--top",
type=int,
default=20,
help="Number of highest-debt files to print.",
)
args = parser.parse_args()
targets = [resolve_target(target) for target in args.targets] if args.targets else list(DEFAULT_TARGETS)
files = sorted(
{file_path.resolve() for target in targets for file_path in iter_kotlin_files(target)}
)
totals: Counter[str] = Counter()
per_file: list[tuple[Path, Counter[str], dict[str, list[int]]]] = []
current_counts: dict[str, Counter[str]] = {}
for file_path in files:
findings = audit_file(file_path)
counts = Counter({key: len(lines) for key, lines in findings.items()})
if counts:
totals.update(counts)
per_file.append((file_path, counts, findings))
current_counts[relative_repo_path(file_path)] = counts
print("DruksCode UI design-system audit")
print(f"Targets: {display_targets(targets)}")
print(f"Files scanned: {len(files)}")
print()
if args.write_allowlist:
write_path = Path(args.write_allowlist)
if not write_path.is_absolute():
write_path = REPO_ROOT / write_path
write_allowlist(write_path, current_counts)
print(f"Allowlist written: {relative_repo_path(write_path)}")
print()
if not totals:
print("No findings.")
return 0
print("Totals:")
for rule in RULES:
print(f" {rule.label}: {totals[rule.key]}")
print()
print(f"Top {args.top} files:")
per_file.sort(key=lambda item: sum(item[1].values()), reverse=True)
for file_path, counts, findings in per_file[: args.top]:
rel = file_path.relative_to(REPO_ROOT)
total = sum(counts.values())
detail = ", ".join(
f"{rule.key}={counts[rule.key]}"
for rule in RULES
if counts[rule.key]
)
first_lines = []
for rule in RULES:
lines = findings.get(rule.key)
if lines:
first_lines.append(f"{rule.key}@{','.join(map(str, lines[:5]))}")
print(f" {rel}: {total} ({detail})")
print(f" first: {'; '.join(first_lines)}")
if args.enforce_baseline:
allowlist_path = Path(args.allowlist)
if not allowlist_path.is_absolute():
allowlist_path = REPO_ROOT / allowlist_path
allowlist = load_allowlist(allowlist_path)
violations = find_baseline_violations(current_counts, allowlist)
print()
if violations:
print("Baseline enforcement failed:")
for rel_path, rule_key, current, baseline in violations[:50]:
print(f" {rel_path}|{rule_key}: current={current}, allowed={baseline}")
return 1
print("Baseline enforcement passed.")
return 1 if args.fail_on_findings else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""
Generate `modules/submissions.json` for the DruksCode Module Market.
Why: the in-app market only renders modules that have a corresponding
submission entry in this file. The repository contract is that an entry
is published only when the module has actually landed on `main`, either
by a merged PR (the normal path) or by a maintainer direct push (rare,
used for seeding the initial example modules).
This script is what enforces the "merged PRs only" promise. It walks
every module folder under `modules/`, asks `git log` for the commit that
introduced or last touched it, then queries the GitHub REST API for the
PR associated with that commit. If the PR is found and merged, we record
PR number, merge timestamp, and the merger's GitHub identity. If no PR
is found, the commit is taken as a direct push and we record the
commit's author timestamp and resolved GitHub login — any human author
is recorded (modules only reach `main` through review or a maintainer
push; hiding non-maintainer authors made real contributor modules
disappear from the in-app market). Only bot identities
(github-actions[bot], web-flow, dependabot[bot]) are excluded, with the
repo owner as the fallback when a commit email maps to no GitHub
account. An orphan folder added without a commit ends up unrecorded,
and the in-app market hides it.
Run locally (no network access — uses cache only):
python3 .github/scripts/ci/generate_submissions.py --offline
Run in CI:
python3 .github/scripts/ci/generate_submissions.py \\
--owner shiaho777 --repo drukscode \\
--maintainers shiaho777 \\
--token "$GITHUB_TOKEN"
Exit codes:
0 = file was generated successfully (may have committed nothing if
nothing changed)
1 = a fatal error occurred
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[3]
MODULES_DIR = REPO_ROOT / "modules"
SUBMISSIONS_PATH = MODULES_DIR / "submissions.json"
REGISTRY_PATH = MODULES_DIR / "registry.json"
# ───────────────────────── git helpers ─────────────────────────────────
def _git(*args: str) -> str:
"""Run a git command relative to the repo root and return stdout."""
result = subprocess.run(
["git", *args],
cwd=str(REPO_ROOT),
check=True,
capture_output=True,
text=True,
)
return result.stdout
def _last_commit_for(path: Path) -> str | None:
"""Return the SHA of the most recent commit that touched `path`."""
try:
out = _git("log", "-n", "1", "--format=%H", "--", str(path.relative_to(REPO_ROOT)))
sha = out.strip()
return sha or None
except subprocess.CalledProcessError:
return None
def _commit_metadata(sha: str) -> dict[str, str]:
"""
Fetch author name, email, ISO timestamp, and subject for a commit.
Used as a fallback when the GitHub API can't tell us about a PR.
"""
out = _git("show", "-s", "--format=%an%n%ae%n%aI%n%s", sha)
parts = out.splitlines()
return {
"author_name": parts[0] if len(parts) > 0 else "",
"author_email": parts[1] if len(parts) > 1 else "",
"author_date": parts[2] if len(parts) > 2 else "",
"subject": parts[3] if len(parts) > 3 else "",
}
_PR_REF_RE = re.compile(r"(?:\(|\s)#(\d+)(?:\)|\b)")
def _pr_numbers_from_text(*texts: str) -> list[int]:
"""Extract GitHub PR numbers mentioned in commit subjects / bodies."""
found: list[int] = []
seen: set[int] = set()
for text in texts:
if not text:
continue
for match in _PR_REF_RE.finditer(text):
num = int(match.group(1))
if num not in seen:
seen.add(num)
found.append(num)
return found
def _all_commits_for(path: Path) -> list[str]:
"""Return every commit SHA that touched `path`, newest first."""
try:
out = _git("log", "--format=%H", "--", str(path.relative_to(REPO_ROOT)))
return [line.strip() for line in out.splitlines() if line.strip()]
except subprocess.CalledProcessError:
return []
# ───────────────────────── GitHub API helpers ──────────────────────────
@dataclass
class GitHubClient:
"""Tiny GitHub REST client that uses urllib so we stay stdlib-only."""
owner: str
repo: str
token: str | None = None
timeout: int = 15
def _request(self, url: str) -> Any:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "webtoapp-submissions-generator",
"X-GitHub-Api-Version": "2022-11-28",
}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def pulls_for_commit(self, sha: str) -> list[dict[str, Any]]:
"""Return PRs associated with the given commit SHA."""
url = (
f"https://api.github.com/repos/{self.owner}/{self.repo}"
f"/commits/{sha}/pulls"
)
try:
return self._request(url) or []
except urllib.error.HTTPError as e:
# 404 just means no PR was associated with this commit, which
# is the normal case for a maintainer direct-push. Anything
# else is worth a note in the log.
if e.code == 404:
return []
print(f"⚠️ GitHub API error for commit {sha[:7]}: {e}", file=sys.stderr)
return []
except urllib.error.URLError as e:
print(f"⚠️ GitHub API URLError for commit {sha[:7]}: {e}", file=sys.stderr)
return []
def user(self, login: str) -> dict[str, Any] | None:
"""Resolve a GitHub login to its display name / avatar URL."""
url = f"https://api.github.com/users/{urllib.parse.quote(login)}"
try:
return self._request(url)
except urllib.error.HTTPError:
return None
except urllib.error.URLError:
return None
def commit_author_login(self, sha: str) -> str:
"""Resolve the GitHub login of a commit's author via the commit API."""
url = f"https://api.github.com/repos/{self.owner}/{self.repo}/commits/{sha}"
try:
commit_info = self._request(url)
author = commit_info.get("author") or {}
return author.get("login") or ""
except (urllib.error.HTTPError, urllib.error.URLError):
return ""
def get_pull(self, number: int) -> dict[str, Any] | None:
"""Fetch a single pull request by number."""
url = (
f"https://api.github.com/repos/{self.owner}/{self.repo}"
f"/pulls/{int(number)}"
)
try:
return self._request(url)
except urllib.error.HTTPError as e:
if e.code != 404:
print(f"⚠️ GitHub API error for PR #{number}: {e}", file=sys.stderr)
return None
except urllib.error.URLError as e:
print(f"⚠️ GitHub API URLError for PR #{number}: {e}", file=sys.stderr)
return None
# ───────────────────────── core logic ──────────────────────────────────
@dataclass
class Contributor:
"""A secondary contributor to a module (everyone except the original submitter)."""
login: str = ""
name: str = ""
avatar: str = ""
profile: str = ""
def to_json(self) -> dict[str, str]:
out: dict[str, str] = {}
if self.login:
out["login"] = self.login
if self.name:
out["name"] = self.name
if self.avatar:
out["avatarUrl"] = self.avatar
if self.profile:
out["profileUrl"] = self.profile
return out
@dataclass
class Submission:
"""In-memory representation of a single `submissions.json` entry."""
pr_number: int | None = None
pr_url: str | None = None
submitted_at: str | None = None
direct: bool = False
submitter_login: str = ""
submitter_name: str = ""
submitter_avatar: str = ""
submitter_profile: str = ""
contributors: list[Contributor] = field(default_factory=list)
def to_json(self) -> dict[str, Any]:
out: dict[str, Any] = {}
if self.pr_number is not None:
out["prNumber"] = self.pr_number
if self.pr_url:
out["prUrl"] = self.pr_url
if self.submitted_at:
out["submittedAt"] = self.submitted_at
if self.direct:
out["direct"] = True
submitter: dict[str, str] = {}
if self.submitter_login:
submitter["login"] = self.submitter_login
if self.submitter_name:
submitter["name"] = self.submitter_name
if self.submitter_avatar:
submitter["avatarUrl"] = self.submitter_avatar
if self.submitter_profile:
submitter["profileUrl"] = self.submitter_profile
if submitter:
out["submitter"] = submitter
if self.contributors:
out["contributors"] = [c.to_json() for c in self.contributors if c.login]
return out
def _read_existing_submissions() -> dict[str, Any]:
"""Read the previous run's output so unchanged modules don't re-fetch."""
if not SUBMISSIONS_PATH.is_file():
return {}
try:
data = json.loads(SUBMISSIONS_PATH.read_text(encoding="utf-8"))
return data.get("submissions") or {}
except (json.JSONDecodeError, OSError):
return {}
def _read_registry() -> list[dict[str, Any]]:
"""Read the registry to know which `id` ↔ `path` pairs to attribute."""
if not REGISTRY_PATH.is_file():
return []
try:
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
modules = data.get("modules")
if isinstance(modules, list):
return [m for m in modules if isinstance(m, dict)]
except (json.JSONDecodeError, OSError):
pass
return []
def _resolve_for_path(
module_path: Path,
client: GitHubClient | None,
maintainers: set[str],
cache: dict[str, Any],
) -> Submission | None:
"""Build a Submission for the module folder at `module_path`."""
sha = _last_commit_for(module_path)
if sha is None:
return None
# We attribute to the *introducing* commit when possible — that's the
# one that landed the module on main. The simplest proxy in `git log`
# is the oldest commit that touched the folder.
introducing_sha: str | None = None
try:
out = _git(
"log",
"--diff-filter=A",
"--format=%H",
"--",
str(module_path.relative_to(REPO_ROOT) / "module.json"),
)
intro_lines = [line.strip() for line in out.splitlines() if line.strip()]
if intro_lines:
introducing_sha = intro_lines[-1]
except subprocess.CalledProcessError:
pass
target_sha = introducing_sha or sha
# In offline mode, fall back to git metadata only.
if client is None:
meta = _commit_metadata(target_sha)
# We still gate maintainer direct-pushes by the configured allowlist,
# so a stranger committing directly without a PR isn't silently
# promoted into the catalog.
login = "" # unknown without GitHub API
if not login and not maintainers:
return None
return Submission(
direct=True,
submitted_at=meta["author_date"],
submitter_login=meta["author_name"],
submitter_name=meta["author_name"],
)
meta = _commit_metadata(target_sha)
pulls = client.pulls_for_commit(target_sha)
# Squash / rebase merges often leave commits/{sha}/pulls empty even though
# the subject carries "(#123)". Fall back to parsing PR refs from git
# metadata and fetching those PRs directly.
if not pulls:
for pr_number in _pr_numbers_from_text(meta.get("subject", "")):
pr_payload = client.get_pull(pr_number)
if pr_payload:
pulls.append(pr_payload)
merged_pulls = [p for p in pulls if p.get("merged_at")]
merged_on_main = [
p for p in merged_pulls
if (p.get("base") or {}).get("ref", "main") == "main"
]
if merged_on_main:
merged_pulls = merged_on_main
if merged_pulls:
# Prefer the earliest-merged PR — that is the one that introduced
# the module to main. Subsequent updates also produce PRs but they
# are version bumps, not the original submission.
pr = sorted(merged_pulls, key=lambda p: p.get("merged_at", ""))[0]
author = pr.get("user") or {}
login = author.get("login") or ""
avatar = author.get("avatar_url") or ""
profile = author.get("html_url") or (
f"https://github.com/{login}" if login else ""
)
# The PR payload doesn't always carry the user's display name; fetch
# it lazily and cache so we don't repeat the call for the same login.
display_name = ""
if login:
cached = cache.setdefault("users", {}).get(login)
if cached is None:
resolved = client.user(login)
cached = {
"name": (resolved or {}).get("name") or login,
"avatar": (resolved or {}).get("avatar_url") or avatar,
}
cache["users"][login] = cached
display_name = cached.get("name") or login
avatar = cached.get("avatar") or avatar
return Submission(
pr_number=pr.get("number"),
pr_url=pr.get("html_url"),
submitted_at=pr.get("merged_at"),
direct=False,
submitter_login=login,
submitter_name=display_name,
submitter_avatar=avatar,
submitter_profile=profile,
)
# No merged PR was found. This is a direct push to main. The main branch
# is branch-protected, so a direct push has already been authorised by a
# maintainer (or admin) — we trust that gate rather than re-checking the
# GitHub login, which is unreliable for commits whose author email isn't
# linked to a GitHub account (the API returns `author: null` and we'd
# silently hide legitimate maintainer pushes).
meta = _commit_metadata(target_sha)
# Try to resolve the GitHub login from the commit's email when possible.
# `git log` doesn't carry the GitHub login, so we look it up by querying
# the commit endpoint, which embeds the GH author when GitHub knows them.
login = ""
try:
url = f"https://api.github.com/repos/{client.owner}/{client.repo}/commits/{target_sha}"
commit_info = client._request(url)
author = commit_info.get("author") or {}
login = author.get("login") or ""
except (urllib.error.HTTPError, urllib.error.URLError):
pass
if not login:
# Commit email isn't linked to a GitHub account, so the API can't
# tell us who authored it. The push already cleared branch
# protection, so attribute it to the repo owner as a fallback.
login = client.owner
# Prefer PR attribution above. For true direct pushes we still record
# any resolved GitHub author — modules only land on main after review
# or maintainer push, so hiding non-maintainer authors made real
# contributor modules disappear from the in-app market.
if (
maintainers
and login
and login.lower() not in {m.lower() for m in maintainers}
and login.lower() in {"github-actions[bot]", "web-flow", "dependabot[bot]"}
):
return None
avatar = ""
profile = ""
display_name = login or meta["author_name"]
if login:
cached = cache.setdefault("users", {}).get(login)
if cached is None:
resolved = client.user(login)
cached = {
"name": (resolved or {}).get("name") or login,
"avatar": (resolved or {}).get("avatar_url") or "",
"profile": (resolved or {}).get("html_url") or f"https://github.com/{login}",
}
cache["users"][login] = cached
display_name = cached.get("name") or login
avatar = cached.get("avatar") or ""
profile = cached.get("profile") or f"https://github.com/{login}"
return Submission(
direct=True,
submitted_at=meta["author_date"],
submitter_login=login,
submitter_name=display_name,
submitter_avatar=avatar,
submitter_profile=profile,
)
def _resolve_contributors(
module_path: Path,
submitter_login: str,
client: GitHubClient | None,
cache: dict[str, Any],
) -> list[Contributor]:
"""
Collect secondary contributors for a module folder.
Walks every commit that touched the folder, resolves each distinct
author's GitHub login via the commit API, and returns everyone except
the original submitter (and bots/github-actions). Offline mode and
unresolvable logins yield an empty list — contributors are a
best-effort enrichment, never a hard requirement.
"""
if client is None:
return []
shas = _all_commits_for(module_path)
seen_logins: set[str] = set()
if submitter_login:
seen_logins.add(submitter_login.lower())
seen_logins.add("github-actions[bot]")
seen_logins.add("web-flow")
contributors: list[Contributor] = []
users = cache.setdefault("users", {})
for sha in shas:
login = client.commit_author_login(sha)
if not login or login.lower() in seen_logins:
continue
seen_logins.add(login.lower())
cached = users.get(login)
if cached is None:
resolved = client.user(login)
cached = {
"name": (resolved or {}).get("name") or login,
"avatar": (resolved or {}).get("avatar_url") or "",
"profile": (resolved or {}).get("html_url") or f"https://github.com/{login}",
}
users[login] = cached
contributors.append(Contributor(
login=login,
name=cached.get("name") or login,
avatar=cached.get("avatar") or "",
profile=cached.get("profile") or f"https://github.com/{login}",
))
return contributors
# ───────────────────────── CLI entry ───────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
parser.add_argument("--owner", default=os.environ.get("REPO_OWNER", "shiaho777"))
parser.add_argument("--repo", default=os.environ.get("REPO_NAME", "drukscode"))
parser.add_argument("--token", default=os.environ.get("GITHUB_TOKEN"))
parser.add_argument(
"--maintainers",
default=os.environ.get("MAINTAINERS", ""),
help=(
"Comma-separated GitHub logins allowed to seed modules via "
"direct push (no PR). Anything else without a merged PR is "
"deliberately hidden from the catalog."
),
)
parser.add_argument(
"--offline",
action="store_true",
help=(
"Skip the GitHub API entirely; useful for smoke-testing the "
"script locally. Falls back to git metadata only."
),
)
parser.add_argument(
"--check",
action="store_true",
help=(
"Generate to a temp file and exit with code 1 if the result "
"differs from the committed file. Used as a guard rail in CI "
"for PRs that touch modules/."
),
)
args = parser.parse_args()
if not MODULES_DIR.is_dir():
print(f"❌ {MODULES_DIR} does not exist", file=sys.stderr)
return 1
maintainers: set[str] = {
m.strip() for m in args.maintainers.split(",") if m.strip()
}
client: GitHubClient | None
if args.offline:
client = None
else:
client = GitHubClient(owner=args.owner, repo=args.repo, token=args.token)
registry = _read_registry()
# Map registry path → id so we attribute each folder to its catalog id.
path_to_id: dict[str, str] = {}
for entry in registry:
p = entry.get("path")
i = entry.get("id")
if isinstance(p, str) and isinstance(i, str):
path_to_id[p] = i
# Walk every kebab-case folder; the validator already ensures the layout.
folders = sorted(
p for p in MODULES_DIR.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
cache: dict[str, Any] = {}
submissions: dict[str, Any] = {}
skipped: list[str] = []
for folder in folders:
module_id = path_to_id.get(folder.name)
if not module_id:
# Folder without a registry entry — the validator will already
# be screaming about this; we just skip it.
skipped.append(folder.name)
continue
submission = _resolve_for_path(folder, client, maintainers, cache)
if submission is None:
# Hidden by design: no merged PR and not a maintainer direct
# push. Surface this in the log so reviewers can tell what's
# going on.
skipped.append(folder.name)
continue
submission.contributors = _resolve_contributors(
folder, submission.submitter_login, client, cache
)
submissions[module_id] = submission.to_json()
output = {
"schema": 1,
"submissions": submissions,
}
if not args.check:
existing_text = SUBMISSIONS_PATH.read_text(encoding="utf-8") if SUBMISSIONS_PATH.is_file() else ""
try:
existing_obj = json.loads(existing_text) if existing_text else {}
except json.JSONDecodeError:
existing_obj = {}
existing_subs = existing_obj.get("submissions") or {}
if existing_subs == submissions and "generatedAt" in existing_obj:
output["generatedAt"] = existing_obj["generatedAt"]
else:
output["generatedAt"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
else:
output["generatedAt"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
ordered = {
"schema": output["schema"],
"generatedAt": output["generatedAt"],
"submissions": output["submissions"],
}
serialised = json.dumps(ordered, indent=2, ensure_ascii=False) + "\n"
if args.check:
# Diff against the committed file. We tolerate a missing file by
# treating it as empty. `generatedAt` is excluded from the diff
# so check mode is deterministic.
existing_text = SUBMISSIONS_PATH.read_text(encoding="utf-8") if SUBMISSIONS_PATH.is_file() else ""
try:
existing_obj = json.loads(existing_text) if existing_text else {}
except json.JSONDecodeError:
existing_obj = {}
existing_subs = existing_obj.get("submissions") or {}
if existing_subs == submissions:
print(f"✅ submissions.json is up to date ({len(submissions)} entries)")
if skipped:
print(f" ({len(skipped)} folder(s) hidden from catalog: {', '.join(skipped)})")
return 0
print("❌ submissions.json is stale — re-run generate_submissions.py and commit the result.", file=sys.stderr)
return 1
SUBMISSIONS_PATH.write_text(serialised, encoding="utf-8")
print(f"✅ Wrote {SUBMISSIONS_PATH.relative_to(REPO_ROOT)} with {len(submissions)} entries")
if skipped:
print(f" ({len(skipped)} folder(s) hidden from catalog: {', '.join(skipped)})")
return 0
if __name__ == "__main__":
sys.exit(main())

671
.github/scripts/ci/validate_modules.py vendored Executable file
View File

@ -0,0 +1,671 @@
#!/usr/bin/env python3
"""
Validate the DruksCode Module Market catalog at `modules/`.
This is the source of truth for what `modules/README.md` claims gets
checked at CI time. Every rule here corresponds to a real failure mode
that would either reject a module at install time or quietly waste space
on disk.
The validator is intentionally written with only Python's standard
library — no pip install step, no extra container — so the CI job stays
fast and deterministic.
Run locally:
python3 .github/scripts/ci/validate_modules.py
Exit codes:
0 = catalog is valid
1 = at least one error was found (CI fails)
"""
from __future__ import annotations
import json
import os
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
# ───────────────────────── enum values (must mirror the Kotlin source) ─
# `ModuleCategory` enum in `core/extension/ExtensionModule.kt`.
ALLOWED_CATEGORIES: set[str] = {
"CONTENT_FILTER", "CONTENT_ENHANCE", "STYLE_MODIFIER", "THEME",
"FUNCTION_ENHANCE", "AUTOMATION", "NAVIGATION", "DATA_EXTRACT",
"DATA_SAVE", "INTERACTION", "ACCESSIBILITY", "MEDIA", "VIDEO",
"IMAGE", "AUDIO", "SECURITY", "ANTI_TRACKING", "SOCIAL", "SHOPPING",
"READING", "TRANSLATE", "DEVELOPER", "OTHER",
}
# `ModuleRunTime` enum.
ALLOWED_RUN_AT: set[str] = {
"DOCUMENT_START", "DOCUMENT_END", "DOCUMENT_IDLE",
"CONTEXT_MENU", "BEFORE_UNLOAD",
}
# `ModulePermission` enum.
ALLOWED_PERMISSIONS: set[str] = {
"DOM_ACCESS", "DOM_OBSERVE", "CSS_INJECT", "STORAGE", "COOKIE",
"INDEXED_DB", "CACHE", "NETWORK", "WEBSOCKET", "FETCH_INTERCEPT",
"CLIPBOARD", "NOTIFICATION", "ALERT", "KEYBOARD", "MOUSE", "TOUCH",
"LOCATION", "CAMERA", "MICROPHONE", "DEVICE_INFO", "MEDIA",
"FULLSCREEN", "PICTURE_IN_PICTURE", "SCREEN_CAPTURE", "DOWNLOAD",
"FILE_ACCESS", "EVAL", "IFRAME", "WINDOW_OPEN", "HISTORY",
"NAVIGATION",
}
# `ConfigItemType` enum.
ALLOWED_CONFIG_TYPES: set[str] = {
"TEXT", "TEXTAREA", "NUMBER", "BOOLEAN", "SELECT", "MULTI_SELECT",
"RADIO", "CHECKBOX", "COLOR", "URL", "EMAIL", "PASSWORD", "REGEX",
"CSS_SELECTOR", "JAVASCRIPT", "JSON", "RANGE", "DATE", "TIME",
"DATETIME", "FILE", "IMAGE",
}
ALLOWED_SOURCE_TYPES: set[str] = {"CUSTOM", "CHROME_EXTENSION"}
STORE_ID_RE = re.compile(r"^[a-p]{32}$")
# ───────────────────────── regex helpers ───────────────────────────────
KEBAB_CASE_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
SEMVER_RE = re.compile(r"^\d+(?:\.\d+){0,3}(?:[-+][\w.-]+)?$")
# Files we let people drop in besides the three the runtime actually
# downloads (module.json, main.js, style.css).
ALLOWED_EXTRA_FILES: set[str] = {
"README.md", # nice for module pages on GitHub
"CHANGELOG.md",
"LICENSE",
"LICENSE.md",
".gitkeep",
}
# Image files contributors may drop next to `main.js` for use as the
# module icon. The CI generator does not download these — they are served
# via the same GitHub raw / jsDelivr fallback the runtime already uses.
ALLOWED_ICON_FILES: set[str] = {
"icon.png", "icon.svg", "icon.webp", "icon.jpg", "icon.jpeg",
}
# Maximum size for an inlined icon. Larger icons should be hosted off-repo
# and referenced via an absolute `iconUrl`. 256 KB is a forgiving limit:
# a 256x256 PNG with reasonable compression sits comfortably below it.
MAX_ICON_BYTES = 256 * 1024
@dataclass
class Report:
"""Accumulates diagnostics and pretty-prints them at the end."""
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def error(self, where: str, message: str) -> None:
self.errors.append(f" ❌ {where}: {message}")
def warning(self, where: str, message: str) -> None:
self.warnings.append(f" ⚠️ {where}: {message}")
def ok(self) -> bool:
return not self.errors
def render(self) -> str:
lines: list[str] = []
if self.errors:
lines.append(f"\n{len(self.errors)} error(s):")
lines.extend(self.errors)
if self.warnings:
lines.append(f"\n{len(self.warnings)} warning(s):")
lines.extend(self.warnings)
if not lines:
lines.append("\nAll module checks passed.")
return "\n".join(lines)
# ───────────────────────── primitive checks ────────────────────────────
def _is_str(value: Any) -> bool:
return isinstance(value, str)
def _expect_str(report: Report, where: str, value: Any, field: str) -> str | None:
if value is None:
return None
if not _is_str(value):
report.error(where, f"`{field}` must be a string, got {type(value).__name__}")
return None
return value
def _expect_list_of_str(report: Report, where: str, value: Any, field: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list) or any(not _is_str(v) for v in value):
report.error(where, f"`{field}` must be a list of strings")
return []
return value
def _validate_url_matches(
report: Report, where: str, url_matches: Any
) -> None:
if url_matches is None:
return
if not isinstance(url_matches, list):
report.error(where, "`urlMatches` must be a list")
return
for i, rule in enumerate(url_matches):
rule_loc = f"{where}::urlMatches[{i}]"
if not isinstance(rule, dict):
report.error(rule_loc, "must be an object with at least `pattern`")
continue
pattern = rule.get("pattern")
if not _is_str(pattern) or not pattern:
report.error(rule_loc, "`pattern` is required and must be a non-empty string")
for flag in ("isRegex", "exclude"):
if flag in rule and not isinstance(rule[flag], bool):
report.error(rule_loc, f"`{flag}` must be a boolean")
def _validate_author(report: Report, where: str, author: Any) -> None:
if author is None:
return
if not isinstance(author, dict):
report.error(where, "`author` must be an object")
return
name = author.get("name")
if not _is_str(name) or not name.strip():
report.error(where, "`author.name` is required")
for opt_key in ("email", "url", "qq"):
if opt_key in author and author[opt_key] is not None and not _is_str(author[opt_key]):
report.error(where, f"`author.{opt_key}` must be a string")
# ───────────────────────── manifest checks ─────────────────────────────
def _validate_module_json(
report: Report, folder: Path, manifest: dict[str, Any]
) -> None:
"""Validate a per-module `module.json`."""
where = f"modules/{folder.name}/module.json"
# Required fields.
if not _is_str(manifest.get("id")) or not manifest["id"].strip():
report.error(where, "`id` is required and must be a non-empty string")
if not _is_str(manifest.get("name")) or not manifest["name"].strip():
report.error(where, "`name` is required and must be a non-empty string")
# Enums (with a friendly hint when wrong).
category = manifest.get("category", "OTHER")
if not _is_str(category) or category not in ALLOWED_CATEGORIES:
report.error(
where,
f"`category` must be one of {sorted(ALLOWED_CATEGORIES)}, got {category!r}",
)
run_at = manifest.get("runAt", "DOCUMENT_END")
if not _is_str(run_at) or run_at not in ALLOWED_RUN_AT:
report.error(
where,
f"`runAt` must be one of {sorted(ALLOWED_RUN_AT)}, got {run_at!r}",
)
# Version (object form).
version = manifest.get("version")
if version is None:
report.error(where, "`version` is required (object with `code`, `name`, `changelog`)")
elif not isinstance(version, dict):
report.error(where, "`version` must be an object — see modules/README.md schema")
else:
v_code = version.get("code")
if not isinstance(v_code, int) or v_code < 1:
report.error(where, "`version.code` must be a positive integer")
v_name = version.get("name")
if not _is_str(v_name) or not SEMVER_RE.match(v_name or ""):
report.error(where, f"`version.name` must be semver-shaped, got {v_name!r}")
if "changelog" in version and version["changelog"] is not None and not _is_str(version["changelog"]):
report.error(where, "`version.changelog` must be a string")
# Permissions.
for perm in _expect_list_of_str(report, where, manifest.get("permissions"), "permissions"):
if perm not in ALLOWED_PERMISSIONS:
report.error(where, f"unknown permission {perm!r}")
# urlMatches and author.
_validate_url_matches(report, where, manifest.get("urlMatches"))
_validate_author(report, where, manifest.get("author"))
# configItems.
config_items = manifest.get("configItems", [])
if config_items is None:
config_items = []
if not isinstance(config_items, list):
report.error(where, "`configItems` must be a list")
return
seen_keys: set[str] = set()
for i, item in enumerate(config_items):
item_loc = f"{where}::configItems[{i}]"
if not isinstance(item, dict):
report.error(item_loc, "must be an object")
continue
key = item.get("key")
if not _is_str(key) or not key.strip():
report.error(item_loc, "`key` is required")
elif key in seen_keys:
report.error(item_loc, f"duplicate key {key!r}")
else:
seen_keys.add(key)
if not _is_str(item.get("name")) or not item["name"].strip():
report.error(item_loc, "`name` is required")
type_ = item.get("type", "TEXT")
if not _is_str(type_) or type_ not in ALLOWED_CONFIG_TYPES:
report.error(item_loc, f"`type` must be one of {sorted(ALLOWED_CONFIG_TYPES)}")
if "required" in item and not isinstance(item["required"], bool):
report.error(item_loc, "`required` must be a boolean")
for str_field in ("description", "defaultValue", "placeholder", "validation"):
if str_field in item and item[str_field] is not None and not _is_str(item[str_field]):
report.error(item_loc, f"`{str_field}` must be a string")
# SELECT / MULTI_SELECT / RADIO need `options`.
if type_ in {"SELECT", "MULTI_SELECT", "RADIO"}:
options = item.get("options")
if not isinstance(options, list) or not options:
report.error(item_loc, f"`options` is required for type {type_}")
elif not all(_is_str(o) for o in options):
report.error(item_loc, f"`options` for type {type_} must be a list of strings; the app parses ModuleConfigItem.options as List<String>, so object forms like [{{\"value\",\"label\"}}] fail Gson deserialization")
# ───────────────────────── registry checks ─────────────────────────────
def _validate_registry_entry(
report: Report, entry: dict[str, Any], index: int
) -> None:
where = f"registry.json::modules[{index}]"
for required in ("id", "path", "name"):
if not _is_str(entry.get(required)) or not entry[required].strip():
report.error(where, f"`{required}` is required and must be a non-empty string")
path = entry.get("path", "")
if _is_str(path) and not KEBAB_CASE_RE.match(path):
report.error(where, f"`path` must be kebab-case, got {path!r}")
version = entry.get("version")
if not _is_str(version) or not SEMVER_RE.match(version or ""):
report.error(where, f"`version` must be a semver string, got {version!r}")
category = entry.get("category", "OTHER")
if not _is_str(category) or category not in ALLOWED_CATEGORIES:
report.error(where, f"`category` must be one of the allowed values, got {category!r}")
run_at = entry.get("runAt", "DOCUMENT_END")
if not _is_str(run_at) or run_at not in ALLOWED_RUN_AT:
report.error(where, f"`runAt` must be one of the allowed values, got {run_at!r}")
for perm in _expect_list_of_str(report, where, entry.get("permissions"), "permissions"):
if perm not in ALLOWED_PERMISSIONS:
report.error(where, f"unknown permission {perm!r}")
_validate_url_matches(report, where, entry.get("urlMatches"))
_validate_author(report, where, entry.get("author"))
if "minAppVersion" in entry and not isinstance(entry["minAppVersion"], int):
report.error(where, "`minAppVersion` must be an integer")
if "hasCss" in entry and not isinstance(entry["hasCss"], bool):
report.error(where, "`hasCss` must be a boolean")
icon_url = entry.get("iconUrl")
if icon_url is not None and not _is_str(icon_url):
report.error(where, "`iconUrl` must be a string when present")
source_type = entry.get("sourceType", "CUSTOM")
if not _is_str(source_type) or source_type not in ALLOWED_SOURCE_TYPES:
report.error(where, f"`sourceType` must be one of {sorted(ALLOWED_SOURCE_TYPES)}, got {source_type!r}")
if source_type == "CHROME_EXTENSION":
store_id = entry.get("storeId")
if not _is_str(store_id) or not STORE_ID_RE.match(store_id or ""):
report.error(where, "`storeId` is required for CHROME_EXTENSION and must be a 32-char Chrome Web Store ID")
def _validate_registry(report: Report, registry: dict[str, Any]) -> list[dict[str, Any]]:
where = "registry.json"
schema = registry.get("schema")
if schema != 1:
report.error(where, f"`schema` must be 1 (got {schema!r}); future versions need a parser update")
if "updatedAt" in registry and not _is_str(registry["updatedAt"]):
report.error(where, "`updatedAt` must be a string")
modules = registry.get("modules")
if not isinstance(modules, list):
report.error(where, "`modules` must be a list")
return []
seen_ids: set[str] = set()
seen_paths: set[str] = set()
for i, entry in enumerate(modules):
if not isinstance(entry, dict):
report.error(f"{where}::modules[{i}]", "must be an object")
continue
_validate_registry_entry(report, entry, i)
eid = entry.get("id") if _is_str(entry.get("id")) else None
if eid:
if eid in seen_ids:
report.error(f"{where}::modules[{i}]", f"duplicate id {eid!r}")
if not KEBAB_CASE_RE.match(eid):
report.error(f"{where}::modules[{i}]", f"id {eid!r} must be kebab-case")
seen_ids.add(eid)
epath = entry.get("path") if _is_str(entry.get("path")) else None
if epath:
if epath in seen_paths:
report.error(f"{where}::modules[{i}]", f"duplicate path {epath!r}")
seen_paths.add(epath)
return [m for m in modules if isinstance(m, dict)]
# ───────────────────────── cross-file consistency ─────────────────────
def _validate_cross_consistency(
report: Report,
entry: dict[str, Any],
manifest: dict[str, Any],
folder: Path,
) -> None:
"""`module.json` and `registry.json` must agree on the shared fields."""
where = f"modules/{folder.name}"
# id
if entry.get("id") != manifest.get("id"):
report.error(
where,
f"`id` mismatch: registry says {entry.get('id')!r}, manifest says {manifest.get('id')!r}",
)
# name
if entry.get("name") != manifest.get("name"):
report.error(
where,
f"`name` mismatch: registry says {entry.get('name')!r}, manifest says {manifest.get('name')!r}",
)
# version: registry stores the string, manifest stores an object.
reg_version = entry.get("version")
man_version_obj = manifest.get("version") or {}
man_version = man_version_obj.get("name") if isinstance(man_version_obj, dict) else None
if reg_version != man_version:
report.error(
where,
f"`version` mismatch: registry={reg_version!r}, module.json::version.name={man_version!r}",
)
# runAt
if entry.get("runAt") and manifest.get("runAt") and entry["runAt"] != manifest["runAt"]:
report.error(
where,
f"`runAt` mismatch: registry={entry['runAt']!r}, manifest={manifest['runAt']!r}",
)
# permissions: registry should be a superset (the listing surface) of manifest.
reg_perms = set(entry.get("permissions") or [])
man_perms = set(manifest.get("permissions") or [])
missing_in_registry = man_perms - reg_perms
if missing_in_registry:
report.error(
where,
f"manifest declares permissions {sorted(missing_in_registry)} that registry.json does not list",
)
# hasCss must match style.css presence on disk.
has_css_flag = bool(entry.get("hasCss"))
style_present = (folder / "style.css").is_file()
if has_css_flag and not style_present:
report.error(
where,
"registry says `hasCss: true` but no `style.css` file is present",
)
if style_present and not has_css_flag:
report.error(
where,
"`style.css` exists but registry has `hasCss: false` — it will not be downloaded",
)
# ───────────────────────── per-folder structural checks ───────────────
def _validate_folder_layout(report: Report, folder: Path) -> None:
"""Each module folder must look like the schema says it does."""
where = f"modules/{folder.name}"
if not KEBAB_CASE_RE.match(folder.name):
report.error(where, f"folder name must be kebab-case, got {folder.name!r}")
if not (folder / "module.json").is_file():
report.error(where, "missing required `module.json`")
if not (folder / "main.js").is_file():
report.error(where, "missing required `main.js`")
# Flag stray files. The runtime ignores them, so they only bloat the repo.
expected = {"module.json", "main.js", "style.css"}
for child in folder.iterdir():
if child.name in expected or child.name in ALLOWED_EXTRA_FILES:
continue
if child.name in ALLOWED_ICON_FILES:
# Icons are size-checked separately further below.
continue
if child.is_dir():
report.warning(
where,
f"unexpected sub-directory `{child.name}/` — the runtime won't read it",
)
else:
report.warning(
where,
f"unexpected file `{child.name}` — the runtime won't download it",
)
def _validate_icon_coherence(
report: Report, folder: Path, entry: dict[str, Any] | None
) -> None:
"""
Cross-check the contributor-supplied icon against the registry.
The contract:
- If `iconUrl` is missing/empty, the app falls back to the first letter
of the module name. No icon file is needed.
- If `iconUrl` looks like a relative path, the corresponding file must
actually exist next to `main.js` and stay within the size budget.
- If `iconUrl` is absolute (starts with `http://` or `https://`), we
defer to the contributor's hosting and do not check the file system.
- The file name must come from `ALLOWED_ICON_FILES` so we don't end up
shipping random binaries through the catalog.
"""
where = f"modules/{folder.name}"
icon_url = (entry or {}).get("iconUrl")
icon_files_present = sorted(
f for f in ALLOWED_ICON_FILES if (folder / f).is_file()
)
# Warn about extra random icon files when the registry doesn't reference
# them — the runtime won't fetch a `logo.png` it has no way to know about.
if not icon_url and icon_files_present:
report.warning(
where,
f"icon file(s) {icon_files_present} present but registry has no "
"`iconUrl` — they won't be used by the runtime",
)
if not _is_str(icon_url) or not icon_url.strip():
return
icon_url = icon_url.strip()
if icon_url.startswith("http://") or icon_url.startswith("https://"):
if icon_url.startswith("http://"):
report.warning(where, "`iconUrl` uses http://; prefer https for catalog assets")
return
# Relative path case. Reject path traversal and force the file to live
# inside the module folder with a known extension.
normalised = icon_url.lstrip("./")
if normalised.startswith("/") or ".." in normalised.split("/"):
report.error(where, f"`iconUrl` must be a path inside the module folder, got {icon_url!r}")
return
if normalised not in ALLOWED_ICON_FILES:
report.error(
where,
f"`iconUrl` must reference one of {sorted(ALLOWED_ICON_FILES)}, got {icon_url!r}",
)
return
icon_path = folder / normalised
if not icon_path.is_file():
report.error(where, f"`iconUrl` points at {normalised!r} but no such file exists")
return
size = icon_path.stat().st_size
if size > MAX_ICON_BYTES:
report.error(
where,
f"`{normalised}` is {size // 1024} KB, over the {MAX_ICON_BYTES // 1024} KB cap; "
"trim the icon or host it off-repo via an absolute URL",
)
def _validate_main_js(report: Report, folder: Path) -> None:
"""Cheap heuristics on `main.js`."""
main_js = folder / "main.js"
if not main_js.is_file():
return
where = f"modules/{folder.name}/main.js"
try:
content = main_js.read_text(encoding="utf-8")
except UnicodeDecodeError:
report.error(where, "must be UTF-8")
return
if not content.strip():
report.error(where, "is empty")
return
if len(content) > 512 * 1024:
report.warning(where, f"is large ({len(content) // 1024} KB) — consider trimming")
# No top-level `return` — the IIFE wrapper turns this into a syntax error.
for lineno, line in enumerate(content.splitlines(), start=1):
stripped = line.strip()
if stripped.startswith("return ") or stripped == "return;" or stripped == "return":
# Could still be inside a function literal at line 1; we can't
# parse JS exactly, but this is a strong smell.
indent = len(line) - len(line.lstrip())
if indent == 0:
report.error(
where,
f"line {lineno}: top-level `return` — the IIFE wrapper would make this a syntax error",
)
break
# If `getConfig` is referenced but no configItems are declared in the
# manifest, the user will see "undefined" defaults silently.
manifest_path = folder / "module.json"
if manifest_path.is_file() and "getConfig(" in content:
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not manifest.get("configItems"):
report.warning(
where,
"uses `getConfig(...)` but `module.json` declares no `configItems`",
)
except (json.JSONDecodeError, OSError):
# The JSON-level checks elsewhere will report the actual cause.
pass
# ───────────────────────── entry point ─────────────────────────────────
def _load_json(report: Report, path: Path, where: str) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
report.error(where, f"file not found: {path}")
except json.JSONDecodeError as e:
report.error(where, f"invalid JSON: {e.msg} (line {e.lineno}, col {e.colno})")
except UnicodeDecodeError:
report.error(where, "file must be UTF-8")
return None
def main(repo_root: Path) -> int:
modules_dir = repo_root / "modules"
if not modules_dir.is_dir():
print(f"❌ {modules_dir} does not exist", file=sys.stderr)
return 1
report = Report()
# 1. Parse + validate the registry.
registry = _load_json(report, modules_dir / "registry.json", "registry.json")
registry_entries: list[dict[str, Any]] = []
if isinstance(registry, dict):
registry_entries = _validate_registry(report, registry)
# 2. Walk module folders.
folders = sorted(
p for p in modules_dir.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
folder_paths = {p.name for p in folders}
registry_paths = {e["path"] for e in registry_entries if _is_str(e.get("path"))}
custom_paths = {e["path"] for e in registry_entries if _is_str(e.get("path")) and e.get("sourceType", "CUSTOM") != "CHROME_EXTENSION"}
# Folders missing a registry entry.
for orphan in folder_paths - registry_paths:
report.error(f"modules/{orphan}", "module folder has no entry in registry.json")
# Registry entries pointing at non-existent folders (CHROME_EXTENSION entries don't need folders).
for ghost in custom_paths - folder_paths:
report.error("registry.json", f"entry refers to missing folder modules/{ghost}/")
# 3. Per-folder validation + cross-file consistency.
entries_by_path = {e["path"]: e for e in registry_entries if _is_str(e.get("path")) and e.get("sourceType", "CUSTOM") != "CHROME_EXTENSION"}
for folder in folders:
_validate_folder_layout(report, folder)
_validate_main_js(report, folder)
_validate_icon_coherence(report, folder, entries_by_path.get(folder.name))
manifest_path = folder / "module.json"
manifest = _load_json(report, manifest_path, f"modules/{folder.name}/module.json")
if isinstance(manifest, dict):
_validate_module_json(report, folder, manifest)
entry = entries_by_path.get(folder.name)
if entry:
_validate_cross_consistency(report, entry, manifest, folder)
print(report.render())
return 0 if report.ok() else 1
if __name__ == "__main__":
repo = Path(__file__).resolve().parents[3]
sys.exit(main(repo))

122
.github/scripts/ui_design_allowlist.txt vendored Normal file
View File

@ -0,0 +1,122 @@
# DruksCode UI design-system historical debt baseline.
# Format: relative/path.kt|rule_key|allowed_count
# Regenerate only after intentionally accepting a new migration checkpoint.
app/src/main/java/br/com/drukstech/codeapp/ui/components/ActivationCodeCard.kt|raw_corner_shape|12
app/src/main/java/br/com/drukstech/codeapp/ui/components/ActivationCodeCard.kt|raw_surface|6
app/src/main/java/br/com/drukstech/codeapp/ui/components/ActivationCodeCard.kt|raw_button|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/AutoRefreshCountdownOverlay.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/AutoRefreshCountdownOverlay.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/AutoStartCard.kt|raw_corner_shape|6
app/src/main/java/br/com/drukstech/codeapp/ui/components/AutoStartCard.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/AutoStartCard.kt|raw_surface|6
app/src/main/java/br/com/drukstech/codeapp/ui/components/BgmCard.kt|raw_surface|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/BgmSelector.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/BgmSelector.kt|raw_surface|9
app/src/main/java/br/com/drukstech/codeapp/ui/components/BrowserDisguiseConfigCard.kt|raw_corner_shape|10
app/src/main/java/br/com/drukstech/codeapp/ui/components/BrowserDisguiseConfigCard.kt|raw_surface|5
app/src/main/java/br/com/drukstech/codeapp/ui/components/CodeSnippetSelector.kt|raw_corner_shape|10
app/src/main/java/br/com/drukstech/codeapp/ui/components/CodeSnippetSelector.kt|raw_surface|8
app/src/main/java/br/com/drukstech/codeapp/ui/components/ColorPickerDialog.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/DnsConfigCard.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/DnsConfigCard.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/EncryptionConfigCard.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/EncryptionConfigCard.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/EnhancedActivationDialog.kt|raw_corner_shape|6
app/src/main/java/br/com/drukstech/codeapp/ui/components/EnhancedActivationDialog.kt|raw_surface|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/IconGeneratorDialog.kt|raw_corner_shape|7
app/src/main/java/br/com/drukstech/codeapp/ui/components/IconGeneratorDialog.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/IconLibraryDialog.kt|raw_corner_shape|4
app/src/main/java/br/com/drukstech/codeapp/ui/components/IconLibraryDialog.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/IconLibraryDialog.kt|raw_surface|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/IconPickerWithLibrary.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/IsolationConfigCard.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/IsolationConfigCard.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/LanguageSelector.kt|raw_card|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/LiquidGlassEffect.kt|hardcoded_color|4
app/src/main/java/br/com/drukstech/codeapp/ui/components/LongPressMenu.kt|hardcoded_color|6
app/src/main/java/br/com/drukstech/codeapp/ui/components/LongPressMenu.kt|raw_corner_shape|10
app/src/main/java/br/com/drukstech/codeapp/ui/components/LongPressMenu.kt|raw_surface|8
app/src/main/java/br/com/drukstech/codeapp/ui/components/LrcEditorDialog.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/LrcEditorDialog.kt|raw_surface|5
app/src/main/java/br/com/drukstech/codeapp/ui/components/ManualLrcAligner.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/ManualLrcAligner.kt|raw_card|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/ManualLrcAligner.kt|raw_surface|4
app/src/main/java/br/com/drukstech/codeapp/ui/components/NotificationConfigCard.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/NotificationConfigCard.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/OnlineMusicSearchDialog.kt|raw_corner_shape|5
app/src/main/java/br/com/drukstech/codeapp/ui/components/OnlineMusicSearchDialog.kt|raw_card|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/OnlineMusicSearchDialog.kt|raw_surface|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/PremiumComponents.kt|raw_button|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/QrCodeShareDialog.kt|raw_corner_shape|6
app/src/main/java/br/com/drukstech/codeapp/ui/components/QrCodeShareDialog.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/RuntimeScreenComponents.kt|raw_corner_shape|5
app/src/main/java/br/com/drukstech/codeapp/ui/components/RuntimeScreenComponents.kt|raw_card|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/RuntimeScreenComponents.kt|raw_surface|4
app/src/main/java/br/com/drukstech/codeapp/ui/components/SampleProjectCard.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/Shimmer.kt|hardcoded_color|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/Shimmer.kt|raw_corner_shape|7
app/src/main/java/br/com/drukstech/codeapp/ui/components/StatusBarImageCropper.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/StatusBarImageCropper.kt|raw_card|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/TypedSampleProjectCard.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/VideoTrimmer.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/VirtualNavigationBar.kt|hardcoded_color|2
app/src/main/java/br/com/drukstech/codeapp/ui/components/VirtualNavigationBar.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/DkcCodeEditorDialog.kt|raw_surface|5
app/src/main/java/br/com/drukstech/codeapp/ui/components/DkcErrorView.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/DkcErrorView.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/announcement/AnnouncementTemplates.kt|hardcoded_color|28
app/src/main/java/br/com/drukstech/codeapp/ui/components/announcement/AnnouncementTemplates.kt|raw_corner_shape|13
app/src/main/java/br/com/drukstech/codeapp/ui/components/announcement/AnnouncementTemplates.kt|raw_card|3
app/src/main/java/br/com/drukstech/codeapp/ui/components/announcement/AnnouncementTemplates.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/components/announcement/AnnouncementTemplates.kt|raw_button|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/AboutScreen.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/AiSettingsScreen.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/AiSettingsScreen.kt|raw_surface|10
app/src/main/java/br/com/drukstech/codeapp/ui/screens/BatchImportDialog.kt|raw_surface|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppScreen.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppScreen.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppScreen.kt|raw_button|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppSplashCard.kt|raw_surface|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppUserScripts.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppUserScripts.kt|raw_card|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppUserScripts.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppWebViewCards.kt|raw_corner_shape|13
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateAppWebViewCards.kt|raw_surface|8
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateFrontendAppScreen.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateFrontendAppScreen.kt|raw_surface|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateGalleryAppScreen.kt|raw_corner_shape|7
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateGalleryAppScreen.kt|raw_surface|5
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateGoAppScreen.kt|raw_corner_shape|7
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateGoAppScreen.kt|raw_surface|8
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateHtmlAppScreen.kt|raw_corner_shape|6
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateHtmlAppScreen.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateMediaAppScreen.kt|raw_corner_shape|16
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateMediaAppScreen.kt|raw_surface|7
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateMultiWebAppScreen.kt|raw_corner_shape|10
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateMultiWebAppScreen.kt|raw_card|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateMultiWebAppScreen.kt|raw_surface|3
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateMultiWebAppScreen.kt|raw_button|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateOfflinePackScreen.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreateOfflinePackScreen.kt|raw_surface|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreatePhpAppScreen.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreatePhpAppScreen.kt|raw_surface|4
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreatePythonAppScreen.kt|raw_corner_shape|8
app/src/main/java/br/com/drukstech/codeapp/ui/screens/CreatePythonAppScreen.kt|raw_surface|9
app/src/main/java/br/com/drukstech/codeapp/ui/screens/DeviceDisguiseCard.kt|raw_corner_shape|8
app/src/main/java/br/com/drukstech/codeapp/ui/screens/DeviceDisguiseCard.kt|raw_surface|6
app/src/main/java/br/com/drukstech/codeapp/ui/screens/HomeScreen.kt|raw_corner_shape|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/HomeScreen.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/HostsAdBlockScreen.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/InstallProjectDepsCard.kt|raw_corner_shape|2
app/src/main/java/br/com/drukstech/codeapp/ui/screens/LinuxEnvironmentScreen.kt|raw_corner_shape|11
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PermissionConfigScreen.kt|raw_corner_shape|5
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PermissionConfigScreen.kt|raw_surface|5
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PlayStoreScreen.kt|hardcoded_color|7
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PlayStoreScreen.kt|raw_corner_shape|3
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PlayStoreScreen.kt|raw_surface|4
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PortManagerScreen.kt|raw_corner_shape|6
app/src/main/java/br/com/drukstech/codeapp/ui/screens/PortManagerScreen.kt|raw_surface|3
app/src/main/java/br/com/drukstech/codeapp/ui/screens/RuntimeDepsScreen.kt|raw_surface|1
app/src/main/java/br/com/drukstech/codeapp/ui/screens/StatsScreen.kt|raw_corner_shape|4
app/src/main/java/br/com/drukstech/codeapp/ui/screens/StatsScreen.kt|raw_surface|4
app/src/main/java/br/com/drukstech/codeapp/ui/screens/WordPressSettingsScreen.kt|raw_corner_shape|3

127
.github/workflows/android-ci.yml vendored Normal file
View File

@ -0,0 +1,127 @@
name: Android CI Build
on:
pull_request:
workflow_dispatch:
push:
branches:
- "**"
permissions:
contents: read
concurrency:
# Group per ref AND event type: routine pushes to the same branch still cancel
# their superseded check runs, but a workflow_dispatch packaging run is never
# cancelled by a merge push landing on the same ref mid-build.
group: android-ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: ${{ github.event_name == 'push' }}
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Download code
uses: actions/checkout@v6
- name: Prepare Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Prepare Android tools
uses: android-actions/setup-android@v3
with:
packages: ""
- name: Prepare Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-provider: basic
- name: Install needed tools
run: |
sdkmanager --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"cmake;3.22.1" \
"ndk;28.2.13676358"
- name: Run checks
run: |
./gradlew --no-daemon --no-configuration-cache \
-Dkotlin.compiler.execution.strategy=in-process \
-PskipShellTemplateSync=true \
-PallowDebugSignedRelease=true \
:app:compileStandardDebugKotlin \
:shell:compileDebugKotlin \
:app:testStandardDebugUnitTest \
:app:checkConfigFieldDrift
package-apks:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- name: Download code
uses: actions/checkout@v6
- name: Prepare Java
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Prepare Android tools
uses: android-actions/setup-android@v3
with:
packages: ""
- name: Prepare Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-provider: basic
- name: Install needed tools
run: |
sdkmanager --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"cmake;3.22.1" \
"ndk;28.2.13676358"
- name: Build APKs
run: |
./gradlew --no-daemon --no-configuration-cache \
-Dkotlin.compiler.execution.strategy=in-process \
-PallowDebugSignedRelease=true \
:app:assembleStandardDebug \
:app:assembleStandardRelease \
:app:assembleGplayDebug \
:app:assembleGplayRelease \
:shell:assembleDebug \
:shell:assembleRelease
- name: Upload standard APKs
uses: actions/upload-artifact@v4
with:
name: standard-apks-DEBUG-SIGNED-do-not-distribute-${{ github.run_number }}
path: |
app/build/outputs/apk/standard/**/*.apk
shell/build/outputs/apk/**/*.apk
if-no-files-found: error
retention-days: 14
- name: Upload gplay APKs
uses: actions/upload-artifact@v4
with:
name: gplay-apks-br.com.drukstech.codeapp.play-DEBUG-SIGNED-do-not-distribute-${{ github.run_number }}
path: app/build/outputs/apk/gplay/**/*.apk
if-no-files-found: error
retention-days: 14

78
.github/workflows/docs-deploy.yml vendored Normal file
View File

@ -0,0 +1,78 @@
# Builds the VitePress documentation site under docs/ and deploys it to
# GitHub Pages. On pull requests the site is only built (to catch breakage);
# on push to main and on manual dispatch it is built and deployed.
name: Docs Deploy
on:
pull_request:
paths:
- "docs/**"
- ".github/workflows/docs-deploy.yml"
push:
branches:
- main
paths:
- "docs/**"
- ".github/workflows/docs-deploy.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Download code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Prepare Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
working-directory: docs
run: npm ci
- name: Build site
working-directory: docs
run: npm run build
- name: Upload Pages artifact
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
# Only this job touches Pages: the build job runs on pull_requests (including
# same-repo branches) and executes third-party dependency postinstall code, so it
# must not hold Pages/OIDC write capabilities.
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

49
.github/workflows/modules-check.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Module Market Check
# Runs the module catalog validator at `.github/scripts/ci/validate_modules.py`.
# Triggered for any change touching the catalog or the validator itself,
# whether on a PR or a direct push to main. Other paths bypass this job
# so contributors who don't touch `modules/` aren't blocked by it.
on:
pull_request:
paths:
- 'modules/**'
- '.github/scripts/ci/validate_modules.py'
- '.github/scripts/ci/generate_submissions.py'
- '.github/workflows/modules-check.yml'
push:
branches:
- main
paths:
- 'modules/**'
- '.github/scripts/ci/validate_modules.py'
- '.github/scripts/ci/generate_submissions.py'
- '.github/workflows/modules-check.yml'
# Manual run from the Actions tab — handy when adjusting the validator
# rules without a real catalog change to push.
workflow_dispatch:
permissions:
contents: read
concurrency:
group: modules-check-${{ github.ref }}
cancel-in-progress: true
jobs:
validate-modules:
name: Validate modules/
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Download code
uses: actions/checkout@v6
- name: Use system Python 3
# Ubuntu runners ship a recent Python 3 — no setup-python step needed.
run: python3 --version
- name: Run module market validator
run: python3 .github/scripts/ci/validate_modules.py

124
.github/workflows/modules-publish.yml vendored Normal file
View File

@ -0,0 +1,124 @@
name: Module Market Publish
# Regenerates `modules/submissions.json` after a PR is merged into main.
#
# This file is what tells the in-app market who submitted each module
# and when. Modules without a corresponding entry are deliberately
# hidden from the catalog — that's how the "only show merged PRs"
# guarantee is enforced. We regenerate from scratch on every relevant
# push so a missing entry can never silently outlive a revert.
#
# main is branch-protected (requires a PR + the "check" status), and the
# default GITHUB_TOKEN can't push to it directly. So instead of pushing,
# this workflow commits the regenerated file to a fixed working branch
# and opens (or updates) a PR, then queues an auto-merge so the catalog
# stays in sync without any manual step.
on:
push:
branches:
- main
paths:
- 'modules/**'
- '.github/scripts/ci/generate_submissions.py'
- '.github/workflows/modules-publish.yml'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: modules-publish
cancel-in-progress: false
jobs:
publish:
name: Generate submissions.json
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
# We need the full history so `git log --diff-filter=A` can find
# the introducing commit for each module folder.
fetch-depth: 0
persist-credentials: true
- name: Use system Python 3
run: python3 --version
- name: Generate submissions.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Legacy parameter: the generator records every human direct-push
# author (modules only reach main via review or maintainer push)
# and only excludes bot identities.
MAINTAINERS: shiaho777
run: |
python3 .github/scripts/ci/generate_submissions.py \
--owner "${GITHUB_REPOSITORY%%/*}" \
--repo "${GITHUB_REPOSITORY##*/}" \
--token "$GITHUB_TOKEN"
- name: Open PR for regenerated submissions.json
env:
# Prefer a PAT (secret PAT_TOKEN) when available: a GITHUB_TOKEN-
# pushed branch can't trigger other workflows (GitHub anti-loop), so
# the required "check" never reports on the resulting PR and it can't
# merge. A PAT-pushed branch triggers android-ci normally. Fall back to
# GITHUB_TOKEN so the workflow still runs without a PAT configured.
GH_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
GIT_AUTH_USER: x-access-token
GIT_AUTH_PWD: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
HAS_PAT: ${{ secrets.PAT_TOKEN != '' }}
BRANCH: ci/regenerate-submissions
run: |
if git diff --quiet -- modules/submissions.json; then
echo "No changes to submissions.json"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# The main branch is protected (requires a PR + the "check" status),
# and the default GITHUB_TOKEN can't push there directly. Push the
# regenerated file to a long-lived working branch and let a PR land it.
git checkout -B "$BRANCH"
git add modules/submissions.json
git commit -m "chore(modules): regenerate submissions.json"
REPO="github.com/${GITHUB_REPOSITORY}"
if [ "$HAS_PAT" = "true" ]; then
git push --force-with-lease "https://${GIT_AUTH_USER}:${GIT_AUTH_PWD}@${REPO}" "$BRANCH"
else
git push --force-with-lease origin "$BRANCH"
fi
# Reuse an existing open PR if one is already tracking this branch.
PR_NUMBER=$(gh pr list --head "$BRANCH" --base main --state open \
--json number --jq '.[0].number // empty')
TITLE="chore(modules): regenerate submissions.json"
BODY="Auto-generated by the \`Module Market Publish\` workflow on push to \`main\`. No manual changes — this only updates \`modules/submissions.json\` so the in-app market catalog stays in sync."
if [ -z "$PR_NUMBER" ]; then
PR_NUMBER=$(gh pr create --head "$BRANCH" --base main \
--title "$TITLE" --body "$BODY")
else
gh pr edit "$PR_NUMBER" --title "$TITLE" --body "$BODY"
fi
# When using a PAT, the branch push already triggered android-ci, so
# wait for its "check" to report then enable auto-merge (GitHub merges
# automatically once all required checks pass). With GITHUB_TOKEN there
# is no check to wait on, so the PR is left for a manual merge.
if [ "$HAS_PAT" = "true" ]; then
gh pr merge "$PR_NUMBER" --squash --auto --delete-branch \
|| echo "::warning::Auto-merge setup failed; PR #$PR_NUMBER left open for manual merge."
else
echo "No PAT_TOKEN configured; PR #$PR_NUMBER left open for manual merge (GITHUB_TOKEN can't satisfy the required check)."
fi

75
.gitignore vendored Normal file
View File

@ -0,0 +1,75 @@
*.apk
*.aar
*.ap_
*.aab
*.dex
*.class
*.so
*.tar.gz
bin/
gen/
out/
release/
.gradle/
build/
.vscode/
.claude/
.zcode/
.workbuddy/
.workbuddy-ai/
local.properties
gradle/config.properties
gradle/gradle-daemon-jvm.properties
rule.md
CLAUDE.md
*.iml
.idea/
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
.idea/caches
.idea/modules.xml
.idea/misc.xml
.idea/vcs.xml
.navigation/
captures/
.externalNativeBuild/
.cxx/
*.jks
*.keystore
*.log
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
*.kotlin_module
obj/
app/src/main/jniLibs/arm64-v8a/libphp.so
/out/
.idea/caches/
.idea/libraries/
.idea/shelf/
.idea/workspace.xml
.idea/tasks.xml
.idea/.name
.idea/compiler.xml
.idea/copyright/profiles_settings.xml
.idea/encodings.xml
.idea/scopes/scope_settings.xml
.idea/vcs.xml
tmp_apk_inspect/
bugreport-*.zip
test-haze.kt
restore
server/
admin/
node_modules/
__pycache__/
*.pyc
.kotlin/

370
AGENTS.md Normal file
View File

@ -0,0 +1,370 @@
# AGENTS.md
Instructions for coding agents working in this repository.
## Code style
- Do what you believe is right. Make the change complete and correct, not the smallest possible diff. If a fix calls for refactoring, renaming, or touching multiple files, do it.
- Match the patterns and conventions already in the surrounding code.
- Do not add copyright or license headers unless asked.
## Project layout
| Path | Role |
|------|------|
| `app/` | Full builder host: editor UI, export pipeline, runtimes, preview. |
| `shell/` | Runtime template. Built to `app/src/main/assets/template/webview_shell.apk` via `:shell:assembleRelease` + `:app:syncShellTemplateApk`. |
| `clone-host/` | Host-side APK clone / identity reshape support library. Its DEX asset generation (`syncCloneHostDex`) is deliberately disabled (`enabled = false`, AV false-positive mitigation, e0d2d4d6) — `AppCloner` runs fail-soft without the asset. |
| `modules/` | Module Market catalog (`registry.json` + per-module folders). |
| `docs/` | VitePress documentation site (guide / developer / extensions, EN + ZH), published to https://shiaho777.github.io/web-to-app/ by `.github/workflows/docs-deploy.yml`. Site URL paths map 1:1 to files under `docs/` (`/zh/...` → `docs/zh/...`). |
| `scripts/` | Build helpers and gates (`check_config_field_drift.py`). |
Runtime Kotlin is authored under `app/` and synced into `shell` by `syncShellRuntimeSources`. Edit the `app/` source once; do not permanently fork copies under shell.
User-facing product docs: `README.md`, `.github/docs/README_CN.md`, `.github/CONTRIBUTING.md`, `modules/README.md`. The published documentation site is https://shiaho777.github.io/web-to-app/ (source: `docs/`, deployed by `.github/workflows/docs-deploy.yml`).
## How the main pieces connect
```text
Editor (Compose screens in app/)
↔ data models (WebApp, configs)
↔ export factory (ApkConfig / ApkConfigJsonFactory)
↔ ApkBuilder / ApkBuildCache → signed generated APK
app/ sources
→ syncShellRuntimeSources → shell DEX → webview_shell.apk (template)
Generated APK runtime
DruksCodeApplication → ShellModeManager → load assets JSON config
→ WebViewManager / runtime servers (Node/PHP/Python/Go/WordPress)
```
Mental model:
- **Host preview** runs `:app` with all classes on the main classpath.
- **Generated APK** runs the shell template classes (full runtime synced from `app/`), reading config from assets JSON via `ShellModeManager`.
- A flag in the editor is useless at export unless it flows through **model → ApkConfig JSON → shell config → runtime code**.
## i18n
- Host UI strings live in `app/src/main/java/br/com/drukstech/codeapp/core/i18n/Strings.kt` (split across `Strings` / `StringsA` … `StringsE`).
- **All** user-visible strings must be inline `when (Strings.lang)` blocks covering all 10 languages: Chinese, English, Arabic, Portuguese, Spanish, French, German, Russian, Japanese, Korean. `when(lang)` blocks may never use `else ->` — `AppStringsResourceConsistencyTest` and `StringsKtTranslationParityTest` enforce this.
- **Never** load user-visible text via `context.getString(R.string.*)` / `stringResource(R.string.*)`. `res/values/strings.xml` holds only `translatable="false"` resources (e.g. `app_name`) and no locale `values-*/` directories exist, so a localized resource string could never cover the 10 languages and would silently fall back to the default `values/` (Chinese). Use `Strings.xxx` (or `Strings.funName(arg)` for parameterised strings — see `linuxEnvInstalledToast(name)` for the pattern). Tests gate this: `kotlin source never references R string for user-visible text`, plus `values strings xml only holds non-localised resources` and `no locale values dirs or grouped app strings files exist` which block resurrecting resource-based strings.
- `R.string` is reserved for `translatable="false"` non-localised resources only (e.g. `app_name`).
- Prefer adding properties on the existing split objects; match surrounding style.
## Android and packaging constraints
- Generated apps keep a low `targetSdk` (28) on the shell path because they rely on on-device fork+exec runtimes. Do not raise shell targetSdk casually.
- The host app targets SDK 35 (antivirus reputation); SELinux W^X therefore blocks host-side exec of downloaded runtimes. `RuntimeExecPolicy` (`core/linux`) gates those previews by probing the installed targetSdk — generated APKs (always 28) pass unconditionally. Node preview (JNI via native libs) is unaffected.
- Launch runtime / toolchain processes through `HostProcessLauncher` (`core/linux`): plain `ProcessBuilder` fork+exec where the platform allows it (generated APKs, targetSdk 28), the user-mode static exec loader (`StaticExecProcess`, memfd-based) on W^X hosts (targetSdk ≥ 29), or a degraded error when no static exec bridge is available. A raw `Runtime.exec` on a downloaded binary crashes the host app on W^X devices (#795).
- Avoid new third-party dependencies unless strongly justified (`app/build.gradle.kts` / `shell/build.gradle.kts`). Prefer platform APIs and existing modules.
- Notification push channels: Web Notification polyfill, polling, WebSocket, FCM (developer-owned Firebase config). Do not add OEM vendor push SDKs by default.
- Foreground services and notification helpers must use `SafeNotificationChannels` (or equivalent fail-soft create). Channel creation failures must not crash FGS startup.
- **One shell template:** `webview_shell.apk` from `:shell` release. Do not introduce a second template APK.
- Export incremental rebuild lives in `app/.../apkbuilder` (`ApkBuildCache` + `ApkBuilder`):
- Modes: `FULL` / `CONTENT_OVERLAY` / `REUSE_UNSIGNED`.
- Template / entry identities must be **content-stable** (no mtime-based keys).
- Encrypted builds always force a full rebuild.
- Do not feed signed or renamed APKs back into full `modifyApk` as templates.
- Port coordination: `PortManager` + `PortConflictMode` (`REASSIGN` / `AUTO_KILL` / `ALERT`) with real stop handlers. Local server runtimes must allocate through PortManager and clean up on stop.
- Local server / Linux env DNS: fork+exec runtimes (Node / PHP / Python / Go / WordPress / Linux env) should wire through `LocalDnsBridgeProxy` when they need host DNS/proxy bridging.
- Large runtime downloads use `NetworkModule.downloadClient` (extended timeouts), not the default short-lived client.
- HTML / FRONTEND packaged shells need file-scheme access via `ShellWebViewConfig` (`allowFileAccess` / local-file detection). Do not regress pure file-based HTML loads.
- Node.js export must embed `libnode_bridge.so`, `libnode.so` (16KB-aligned), and `libc++_shared.so` as native libs. Go export must embed `libgo_exec_loader.so`.
- Gradle custom tasks (`syncCloneHostDex`, etc.) must be configuration-cache safe: capture `File`/`Provider` values at configuration time, do not reference `Project`/`android.sdkDirectory` inside task closures.
## Workflow
- Do not commit secrets, `local.properties`, keystores, or IDE/cache junk.
- Do not create commits, push, open PRs, or file Issues unless the user asks to deliver / ship / push / open a PR (or equivalent).
- When changing export or shell packaging, rebuild the template you touched.
- When changing config fields, run `checkConfigFieldDrift` to catch model ↔ shell config name drift.
### Delivery (Issue + PR + CI)
Default target: [shiaho777/drukscode](https://github.com/shiaho777/web-to-app). Prefer a pull request over direct pushes to `main` when delivering code. Human-facing wording of the same loop lives in [CONTRIBUTING.md](.github/CONTRIBUTING.md); keep those docs in sync when this process changes.
**Language (required):** GitHub **Issues and PRs must be written in English** — titles, bodies, labels text you author, and delivery comments on the Issue/PR. Local chat with the user may be Chinese or any language; do not copy that language into Issue/PR text.
When the user asks to deliver a change, run the Issue → branch → PR → CI → merge loop end-to-end. Do not close the Issue until the PR is merged and CI is green.
---
## Shell template and runtime sync
### Dual runtime (preview vs export)
| | Host `:app` | Generated APK |
|--|-------------|---------------|
| DEX | All `app/src` classes | Shell sync include−exclude (full runtime set) |
| Config | Editor / in-memory models | Assets JSON via `ShellModeManager` |
| Template | Not used | `app/src/main/assets/template/webview_shell.apk` |
**Preview ≠ export** unless both paths stay valid:
1. Host still has the real implementation.
2. Shell-synced code reads the config field from assets JSON at runtime.
3. The field name in `ApkConfig` JSON matches the `@SerializedName` in `ShellModeManager` (Gson silently drops mismatches).
Most common failure: preview works; exported APK silently skips the feature because a config field name drifted between the export factory and the shell config class.
### Where each concern is edited
| Concern | Path |
|---------|------|
| What enters shell | `shell/build.gradle.kts` → `syncShellRuntimeSources` include/exclude |
| Shell template build | `:shell:assembleRelease` + `:app:syncShellTemplateApk` |
| Template output | `app/src/main/assets/template/webview_shell.apk` |
| Config → shell JSON | `app/.../apkbuilder/ApkConfigJsonFactory.kt` |
| Shell config types | `app/.../core/shell/ShellModeManager.kt` |
| Runtime WebView config | `app/.../ui/shell/ShellWebViewConfig.kt` |
| Config drift gate | `scripts/check_config_field_drift.py` → `:app:checkConfigFieldDrift` |
| Shell minify policy | `shell/proguard-rules.pro` |
---
## Common change recipes
These are the default approaches for everyday work. Follow the chain end-to-end; stopping at UI or host-only code is how preview and export diverge.
### 1. Add or change a host UI string
1. Add the property in the correct `Strings*` split with all 10 languages.
2. Reference it from Compose/UI the same way neighbors do.
### 2. Add an editor setting that must affect the generated APK
Trace and update **all** of:
1. Model (`WebApp` / nested config) and editor UI binding
2. Export mapping (`ApkBuilder` / `ApkConfig` / `ApkConfigJsonFactory`)
3. Shell config types (`ShellModeManager` / shell config data classes) if runtime reads them
4. Runtime use site in shell-synced code
5. Unit tests for export wiring when flags change
6. **Coverage test update (REQUIRED).** When adding a new Boolean field to
`WebViewConfig`, you MUST add it to `flipAllBooleans()` in
`WebViewConfigBooleanCoverageTest.kt`. The test
`flipAllBooleans covers every declared Boolean field` uses reflection to
verify that every declared Boolean field is listed — if you forget, CI
fails with a message naming the missing field. For non-Boolean fields, add
a spot-check to `key non-Boolean WebViewConfig fields survive the export
round-trip`. This is the safety net that catches "preview works, export
broken" before it ships.
7. **Shell UI parity (REQUIRED for per-type player screens).** Gallery / media /
splash-style app types render through DIFFERENT composables in preview
(`ui/gallery/`, `ui/media/`) vs generated APKs (`ui/shell/`). A flag that flows
end-to-end still does nothing if the shell screen never reads it (gallery
thumbnail bar shipped config + assets correctly and still rendered nothing,
#781). `ShellUiParityTest.kt` enforces this: every model field read by host
runtime UI must also be read by shell runtime UI. If you add a field and read
it in a host player, the test fails naming the field until you port the
behavior to shell or add an explicit `allow` entry with a reason. When adding
a new per-type player pair, extend its `pairs` list the same way.
Missing any step usually yields: editor shows the switch, export ignores it, or export embeds config the runtime never reads.
### 3. Change shell runtime behavior used by every generated app
1. Edit the source under `app/` (shared runtime).
2. Confirm the file is included by `syncShellRuntimeSources`.
3. Rebuild shell template if you need to validate packaging.
4. Keep changes surgical; shell has a low targetSdk and a thin dependency set.
5. If you touch FGS / notification channel creation, fail soft via `SafeNotificationChannels`.
### 4. Add a host-only feature (editor, market, tooling)
1. Keep implementation under host-only packages (`core/apkbuilder`, host screens, sample/market, `core/host`, …).
2. Do not pull host-only deps into `shell/build.gradle.kts`.
### 5. Touch APK export or incremental rebuild
1. Prefer `ApkBuildCache` / content hashes over timestamps.
2. Encrypted builds stay full rebuild.
3. Do not use signed outputs as templates.
4. If template bytes change, ensure cache keys invalidate correctly.
### 6. Change notifications / engines / network hardening
1. Prefer existing channel abstractions (polyfill / polling / WebSocket / FCM).
2. Do not add OEM push SDKs by default.
3. FGS channel create paths must tolerate OEM/channel failures.
### 7. Module Market / `modules/`
1. Follow `modules/README.md` catalog layout (`registry.json` + module folders).
2. Runtime consumption still goes through the extension/shell paths if it ships inside generated APKs.
### 8. Fix "works in preview, broken after export"
Checklist in order:
1. Did shell config JSON actually contain the field at runtime?
2. Does the field name in `ApkConfig` JSON match the `@SerializedName` in shell config? Run `checkConfigFieldDrift`.
3. Is the runtime use site shell-synced (not host-only)?
4. For adblock: confirm `adBlockEnabled` mapping, host filter rebuild from cached subscriptions, and export rule compile without wiping host state.
5. Rebuild template after sync changes (stale template is a frequent miss).
### 9. Local server runtime / download path
1. Allocate ports through `PortManager` with the configured conflict policy; implement real stop handlers.
2. Wire fork+exec processes into `LocalDnsBridgeProxy` when they need host DNS/proxy env.
3. Use `NetworkModule.downloadClient` for large dependency / engine / runtime downloads.
4. Launch processes through `HostProcessLauncher` (`core/linux`) so W^X hosts (targetSdk ≥ 29) degrade to the user-mode static exec loader instead of crashing.
### 10. Node.js / Go export
1. Node.js: ensure `injectNodeJsNativeLibs` embeds `libnode_bridge.so` + `libnode.so` (16KB-aligned via `ElfAligner16k`) + `libc++_shared.so`. Node binary resolution prefers `nativeLibraryDir`, falls back to download cache.
2. Go: ensure `injectGoExecLoaderNativeLib` embeds `libgo_exec_loader.so`.
3. Go host-side builds never run the multi-process `go build` driver: `GoDirectBuilder` replays `compile`/`asm`/`link` as single-shot processes through `HostProcessLauncher` (user-mode static exec loader on W^X hosts), with a content-keyed persistent package archive cache (#796).
4. `NodeService` runs in a dedicated `:nodejs` OS process so V8 lifecycle is isolated from the host.
### 11. Change a feature that has an Agent tool
The in-app Agent exposes up to 57 tools — 52 base + 2 plan-mode + 3 imagery (imagery only load with an image-capable model; see `ToolRegistryFactory.build()`) — that wrap host service classes. When you change a feature, trace the tool chain:
```text
LLM response (tool_calls)
→ AgentEngine.executeToolCall()
→ PermissionPrompter (write tools ask user; read-only run silently)
→ Tool.execute(args, ctx)
→ Service class (ApkBuilder, AppExporter, PortManager, EngineManager, AdBlocker, …)
→ ToolResult → back to LLM
```
Checklist when touching a feature that an Agent tool wraps:
1. **API signature drift.** If the service class constructor, method signature, or return type changes, update the tool's `execute()` in `app/.../agent/tool/builtin/`. A stale call compiles only if the old overload still exists; otherwise it's a build break.
2. **`parametersSchema` ↔ `execute()` alignment.** The JSON schema advertised to the LLM must match what `execute()` actually reads from `args`. Adding a parameter to the service call without exposing it in the schema means the LLM can never pass it.
3. **`description` accuracy.** The LLM decides *when* and *how* to call a tool solely from `description` + `parametersSchema`. If the feature's behavior changed, update the description text — a misleading description causes silent misuse.
4. **`isReadOnly()` correctness.** `true` → runs without user confirmation; `false` → triggers `PermissionPrompter` dialog. If a tool gains write side-effects, flip it to `false`.
5. **Registration.** New tools must be added to `ToolRegistryFactory.baseTools()` (grouped by domain comment). Forgotten registration = tool invisible to the LLM.
6. **Removed features.** If a feature is deleted, remove or disable its tool. A tool that calls a dead class crashes at runtime inside the agent loop.
7. **Host-only.** Agent tools live under `core/agent/` and are never shell-synced. Do not add them to `syncShellRuntimeSources`.
Key paths:
| Concern | Path |
|---------|------|
| Tool interface | `app/.../agent/tool/Tool.kt` |
| Tool registry | `app/.../agent/tool/ToolRegistryFactory.kt` |
| Tool context (services access) | `app/.../agent/tool/ToolContext.kt` |
| Built-in tools (by domain) | `app/.../agent/tool/builtin/*.kt` |
| Agent loop / execution | `app/.../agent/engine/AgentEngine.kt` |
| Permission prompt (Channel-based) | `app/.../agent/permission/PermissionPrompter.kt` |
| LLM provider (SSE streaming) | `app/.../agent/llm/OpenAiCompatProvider.kt` |
### 12. Change editor config-card UI (Compose)
The editor screens are built from `DkcSettingCard`s that share one visual grammar. The activation-card series (issue #567: PRs #568 → #571 → #573 → #574) took four attempts because each round invented layout instead of copying the neighbours, and each round was only compile-checked. A short prompt like "改一下 XX 卡的 UI" still means: follow these rules.
**Copy, don't invent.** Before writing any card, open the reference implementation on the same screen and mirror it element by element:
| You are writing… | Copy from (`CreateAppWebViewCards.kt`) |
|------------------|----------------------------------------|
| Toggle-headed feature card | `FullscreenModeCard` |
| Collapsible card with `DkcChoiceRow` header | `BrowserAdvancedConfigCard` |
| Radio group (single-choice) | PWA offline-strategy selector in `sectionOfflinePerformance` |
| Sub-toggles / fields inside an expanded zone | `staticAssetPack` block, proxy block |
| Nested conditional content | proxy MANUAL/PAC `AnimatedVisibility` swap |
Inputs and dialogs follow the same grammar: text inputs are `PremiumTextField`, list-manager dialogs are `Dialog` + `DkcCard` + embedded `TopAppBar` (see `AdBlockSubscriptionSelectorDialog`), and `ActivationCodeCard`'s offline-policy rows are another canonical radio reference.
Hard rules learned the hard way:
1. **Two sanctioned card shapes.** Toggle header: `DkcSettingCard { DkcToggleRow(header); AnimatedVisibility(enabled, CardExpandTransition/CardCollapseTransition) { Column { DkcSectionDivider(); full-bleed rows separated by more dividers } } }`. Collapse header: `DkcSettingCard { Column { DkcChoiceRow(header); AnimatedVisibility(expanded) { Column(padding(horizontal = DkcSpacing.RowHorizontal, vertical = DkcSpacing.ContentGap), spacedBy(DkcSpacing.ContentGap)) { … } } } }`.
2. **Alignment grid.** `DkcToggleRow` / `DkcChoiceRow` are full-bleed rows carrying their own 16dp padding. Anything that is *not* a row — text fields, radios, group labels, buttons, notes — must sit inside a `Column(padding(horizontal = DkcSpacing.RowHorizontal, vertical = DkcSpacing.ContentGap))` zone. Sub-toggles inside a zone double-indent (16dp zone + 16dp row); that matches the reference, do not "fix" it. Group labels are `Text(style = labelMedium, color = primary)`, not `DkcSection`.
3. **Radio groups** are exactly: `Text(labelMedium, primary)` label, then `Row(fillMaxWidth().clip(MaterialTheme.shapes.small).clickable { … }.padding(vertical = 4.dp)) { RadioButton; Spacer(4.dp); Text(bodySmall) }`. Never wrap a radio group in `DkcSection`, never attach per-option prose.
4. **Card headers carry title + icon only.** No subtitle on the header row, no free-floating description text next to it. Subtitles belong on child rows that genuinely need one (e.g. 显示状态栏).
5. **Expansion state ≠ feature state.** Never bind a section's `isExpanded` / `AnimatedVisibility(visible=…)` to the feature's `enabled` flag — expanding a panel must never switch the feature on. Section collapse state is its own `remember { mutableStateOf }`.
6. **Conditional sub-blocks** (mode swaps, dependent fields) use `AnimatedVisibility` with `CardExpandTransition` / `CardCollapseTransition`, never bare `if` inside the card body.
7. **Compile ≠ verified.** After any card UI change, build + install on the emulator and check the rendered card: `uiautomator dump` element bounds, compare left edges / row heights of the changed card against its neighbours on the same screen (they must share the same content columns), plus a screenshot pass. Content a few dp off the grid is invisible in code review and obvious on screen.
8. **If a UI rework PR gets "this doesn't match the other cards" feedback**, the fix is realignment to these patterns, not further invention.
---
## Easy-to-miss points
- **Keyboard avoidance below API 30 requires the classic window path.** Android 10 and lower have no native IME-inset dispatch: an edge-to-edge window (`decorFitsSystemWindows = false`) is never resized for the keyboard and reports zero IME insets, so no `softInputMode` value helps there. `WindowHelper.applyImmersiveFullscreen` therefore keeps the decor fitting system windows on the RESIZE keyboard path below API 30 (system `SOFT_INPUT_ADJUST_RESIZE` works) and degrades TRANSPARENT/IMAGE status-bar styles to solid colors on that path. Do not re-enable edge-to-edge unconditionally for those devices (#613; #634 flipped only the softInputMode bits and fixed nothing — its Robolectric test passed because it never asserted the layout flags).
- **Shared sources are authored in `app/`.** Editing only a file under `shell/src` is usually wrong; it will be overwritten on sync or diverge from host.
- **Config field names drift.** Editor model, `ApkConfig`, JSON factory, and shell config must stay aligned; Gson silently drops unknown/missing fields. Run `checkConfigFieldDrift`.
- **Low targetSdk (28) and fork/exec runtimes** constrain "modernize the shell SDK" changes.
- **Incremental export cache** keys must be content-based; mtime and resigned APKs create false hits/misses.
- **HTML/FRONTEND file access.** Packaged local-file shells must have `allowFileAccess = true` (forced in `buildWebViewBlock` and `ShellWebViewConfig`); do not regress pure file-based HTML loads.
- **Node native libs.** Exported NODEJS_APP needs `libnode_bridge.so` + `libnode.so` + `libc++_shared.so`; missing any causes `loadNode` / `loadJniBridge` failure at runtime.
- **16KB page alignment.** `libnode.so` and other large ELF natives must be 16KB-aligned (`ElfAligner16k`) for Android 15+ devices; `node_bridge.cpp` / `node_launcher.c` enable 16KB app-compat before `dlopen`.
- **Node JNI output bridge.** `NodeJniOutputBridge` is a stable class referenced by native code; keep its `-keep` proguard rule so R8 does not rename `onOutput`.
- **Crashing FGS when notification channel creation fails.** Always use `SafeNotificationChannels` for channel creation.
- **Adblock is wired for preview + export.** Do not wipe host filter state during export; the host AdBlocker serves preview and the compiled rule set ships in the APK.
- **Runtime permissions are feature-driven.** `RuntimePermissionSync` derives the permission list from enabled features; do not revert to a static template.
- **Splash preview media path.** Preview reads splash media from the host filesystem (`splashMediaPath`); export packages it into assets. Do not hardcode `assets/splash_media.*` as the only source.
- **Host player UI ≠ shell player UI.** Preview players (`ui/gallery/`, `ui/media/`) and generated-APK players (`ui/shell/`) are separate composables sharing only the config. Port every visible behavior across (thumbnail bar, background, keep-screen-on, orientation) and let `ShellUiParityTest` verify the flags; config flowing end-to-end is necessary but not sufficient.
- **Port conflict policy.** Local server runtimes must allocate through `PortManager` and clean up on stop; do not bind ports directly.
- **Agent tool ↔ service drift.** When a service class API changes, the corresponding Agent tool in `core/agent/tool/builtin/` must be updated in the same PR. A stale tool either fails to compile or silently passes wrong arguments at runtime. Check `ToolRegistryFactory.baseTools()` for the full tool list.
- **Editor card UI grammar.** Config-screen cards share one layout grammar (recipe 12): rows are full-bleed, non-row content sits in 16dp-padded zones, expansion never toggles the feature, and card UI is verified on the emulator — not just compiled.
---
## Forbidden / high-risk mistakes
- Second shell template APK
- Excluding a shell-synced class but leaving imports/constructors in shell-synced sources
- Re-enabling shell R8 obfuscation / aggressive shrink without testing exported apps
- Putting host-only tools back into shell sync for convenience
- OEM push SDKs or unjustified heavy dependencies in shell
- Feeding signed/renamed APKs into template/modify paths
- Crashing FGS when notification channel creation fails
- Committing secrets, keystores, or local machine config
- Regressing HTML/FRONTEND file access in packaged shells
- Shipping a host player-screen feature without its shell counterpart (preview-only UI)
- Shipping NODEJS_APP without `libnode_bridge.so` / `libnode.so` / `libc++_shared.so`
- Skipping 16KB alignment for large ELF natives
- Inventing editor card layout/spacing/animations instead of copying the neighbouring cards' patterns, or shipping card UI verified only by compilation
---
## Verify commands
```bash
./gradlew :shell:assembleRelease :app:syncShellTemplateApk --no-configuration-cache
./gradlew :app:compileStandardDebugKotlin -x syncCloneHostDex --no-configuration-cache
./gradlew :app:checkConfigFieldDrift --no-configuration-cache
python3 scripts/check_config_field_drift.py
```
Use these when you change shell membership, export packaging, or config fields. For host-only UI/string work, targeted compile on `:app` is usually enough.
CI note: the PR `check` job compiles only `:shell:compileDebugKotlin` (debug variant) and skips template sync (`-PskipShellTemplateSync=true`). The shell **release** variant (R8 / proguard) and the template pipeline are exercised only by the manual `workflow_dispatch` packaging job — after touching `shell/proguard-rules.pro` or shell packaging, run the first command locally before pushing.
Related focused tests often worth running after nearby edits: `ApkBuildCacheTest`, `AdBlockerHostRuntimeTest`, `AdBlockExportWiringTest`, `PortManagerTest`, `BuildInputPreflightTest`, `GoBuildEnvironmentTest`, `RuntimePermissionSyncTest`.
---
## Implementation snapshot
Landed:
- Single shell template (`webview_shell.apk`) from `:shell` release, full runtime synced from `app/`
- Incremental `ApkBuildCache` (`FULL` / `CONTENT_OVERLAY` / `REUSE_UNSIGNED`); encrypted builds always full
- Notification channels: polyfill, polling, WebSocket, FCM (BYO Firebase) via existing abstractions
- `SafeNotificationChannels` fail-soft path for FGS
- `PortManager` conflict policies + real stop handlers across Node/PHP/Python/Go/WordPress
- `LocalDnsBridgeProxy` wiring for local server runtimes (including Node.js)
- Runtime downloads via `NetworkModule.downloadClient`
- Adblock preview + export wiring restored
- HTML/FRONTEND file-access for packaged local shells
- Node.js export: `libnode_bridge.so` + 16KB-aligned `libnode.so` + `libc++_shared.so`; 16KB app-compat before dlopen; stable `NodeJniOutputBridge` JNI callback
- Go export: `libgo_exec_loader.so` embedded; in-app build ENOSPC handling + GOTMPDIR relocation
- Go host-side builds on W^X hosts: `GoDirectBuilder` replays `compile`/`asm`/`link` as single-shot processes through `HostProcessLauncher` (user-mode static exec loader), with a content-keyed persistent package archive cache (#796)
- Multi-web: gallery/media site sources embedded into the APK and resolved at runtime; nested site sources degrade to a URL instead of aborting the build (#798, #792)
- Gallery playback: shuffleOnLoop, rememberPosition, overview grid, thumbnail bar, pinch-to-zoom viewer, with host/shell parity enforced by `ShellUiParityTest` (#781, #786, #801)
- Untrusted bitmap decodes bounded (oversized-image crash fix) (#788)
- Backup/restore coverage aligned with the current storage layout; restarts only when local files change, with throttled progress callbacks (#790, #794)
- NativeBridge enabled by default for newly created apps (#805)
- Runtime permission sync (feature-driven)
- Splash preview media path fallback
- Config field drift detection (`checkConfigFieldDrift`)
- Module Market: Chrome Web Store live search + GreasyFork browse
- Code editor find-and-replace
- Security hardening sweep: TLS-fingerprint bridge validates upstream certs (system + custom CAs + hostname) and its local CA is signature-verified with no error-type fallback; JS bridges are caller/origin/scheme-gated (NativeBridge CORS bypass, GM bridge, MV3 `ChromeHostPermissions`); MITM proxy host-allowlisted and CA key wrapped at rest; zip extraction routed through `util/SafeZip`; concurrent exports serialized per package with per-package work dirs; multi-web server-runtime site sources degrade to URL; PHP/Python/WP embed failures fail the build; error pages and `TranslateBridge` callbacks JSON-escaped; keystore password sidecars excluded from backups
- Agent tool system: up to 57 tools — 52 base + 2 plan-mode + 3 imagery (image-capable models only) — covering app lifecycle, config templates, ports/engine, hosts/runtime, stats/modifier/import, build env/Play, modules, files, and imagery, with Channel-based permission prompting, per-section SSE parse resilience, and plan mode; runtime/build-env tools surface `localExecAllowed` so the LLM knows targetSdk>=29 hosts cannot exec app-storage binaries

24
LICENSE Normal file
View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

365
README.md Normal file
View File

@ -0,0 +1,365 @@
<div align="center">
<img src="app/src/main/res/mipmap-xxxhdpi/ic_launcher.png" width="96" alt="DruksCode icon" />
# DruksCode
### Build Android APKs from web projects, directly on your phone.
**An on-device APK workshop that goes far beyond URL wrapping — it can fork and exec full server runtimes, ship a hardened anti-censorship network stack, sign bundles for Google Play, and run MV3 browser extensions, all without a PC or a remote build server.**
**English** · [简体中文](.github/docs/README_CN.md)
<p align="center">
<a href="https://github.com/shiaho777/web-to-app/stargazers">
<img src="https://img.shields.io/github/stars/shiaho777/drukscode?style=for-the-badge" alt="Stars">
</a>
<a href="https://github.com/shiaho777/web-to-app/network/members">
<img src="https://img.shields.io/github/forks/shiaho777/drukscode?style=for-the-badge" alt="Forks">
</a>
<a href="https://github.com/shiaho777/web-to-app/releases/latest">
<img src="https://img.shields.io/github/v/release/shiaho777/drukscode" alt="Latest Version">
</a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/22597?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-22597" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/trendshift/repositories/22597/weekly?language=Kotlin" alt="shiaho777%2Fdrukscode | Trendshift" width="250" height="55">
</a>
</p>
<p align="center">
<a href="https://www.swiftproxy.net/?ref=shiaho">
<img src=".github/assets/sponsors/swiftproxy-logo-full.png" alt="Swiftproxy" width="220" />
</a><br/>
<a href="https://www.swiftproxy.net/?ref=shiaho"><b>Sponsored by Swiftproxy</b></a> — Swiftproxy · Reliable Residential Proxies for Web Automation
</p>
<p align="center">
<a href="#what-makes-webtoapp-different">What's different</a> ·
<a href="#what-you-can-build">What you can build</a> ·
<a href="#capability-overview">Capability overview</a> ·
<a href="#full-feature-map">Feature map</a> ·
<a href="#module-market">Module market</a> ·
<a href="#documentation">Documentation</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#build-from-source">Build</a>
</p>
---
<div align="center">
<img src=".github/assets/social-preview.png" width="90%" alt="DruksCode: My Apps home, the app list with the main toolbox (Agent, Extension Modules, Port Manager), per-app APK actions (Edit, Create Shortcut, Build/Share/Export APK), and the About screen — all running on an Android phone" />
</div>
---
## What makes DruksCode different
Most "website to app" tools stop at wrapping a URL in a WebView. DruksCode is closer to a pocket-sized APK workshop, and the hard parts are exactly where it diverges:
- **It runs real server runtimes on-device.** Node.js, PHP, Python, Go, and WordPress are fork+exec'd as native binaries straight from app storage — like Termux, packaged into an installable APK. URL-wrapper tools cannot do this at all.
- **It ships a hardened, anti-censorship network stack.** DNS-over-HTTPS, TLS fingerprint spoofing (Chrome / Firefox / Safari JA3 templates) with a local MITM bridge, Encrypted Client Hello (ECH) on both engines to encrypt the SNI, per-app proxies, and CORS bypass for locked-down SPAs.
- **The whole build is self-contained.** Binary AXML/ARSC patching, permission pruning, V1/V2/V3 signing, and Google Play-ready AAB export all happen inside the app via `apksig` — no remote build queue, no PC.
- **It stays extensible after shipping.** Add JS/CSS modules, Tampermonkey-style userscripts, or MV3 Chrome extensions (live-searched and installed from the Chrome Web Store) without rebuilding the host.
- **The host UI speaks 10 languages out of the box.** Chinese, English, Arabic (RTL), Portuguese, Spanish, French, German, Russian, Japanese, and Korean — switch anytime in Settings; new in-app copy is maintained for all ten.
---
## Capability overview
A quick scan of what's in the box. Each links to the detailed feature map below.
| Area | Highlights |
| --- | --- |
| **Build targets** | Web · HTML · Frontend · WordPress · Node.js · PHP · Python · Go · Image · Video · Gallery · Multi-Web |
| **Browser engines** | System WebView by default; optional GeckoView (Firefox) runtime |
| **Network & anti-censorship** | DoH (7 providers), TLS fingerprint spoofing + MITM bridge, ECH, static/PAC/SOCKS5 proxies, CORS bypass |
| **Privacy & hardening** | 50+ vector browser fingerprint disguise, resource encryption (AES-256-GCM), anti-debug, activation gating |
| **Local runtimes** | Native Node.js 18.20, PHP 8.4 + Composer 2.10, Python 3.14, official Go 1.26, WordPress 7.x over SQLite |
| **Extensions** | Built-in modules, userscripts with `GM_*`, MV3 Chrome extensions, live Chrome Web Store search |
| **APK/AAB output** | On-device V1/V2/V3 signing, Google Play AAB export with targetSdk rewrite, keystore management |
| **Agent** | Full-app automation via up to 57 tools: generate, build, export, manage ports/engines/runtimes, clone apps, ad-block rules, config templates, and more; auto-retry on 429/5xx |
| **Host languages** | **10 UI languages** — 中文 · English · العربية · Português · Español · Français · Deutsch · Русский · 日本語 · 한국어 (Arabic RTL) |
---
## What you can build
| Input | Output | Good for |
| --- | --- | --- |
| Website URL | WebView-based APK | Landing pages, tools, dashboards, docs, internal systems |
| HTML / static front-end | Localhost-backed APK | React, Vue, Vite, static builds, offline web apps |
| Node.js / PHP / Python / Go | APK with an on-device local server | Small server apps, admin tools, demos, prototypes |
| WordPress | APK running WordPress over local PHP + SQLite | Portable sites, theme/plugin demos, content packages |
| Images / video / galleries | Media-focused APK | Albums, course materials, portfolios, offline viewers |
| Multiple sites | Tab/card/feed/drawer multi-web APK | Link hubs, portals, app collections |
| Installed APK | Rebranded clone or shortcut disguise | Icon/name/package experiments, repackaging research |
---
## Full feature map
DruksCode has a large number of switches. The sections below group them by use case, kept collapsible so the top of the page stays scannable.
<details>
<summary><b>🌐 Browser engine & networking</b></summary>
- **Dual engine** — System WebView by default, or an optional GeckoView (Firefox) runtime downloaded on first use.
- **Kernel flavor disguise** — present as Chrome, Edge, Samsung Internet, Firefox, or Safari-style while keeping the real engine.
- **Desktop mode**, custom User-Agent, and JS/CSS injection at document-start / end / idle.
- **Popup handling** — same window, external browser, popup window, or block.
- **Proxies** — static HTTP/HTTPS/SOCKS5, PAC, authentication, bypass rules, and a local HTTP-to-SOCKS bridge.
- **DNS-over-HTTPS** — Cloudflare, Google, AdGuard, NextDNS, CleanBrowsing, Quad9, Mullvad, plus custom endpoints; strict or automatic modes.
- **Encrypted Client Hello (ECH)** — encrypt the SNI in the TLS handshake on **both engines**: GeckoView via TRR + its ECH prefs, the system WebView via the MITM bridge's Chromium upstream (auto-downloaded on first use, embedded in exported APKs). Auto-wires DoH when toggled; a SOCKS upstream proxy takes precedence.
- **TLS fingerprint spoofing** — impersonate Chrome 131 / Firefox 133 / Safari 18 JA3 profiles (or custom ciphers), served through a local TLS-MITM bridge so the outgoing ClientHello matches a real browser.
- **CORS bypass** — on by default for static SPAs that call external APIs blocked by CORS; same-origin traffic is left alone, and CORS-only apps can use a lightweight `PrivateNetworkNativeBridgeAdapter` without the full Native Bridge surface.
- **Failover** — automatic fallback to mirror URLs when the primary target is unreachable.
- **PWA** offline cache strategies, custom error pages, per-app host overrides, and payment-scheme handlers.
- **Compatibility toggles** — blob download interception, scroll memory, image repair, clipboard / orientation / notification polyfills, private-network bridging, and Native Bridge capability gates.
- **Download location** — system Downloads, app-private storage, or a user-picked SAF folder, wired through the full packaging passthrough chain.
</details>
<details>
<summary><b>🛡️ Privacy, fingerprint defense & hardening</b></summary>
- **Browser fingerprint disguise across 50+ vectors** — User-Agent, WebGL, Canvas, AudioContext, ClientRects, timezone, language, memory, media devices, WebRTC, fonts, battery, permissions, performance, storage, notifications, CSS media, iframe propagation, and error-stack cleanup.
- **Hosts-rule ad blocker** with cosmetic MutationObserver filtering, **20 built-in community filter lists** (EasyList, uBlock Origin, AdGuard, AdAway, plus 8 language-specific lists), per-source enable/disable/delete, named custom filter imports managed as cards and selectable per app, and custom subscription rules bundled into the APK.
- **Resource encryption** (PBKDF2 + AES-256-GCM) for packaged config, HTML, media, and BGM; optional custom encryption password stronger than package/certificate-derived keys.
- **Runtime hardening** when encryption is on — anti-debug, anti-Frida, DEX-tamper checks; threat response of log-only, silent exit, or randomized crash.
- **WebView/content isolation** for storage, WebRTC, Canvas, Audio, WebGL, fonts, headers, and IP surfaces.
- **Activation-code gating** — local verification, or your own HTTPS endpoint signed with EC P-256. See the [remote activation docs](.github/docs/remote-activation.md).
</details>
<details>
<summary><b>📦 Local server runtimes (fork + exec on-device)</b></summary>
- **Node.js** (18.20.x) runs in a dedicated `:nodejs` OS process via a native `node_launcher` wrapper loading `libnode.so`; supports custom native `.node` extensions.
- **PHP 8.4** from `pmmp/PHP-Binaries`, downloaded once on first use, with Composer 2.10.x and custom native extensions (`zend_extension`, `.so`).
- **Python 3.14** — Flask, Django, FastAPI via uvicorn, Tornado, the built-in HTTP server; pip dependencies resolved into `.pypackages`, custom native extensions supported; binary names are versioned so future bumps do not hard-code paths.
- **Go 1.26** — official Linux arm64 toolchain (`.tar.gz` from `dl.google.com`, USTC mirror for CN), on-device `go build` / `go mod` / `go run`, `vendor/` offline builds, static serving, and the native `go_exec_loader` wrapper; DNS and CA trust go through the same local JVM bridge used by PHP.
- **WordPress 7.x** over local PHP + SQLite (`sqlite-database-integration`), with theme and plugin import.
- **Linux Environment** screen manages toolchains and dependencies for Node, PHP, and Python.
- **Port Manager** coordinates runtime ports across generated apps via broadcast receivers.
- A **local DNS bridge proxy** (HTTP CONNECT in the Android JVM) gives runtimes working DNS resolution and outbound HTTP where the musl/packed binary can't reach the system resolver.
</details>
<details>
<summary><b>🧩 Extensions & automation</b></summary>
- **Built-in modules** — video download (YouTube / Bilibili / Douyin / Xiaohongshu extractors), video enhancer with YouTube cleanup (ad skip, max quality, background play, SponsorBlock), web analyzer, find-in-page, dark mode, privacy tools, content enhancer, element blocker, and YouTube launcher.
- **Userscripts** — Greasemonkey/Tampermonkey-style `.user.js` with a `GM_*` bridge (storage, requests, styles, menu commands) and promise-based `GM.*` APIs gated by script grants.
- **MV3 Chrome extension runtime** for manifest content scripts in isolated or main worlds, with `chrome.*` polyfills for runtime, storage, tabs, scripting, and declarative network-request parsing.
- **In-app Chrome Web Store search** — browse and install browser extensions by keyword (or paste a store URL / extension ID), with offline fallback to manual import.
- **Export codes** (`DKC1:` gzip + Base64) and QR sharing via ZXing.
- **Agent** — a tool-calling assistant with up to 57 built-in tools covering the entire app surface: create/edit/build/export apps, manage ports and browser engines, install/clear runtimes, ad-block hosts rules, common-config templates, usage stats, app cloning, batch import, Play policy checks, and module development. Plan mode waits for user approval; automatic retry/backoff on 429/5xx.
</details>
<details>
<summary><b>📱 App experience</b></summary>
- **Splash screens** — image or video, with skip behavior, trim ranges, and fixed orientation.
- **Background music** — playlists with synced LRC lyrics, lyric animations, custom font/color/stroke/shadow, and online music search.
- **Toolbar, status bar (light & dark), navigation, floating-window mode, and long-press menu styles.** The browser toolbar is a master toggle (off by default) with per-item buttons for title/URL/back/forward/refresh plus a native **find-in-page** bottom bar and a **console** panel for on-device debugging. Status bar color can follow theme, a custom color, full transparency, or **PAGE_TOP** (sample the page’s top pixels so the chrome matches the content).
- **Download location mode** — system Downloads, app-private directory, or a custom SAF folder picked by the user.
- **Announcement templates** for launch, interval, and no-network moments.
- **Host app language** — switch the entire builder UI among 10 languages (中文 / English / العربية / Português / Español / Français / Deutsch / Русский / 日本語 / 한국어); Arabic is full RTL.
- **Translation overlay** — 20 target languages via Google, MyMemory, LibreTranslate, or Lingva engines, with automatic failover across them (in-page translate for the *content* of generated apps, separate from host UI language).
- **Print bridge** — intercept `window.print()` and blob/data-URL PDFs to the Android print framework / PDF export (with an onPageStarted re-inject fallback so late navigations stay hooked).
- **Media Session bridge** — surface web media on the system media notification and lock-screen controls, with Bluetooth and Android Auto support.
- **Notifications** — Web Notification polyfill, scheduled and persistent notifications with progress, URL-polling foreground service, deep links, boot auto-start, scheduled launch, and background-run service.
- **Per-app usage stats** with Vico charts and URL health monitoring.
</details>
<details>
<summary><b>🔧 APK / AAB export & signing</b></summary>
- **Custom package name**, `versionName`, `versionCode`, icon, label, architecture target, and export format.
- **Build-time permission injection** with unused permissions pruned from the template manifest.
- **One-tap AAB export** — auto-builds the APK on demand, converts it to a Play-ready signed AAB with `targetSdk` rewritten to the Play-required level (currently 36) and protobuf metadata generated locally; cancellable mid-build.
- **Keystore management** — create, import, export, delete, and certificate-fingerprint viewing; PKCS12/PFX/JKS/BKS import including Android Studio upload-key cases where store and key passwords differ.
- **Signature schemes** — V1, V2, V3 independently controlled, with auto-fallback for legacy certificates; custom V1 signer filename for `META-INF/<name>.SF` / `.RSA`.
- **Performance options** — image compression, WebP conversion, code minification, lazy loading, DNS prefetch, and preload hints.
- **Full project and app-data backup/restore.**
</details>
<details>
<summary><b>🗂 File manager & project tooling</b></summary>
- **File manager** — a single screen to view, share, install, open, and clear build outputs (APK builds, AAB exports, app clones, build logs) and a user-files directory, with a read-only build-log viewer.
- **Website scraper** for offline packs — HTML, CSS, JS, images, fonts, `url()`, `srcset`, `@import`, path rewriting, same-domain limits, depth limits, and size limits; parallel streaming worker pool with main-thread progress callbacks.
- **Multi-Web layouts** — tabs, cards, feeds, drawers, per-site icons/theme colors/extraction selectors/refresh intervals, and shared JS/CSS.
- **Gallery apps** — categorized media, grid/list/timeline views, shuffle/single-loop, sorting, thumbnail bar, overlays, auto-next, and playback memory.
- **App Modifier** — shortcut disguise or real binary clone with manifest/resource patching and re-signing.
</details>
<details>
<summary><b>🔬 Specialized tools & research features</b></summary>
- **Device disguise** is included for technical demonstration and must only be used with informed user consent.
</details>
---
## Agent
DruksCode ships a built-in AI agent (open from **⋮ → Agent**) that can operate the entire app through natural-language conversation. Instead of tapping through menus, you describe what you want and the Agent executes it via a tool-calling loop backed by any LLM you configure in [AI Settings](#tech-stack) — Chat Completions, Anthropic Messages, or OpenAI Responses endpoints, plus Google Gemini and Ollama / LM Studio / VLLM locals.
**How it works:**
1. You send a message (or attach an image for visual context).
2. The LLM reasons and emits `tool_calls`.
3. The Agent executes each tool on-device — read-only tools run immediately; write tools pop a permission dialog first.
4. Results flow back to the LLM, which continues until the task is done or it asks you a clarifying question.
**Up to 57 built-in tools, grouped by domain (the 3 imagery tools load only with an image-capable model):**
| Domain | Examples |
| --- | --- |
| Files | Read, Write, Edit, Delete, List, Glob, Grep project files |
| Apps | List, Get, Create, Update app configurations |
| App lifecycle | Build APK/AAB, Export, Share, Create shortcut, Duplicate, Delete, Move to category |
| Ports & engines | Scan/kill ports, check/select/delete browser engines (WebView, GeckoView) |
| Runtimes | Status, install, and cache-clear for Node.js, PHP, Python, Go, WordPress, Linux env |
| Ad-block | Rule counts, import/remove/enable/disable hosts subscriptions |
| Stats & health | Usage statistics, URL health checks |
| App modifier | List installed apps, clone/rebrand, batch import, export templates |
| Build env & compliance | Initialize Linux build env, install components, Google Play policy checks |
| Modules | List, create, update extension modules |
| Interaction | Ask user questions (multi-select), plan mode (propose → approve → execute), todo tracking |
---
## Module market
DruksCode has a GitHub-backed module market for community JS/CSS extension modules. The catalog is just files in this repository, so contributions use a normal pull-request flow.
```
modules/
├── registry.json # app-facing catalog
├── submissions.json # CI-generated PR / contributor metadata
├── README.md # contributor guide
└── <module-folder>/ # each module
```
The app fetches both `registry.json` and `submissions.json` and only shows modules present in both, keeping the in-app catalog aligned with actually-merged PRs. The submissions file also records every contributor per module, so the catalog shows stacked avatars and a contributors leaderboard. Catalog files and module icons route through a global mirror first, with `raw.githubusercontent.com` and jsDelivr as automatic fallbacks, so the store loads fast everywhere (including mainland China).
- Users open **Extension Modules** and tap the storefront icon.
- Contributors add a folder under `modules/`, update `registry.json`, and open a PR.
- The default client cache is one hour, so merged modules propagate without an app update.
The high-level architecture lives here; the canonical submission rules, field schemas, reviewer checklist, and CI validation details live in [`modules/README.md`](modules/README.md).
The community market carries only JS/CSS extension modules. **Browser extensions (MV3)** are no longer a community catalog — instead the **Browser Extensions** tab searches the Chrome Web Store live: type a keyword, browse results, and install on demand through the existing CRX pipeline. Live search requires a network that can reach Google.
## Documentation
The official documentation site is published at **[shiaho777.github.io/web-to-app](https://shiaho777.github.io/web-to-app/)**, in English and 简体中文:
| Section | URL | Covers |
| --- | --- | --- |
| Guide | [/guide/introduction](https://shiaho777.github.io/web-to-app/guide/introduction) | Getting started, main screen, app types, per-app actions and common config, FAQ |
| Developer | [/developer/](https://shiaho777.github.io/web-to-app/developer/) | Architecture, export pipeline, shell sync, config drift, i18n, change recipes |
| Extensions | [/extensions/](https://shiaho777.github.io/web-to-app/extensions/) | JS/CSS modules, userscripts, Chrome MV3, API reference, publishing |
| 简体中文 | [/zh/](https://shiaho777.github.io/web-to-app/zh/) | Full Chinese mirror of every page above |
## Architecture
- The repository has **three Gradle modules**: `app` (the full builder and host), `shell` (the runtime host embedded into generated APKs), and `clone-host` (host code for app cloning — compiled to a `classes.jar`, converted to DEX via d8, and bundled as an asset for `AppCloner`).
- Runtime code is authored in `app` and synchronized into `shell`, so shared WebView/runtime behavior has one source of truth (`core/shell`, `core/webview`, `core/engine`, `core/extension`, `ui/shell`, etc.).
- The APK builder patches template APKs at the binary AXML/ARSC level, injects config/resources, prunes permissions, and signs with `apksig`. A separate encrypted build path (`EncryptedApkBuilder`) offers resource encryption, shelling, and integrity checks.
- Generated APKs pin `targetSdk = 28` deliberately (via the shell template) — it is what lets them fork+exec native runtimes (Node.js, PHP, Python, Go, WordPress) from app storage, a capability URL-wrapper tools lack. The host app itself targets 35 (antivirus engines flag low-targetSdk builds as legacy malware); SELinux W^X at that level blocks host-side preview of the exec-based runtimes, which degrade with an explicit message — Node.js preview (JNI) and every exported app are unaffected. For Play Store distribution the AAB exporter separately rewrites `targetSdk` to the Play-required level (currently 36). WebView-only app types (Web/HTML/Frontend/Gallery/Media/MultiWeb) can also opt to raise the standalone APK's `targetSdk` (34/35/36) from the APK export section; server-runtime types are gated to 28 because `targetSdk >= 29` enforces W^X and breaks fork+exec of the bundled binaries.
- Server runtimes and the optional GeckoView native libraries (`.so` + `omni.ja`) are downloaded on first use rather than bundled into the base APK; the GeckoView API classes come from a gradle dependency, while the heavy native artifacts are fetched on demand.
- The configuration center is `WebApp` (`data/model/WebApp.kt`) and its `*Config` classes — the single source of truth for all feature settings, carried through a full packaging passthrough chain into the generated APK.
## Tech stack
- Kotlin, Jetpack Compose, Material 3
- Koin for dependency injection
- Room 2.7.2 + KSP for persistence
- OkHttp 4.12.0 + `okhttp-dnsoverhttps`
- `com.android.tools.build:apksig` 8.3.0 for APK signing
- BouncyCastle 1.78.1 for crypto and signing
- `protobuf-javalite` 3.25.5 for AAB metadata
- Firebase Cloud Messaging (BYO Firebase config) for push notifications
- GeckoView as an optional browser engine (native libs downloaded on first use)
- Coil for image/video/GIF loading
- Haze for blur/glassmorphism UI
- Gson for JSON serialization (shell config pipeline)
- AndroidX Security Crypto + DataStore for stored secrets
- Vico Compose-M3 for charts
- ZXing for QR sharing
- Apache Commons Compress + xz for project import and website scraping
- Native C++ via JNI for `node_launcher` and `go_exec_loader`
- Robolectric for unit tests
See [app/build.gradle.kts](app/build.gradle.kts) for the complete dependency list.
## Build from source
Requirements: Android Studio Hedgehog or newer, JDK 17. The Gradle wrapper pins Gradle 9.4.1.
```bash
git clone https://github.com/shiaho777/web-to-app.git
cd drukscode
./gradlew assembleDebug
```
For release builds, configure signing through `local.properties` and `app/build.gradle.kts`.
## Contributing
| Lane | What you do | Guide |
| --- | --- | --- |
| `modules/` | Publish a community module to the in-app market | [modules/README.md](modules/README.md) |
| Issues | Report a bug or request a feature | [GitHub Issues](https://github.com/shiaho777/web-to-app/issues) |
| Code | Fix a bug or build a feature in the Android client | [CONTRIBUTING.md](.github/CONTRIBUTING.md) |
| `AGENTS.md` | Authoritative guide for AI coding agents and deep contributors (architecture, shell sync, export pipeline, config drift, packaging constraints) | [AGENTS.md](AGENTS.md) |
## Contact
Developed by **shiaho**.
| Platform | Link |
| --- | --- |
| GitHub | [github.com/shiaho777/web-to-app](https://github.com/shiaho777/web-to-app) |
| Telegram | [t.me/webtoapp777](https://t.me/webtoapp777) |
| X (Twitter) | [@shiaho777](https://x.com/shiaho777) |
| Bilibili | [b23.tv/8mGDo2N](https://b23.tv/8mGDo2N) |
| QQ Group | 1041130206 |
## License
[The Unlicense](LICENSE).
Advanced features such as device disguise are intended for technical demonstration and must only be used with informed user consent.
## Acknowledgements
[daoxe.com](https://daoxe.com) — AI API relay for personal use.
<p align="center">
<a href="https://www.swiftproxy.net/?ref=shiaho">
<img src=".github/assets/sponsors/swiftproxy-banner-en.png" width="640" alt="Swiftproxy — Residential Proxies from $0.7/GB, ISP Proxies $6/IP, 10% off code: PROXY90" />
</a>
</p>
<p align="center">
<a href="https://www.swiftproxy.net/?ref=shiaho"><b>Sponsored by Swiftproxy</b></a> — Swiftproxy provides 90M+ clean residential IPs across 220+ locations, supporting HTTP(S) and SOCKS5 proxies, flexible targeting, rotating and sticky sessions, and non-expiring traffic. It helps DruksCode users with reliable global access, location-based testing, web automation, and data collection. Free testing is available, with 10% off using code <code>PROXY90</code>.
</p>
---
<div align="center">
**Open source · Built for Android power users · Star to support the project**
</div>

701
app/build.gradle.kts Normal file
View File

@ -0,0 +1,701 @@
import java.util.Properties
import java.util.zip.ZipFile
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.protobuf")
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
val releaseSigningStoreFile = localProperties.getProperty("signing.storeFile")
?.takeIf { it.isNotBlank() }
?.let { rootProject.file(it) }
val hasReleaseSigningConfig = releaseSigningStoreFile?.isFile == true &&
!localProperties.getProperty("signing.storePassword").isNullOrBlank() &&
!localProperties.getProperty("signing.keyAlias").isNullOrBlank() &&
!localProperties.getProperty("signing.keyPassword").isNullOrBlank()
android {
signingConfigs {
if (hasReleaseSigningConfig) {
create("release") {
storeFile = releaseSigningStoreFile
storePassword = localProperties.getProperty("signing.storePassword")
keyAlias = localProperties.getProperty("signing.keyAlias")
keyPassword = localProperties.getProperty("signing.keyPassword")
}
}
}
namespace = "br.com.drukstech.codeapp"
compileSdk = 36
defaultConfig {
applicationId = "br.com.drukstech.codeapp"
minSdk = 23
targetSdk = 35
versionCode = 62
versionName = "2.6.0"
buildConfigField("boolean", "SHELL_RUNTIME_ONLY", "false")
vectorDrawables {
useSupportLibrary = true
}
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
externalNativeBuild {
cmake {
cppFlags += "-std=c++17"
arguments += "-DANDROID_STL=c++_shared"
}
}
}
flavorDimensions += "distribution"
productFlavors {
create("standard") {
// Sideloaded variant (GitHub releases, keeps `br.com.drukstech.codeapp` for the existing
// update path). Identical to gplay in every way except the applicationId —
// both inherit targetSdk 35 from defaultConfig and run the same code paths
// (runtime capability gates key off the installed targetSdk, not the channel).
}
create("gplay") {
// Google Play variant. Only Play's applicationId requirement differs
// (`br.com.drukstech.codeapp` is registered on Google Play by another party); behavior,
// targetSdk and every build rule are shared with the standard flavor.
applicationId = "br.com.drukstech.codeapp.play"
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = if (hasReleaseSigningConfig) {
signingConfigs.getByName("release")
} else {
val allowDebugSigned = (project.findProperty("allowDebugSignedRelease") as? String) == "true"
if (allowDebugSigned) {
signingConfigs.getByName("debug")
} else {
throw GradleException(
"Release build has no valid signing config. Configure signing.storeFile / " +
"signing.storePassword / signing.keyAlias / signing.keyPassword in local.properties " +
"before assembling a release APK for distribution. Refusing to silently sign with " +
"the debug key — a debug-signed release breaks upgrades for existing users " +
"('signatures do not match'). For a throwaway debug-signed build (e.g. CI smoke " +
"build, never distribute it), pass -PallowDebugSignedRelease=true."
)
}
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
splits {
abi {
isEnable = false
}
}
bundle {
language {
enableSplit = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
lint {
disable += "NullSafeMutableLiveData"
disable += "ExpiredTargetSdkVersion"
disable += "ExpiringTargetSdkVersion"
disable += "OldTargetApi"
abortOnError = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
// Gecko omni.ja is downloaded on demand, never bundle it in host.
excludes += "assets/omni.ja"
excludes += "**/omni.ja"
// BouncyCastle post-quantum experimental blobs (~1.2M), unused.
excludes += "**/org/bouncycastle/pqc/**"
// Django gettext sources (~5.9M raw): the Python runtime only reads
// compiled .mo files, .po never ships in a working install.
excludes += "**/*.po"
}
jniLibs {
useLegacyPackaging = true
excludes += "**/libxul.so"
excludes += "**/libmozglue.so"
excludes += "**/libgeckoffi.so"
excludes += "**/libmozavutil.so"
excludes += "**/libmozavcodec.so"
excludes += "**/libgkcodecs.so"
excludes += "**/libminidump_analyzer.so"
excludes += "**/libnss3.so"
excludes += "**/libfreebl3.so"
excludes += "**/libsoftokn3.so"
excludes += "**/liblgpllibs.so"
excludes += "**/libplugin-container.so"
excludes += "**/libcrypto_engine.so"
// Cronet natives are downloaded on demand / injected per-export, never bundled.
excludes += "**/libcronet*.so"
}
}
androidResources {
// Keep "" semantics (include dot-dirs like .pypackages) while dropping
// Django gettext sources (*.po, runtime reads .mo only) and Gecko's
// omni.ja (downloaded on demand). packaging.resources.excludes does
// NOT cover assets, hence aapt-level filtering (verified by APK audit).
ignoreAssetsPattern = "*.po:*.ja"
localeFilters += listOf("zh", "en", "ar")
}
}
val shellTemplateOutput = project(":shell").layout.buildDirectory.file("outputs/apk/release/shell-release.apk")
val skipShellTemplateSync = providers.gradleProperty("skipShellTemplateSync").map(String::toBoolean).orElse(false)
tasks.register<Copy>("syncShellTemplateApk") {
description = "Builds the dedicated shell template APK and copies it into the app assets."
group = "build"
dependsOn(":shell:assembleRelease")
from(shellTemplateOutput)
into(file("src/main/assets/template"))
rename { "webview_shell.apk" }
}
tasks.matching { it.name == "preBuild" }.configureEach {
if (!skipShellTemplateSync.get()) {
dependsOn("syncShellTemplateApk")
}
}
val cloneHostAar = rootProject.layout.projectDirectory.file("clone-host/build/outputs/aar/clone-host-release.aar")
val androidSdkDir = android.sdkDirectory
tasks.register<Copy>("syncCloneHostDex") {
description = "Extracts classes.jar from clone-host AAR and converts it to a DEX asset for APK cloning."
group = "build"
dependsOn(":clone-host:assembleRelease")
enabled = false
val dexOutputDir = layout.projectDirectory.dir("src/main/assets/clone_host").asFile
val intermediateDir = layout.buildDirectory.dir("intermediates/clone-host-extract")
val sdkDir = androidSdkDir
doFirst {
intermediateDir.get().asFile.mkdirs()
dexOutputDir.mkdirs()
}
from(cloneHostAar)
into(intermediateDir)
rename { "clone-host.aar" }
doLast {
val aarFile = intermediateDir.get().file("clone-host.aar").asFile
if (!aarFile.exists()) {
throw GradleException("clone-host AAR not found at ${aarFile.absolutePath}")
}
val classesJar = intermediateDir.get().file("classes.jar").asFile
ZipFile(aarFile).use { zip ->
val entry = zip.getEntry("classes.jar")
?: throw GradleException("classes.jar not found in clone-host AAR")
zip.getInputStream(entry).use { input ->
classesJar.outputStream().use { output -> input.copyTo(output) }
}
}
val androidJar = sdkDir.resolve("platforms/android-36/android.jar")
if (!androidJar.exists()) {
throw GradleException("android.jar not found at ${androidJar.absolutePath}")
}
val d8 = sdkDir.resolve("build-tools")
.listFiles()?.maxByOrNull { it.name }
?.resolve("d8")
?: throw GradleException("d8 not found in build-tools")
dexOutputDir.mkdirs()
val process = ProcessBuilder(
d8.absolutePath,
"--release",
"--min-api", "23",
"--lib", androidJar.absolutePath,
"--output", dexOutputDir.absolutePath,
classesJar.absolutePath
).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText()
if (process.waitFor() != 0) {
throw GradleException("d8 failed to dex clone-host: $output")
}
}
}
tasks.matching { it.name == "preBuild" }.configureEach {
dependsOn("syncCloneHostDex")
}
tasks.register("testClasses") {
group = "verification"
description = "Compatibility alias for JVM-style test class compilation in the Android app module."
dependsOn("compileDebugUnitTestSources")
}
tasks.register("unitTestClasses") {
group = "verification"
description = "Compatibility alias for Android unit test class compilation in the Android app module."
dependsOn("compileDebugUnitTestSources")
}
abstract class SyncNativeExecutableJniLibsTask : DefaultTask() {
@get:Input
abstract val variantName: org.gradle.api.provider.Property<String>
@get:Input
abstract val buildTypeName: org.gradle.api.provider.Property<String>
@get:Input
abstract val executableName: org.gradle.api.provider.Property<String>
@get:Input
abstract val packagedLibraryName: org.gradle.api.provider.Property<String>
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val cxxRoot: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun sync() {
val cxxRootDir = cxxRoot.asFile.get()
if (!cxxRootDir.exists()) {
throw GradleException("CXX output not found for ${variantName.get()}: ${cxxRootDir.absolutePath}")
}
val executableTargets = cxxRootDir.walkTopDown()
.filter { file ->
file.isFile &&
file.name == executableName.get() &&
file.parentFile?.parentFile?.name == "obj"
}
.toList()
if (executableTargets.isEmpty()) {
throw GradleException("${executableName.get()} artifacts not found for ${variantName.get()} under ${cxxRootDir.absolutePath}")
}
val outputRoot = outputDir.get().asFile
outputRoot.deleteRecursively()
outputRoot.mkdirs()
executableTargets.forEach { binary ->
val abi = binary.parentFile.name
val destFile = outputRoot.resolve("$abi/${packagedLibraryName.get()}")
destFile.parentFile.mkdirs()
binary.copyTo(destFile, overwrite = true)
destFile.setExecutable(true, false)
}
}
}
/**
* Builds the patched musl dynamic linker (libmusl-linker.so) into generated
* jniLibs for arm64-v8a / x86_64. The linker carries the W^X exec bridge:
* app_data library fds that SELinux refuses to exec-map are copied into an
* executable memfd and mapped from there, restoring Python host previews on
* targetSdk>=29 builds. Source provenance: upstream musl tarball +
* scripts/patches/musl-1.2.5-dkc-exec-bridge.patch +
* scripts/musl-bridge/dkc_mulxc3.c (NDK compiler-rt drops x86_80 routines).
*/
abstract class BuildMuslBridgeTask : DefaultTask() {
@get:Input
abstract val muslVersion: org.gradle.api.provider.Property<String>
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val patchFile: RegularFileProperty
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val x80ShimFile: RegularFileProperty
@get:InputDirectory
@get:PathSensitive(PathSensitivity.NONE)
abstract val ndkToolchainDir: DirectoryProperty
@get:Internal
abstract val workDir: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
private fun run(cmd: List<String>, cwd: File? = null, env: Map<String, String> = emptyMap()) {
val proc = ProcessBuilder(cmd).apply {
if (cwd != null) directory(cwd)
environment().putAll(env)
redirectErrorStream(true)
}.start()
val out = proc.inputStream.bufferedReader().readText()
val code = proc.waitFor()
if (code != 0) throw GradleException("command failed ($code): ${cmd.joinToString(" ")}\n$out")
}
@TaskAction
fun build() {
val tc = ndkToolchainDir.get().asFile
if (!File(tc, "bin/clang").isFile) {
throw GradleException("NDK toolchain not found at $tc (set NDKVersion or install the NDK)")
}
val libccDir = File(tc, "lib/clang")
.listFiles { f -> f.isDirectory }?.map { File(it, "lib/linux") }.orEmpty()
fun libcc(name: String): String =
libccDir.firstOrNull { File(it, name).isFile }?.resolve(name)?.absolutePath
?: throw GradleException("compiler-rt $name not found under $tc")
val work = workDir.get().asFile.apply { mkdirs() }
val version = muslVersion.get()
val tarball = File(work, "musl-$version.tar.gz")
if (!tarball.isFile) {
run(
listOf(
"curl", "-fsSL", "--max-time", "180", "-o", tarball.absolutePath,
"https://musl.libc.org/releases/musl-$version.tar.gz"
)
)
}
val src = File(work, "musl-$version")
src.deleteRecursively()
run(listOf("tar", "xzf", tarball.absolutePath, "-C", work.absolutePath))
run(listOf("patch", "-p1", "-d", src.absolutePath, "--input", patchFile.get().asFile.absolutePath))
File(src, "src/math/x86_64").also { it.mkdirs() }
.resolve(x80ShimFile.get().asFile.name).run {
x80ShimFile.get().asFile.copyTo(this, overwrite = true)
}
val clang = File(tc, "bin/clang").absolutePath
val strip = File(tc, "bin/llvm-strip").absolutePath
val jobs = Runtime.getRuntime().availableProcessors().toString()
val targets = listOf(
"arm64-v8a" to ("aarch64-linux-musl" to "libclang_rt.builtins-aarch64-android.a"),
"x86_64" to ("x86_64-linux-musl" to "libclang_rt.builtins-x86_64-android.a")
)
val out = outputDir.get().asFile
out.deleteRecursively()
for ((abi, target) in targets) {
val (triple, rt) = target
val buildDir = File(work, "build-$abi").apply { deleteRecursively(); mkdirs() }
val libccPath = libcc(rt)
val configureEnv = mapOf(
"CC" to "$clang --target=$triple",
"CROSS_COMPILE" to "${File(tc, "bin/llvm-").absolutePath}",
"CFLAGS" to "-O2",
"LDFLAGS" to "-Wl,-z,max-page-size=16384"
)
run(listOf("../musl-$version/configure", "--target=$triple"), cwd = buildDir, env = configureEnv)
run(listOf("make", "-j$jobs", "LIBCC=$libccPath"), cwd = buildDir, env = configureEnv)
val dest = File(out, "$abi/libmusl-linker.so")
dest.parentFile.mkdirs()
run(listOf(strip, File(buildDir, "lib/libc.so").absolutePath, "-o", dest.absolutePath))
dest.setExecutable(true, false)
}
}
}
val muslBridgeTask = tasks.register<BuildMuslBridgeTask>("buildMuslBridge") {
group = "build"
description = "Builds the patched musl dynamic linker (libmusl-linker.so) with the W^X exec bridge into generated jniLibs."
muslVersion.set("1.2.5")
patchFile.set(rootProject.file("scripts/patches/musl-1.2.5-dkc-exec-bridge.patch"))
x80ShimFile.set(rootProject.file("scripts/musl-bridge/dkc_mulxc3.c"))
val hostTag = if (System.getProperty("os.name").lowercase().contains("mac")) "darwin-x86_64" else "linux-x86_64"
ndkToolchainDir.set(File(android.ndkDirectory, "toolchains/llvm/prebuilt/$hostTag"))
workDir.set(layout.buildDirectory.dir("musl-bridge"))
outputDir.set(layout.buildDirectory.dir("generated/jniLibs/muslBridge"))
}
androidComponents {
onVariants { variant ->
val capName = variant.name.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
val variantBuildTypeName = variant.buildType ?: "release"
val cxxBuildType = if (variantBuildTypeName.equals("debug", ignoreCase = true)) "Debug" else "RelWithDebInfo"
val nativeBuildTaskName = "buildCMake$cxxBuildType"
tasks.matching { it.name == "merge${capName}NativeLibs" }.configureEach {
dependsOn(muslBridgeTask)
}
variant.sources.jniLibs?.addGeneratedSourceDirectory(
muslBridgeTask,
BuildMuslBridgeTask::outputDir
)
val syncNodeLauncherTask = tasks.register<SyncNativeExecutableJniLibsTask>("syncNodeLauncherJniLibs$capName") {
group = "build"
description = "Copies ABI-specific node launcher executables into generated jniLibs for ${variant.name}."
variantName.set(variant.name)
buildTypeName.set(variantBuildTypeName)
executableName.set("node_launcher")
packagedLibraryName.set("libnode_launcher.so")
cxxRoot.set(layout.buildDirectory.dir("intermediates/cxx/$cxxBuildType"))
outputDir.set(layout.buildDirectory.dir("generated/jniLibs/nodeLauncher/${variant.name}"))
dependsOn(nativeBuildTaskName)
}
val syncGoLoaderTask = tasks.register<SyncNativeExecutableJniLibsTask>("syncGoExecLoaderJniLibs$capName") {
group = "build"
description = "Copies ABI-specific Go exec loader executables into generated jniLibs for ${variant.name}."
variantName.set(variant.name)
buildTypeName.set(variantBuildTypeName)
executableName.set("go_exec_loader")
packagedLibraryName.set("libgo_exec_loader.so")
cxxRoot.set(layout.buildDirectory.dir("intermediates/cxx/$cxxBuildType"))
outputDir.set(layout.buildDirectory.dir("generated/jniLibs/goExecLoader/${variant.name}"))
dependsOn(nativeBuildTaskName)
}
tasks.matching { it.name == "merge${capName}NativeLibs" }.configureEach {
dependsOn(syncNodeLauncherTask)
dependsOn(syncGoLoaderTask)
}
variant.sources.jniLibs?.addGeneratedSourceDirectory(
syncNodeLauncherTask,
SyncNativeExecutableJniLibsTask::outputDir
)
variant.sources.jniLibs?.addGeneratedSourceDirectory(
syncGoLoaderTask,
SyncNativeExecutableJniLibsTask::outputDir
)
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.25.5"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.google.protobuf:protobuf-javalite:3.25.5")
implementation("com.google.android.material:material:1.10.0")
implementation("androidx.activity:activity-compose:1.8.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
implementation(platform("androidx.compose:compose-bom:2026.06.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.5.0-alpha17")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation("androidx.navigation:navigation-compose:2.7.5")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.room:room-runtime:2.7.2")
implementation("androidx.room:room-ktx:2.7.2")
ksp("androidx.room:room-compiler:2.7.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-video:2.5.0")
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-messaging")
implementation("org.bouncycastle:bcpkix-jdk15to18:1.78.1")
implementation("org.bouncycastle:bcprov-jdk15to18:1.78.1")
implementation("io.insert-koin:koin-android:3.5.3")
implementation("io.insert-koin:koin-androidx-compose:3.5.3")
implementation("androidx.webkit:webkit:1.9.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.apache.commons:commons-compress:1.26.0")
implementation("org.tukaani:xz:1.9")
implementation("com.android.tools.build:apksig:8.3.0")
implementation("org.mozilla.geckoview:geckoview-arm64-v8a:142.0.20250827004350")
// Forced HTTP/3 (issue #721): Chromium's own network stack as the MITM bridge's
// upstream leg. Java classes ship in the APK; libcronet is NEVER bundled — the host
// downloads it on demand (filesDir/cronet_deps) and ApkBuilder injects it into
// exported APKs only when the app enables 强制 HTTP/3 (libnode/GeckoView precedent).
// Version must match CronetDependencyManager.CRONET_ARTIFACT_VERSION.
implementation("org.chromium.net:cronet-embedded:143.7445.0")
implementation("com.google.zxing:core:3.5.2")
// Native Google sign-in through the Jetpack Credential Manager (NativeBridge).
implementation("androidx.credentials:credentials:1.5.0")
implementation("androidx.credentials:credentials-play-services-auth:1.5.0")
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.media:media:1.7.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("com.google.truth:truth:1.1.5")
testImplementation("org.robolectric:robolectric:4.12.2")
testImplementation("androidx.test:core:1.5.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
/**
* Resolves a Python 3 interpreter command for the current platform. Windows usually exposes
* Python as `python` (or the `py -3` launcher) rather than `python3`, so a hardcoded
* `python3` fails there. Each candidate is verified to actually be Python 3 before use.
* NOTE: intentionally duplicated in the root build.gradle.kts (this project has no buildSrc).
*/
fun resolvePython3Command(): List<String> {
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
val candidates: List<List<String>> = if (isWindows) {
listOf(listOf("python3"), listOf("python"), listOf("py", "-3"))
} else {
listOf(listOf("python3"), listOf("python"))
}
for (candidate in candidates) {
try {
val probe = ProcessBuilder(candidate + "--version").redirectErrorStream(true).start()
val output = probe.inputStream.bufferedReader().readText()
if (probe.waitFor() == 0 && output.contains("Python 3")) {
return candidate
}
} catch (_: Exception) {
// Candidate unavailable; try the next one.
}
}
return listOf("python3")
}
tasks.register("checkConfigFieldDrift") {
group = "verification"
description = "Detect field-name drift between ApkConfig payload keys and ShellConfig @SerializedName (static guard for preview/export config consistency)"
val script = rootProject.file("scripts/check_config_field_drift.py")
val payloadFile = file("src/main/java/br/com/drukstech/codeapp/core/apkbuilder/ApkConfigJsonFactory.kt")
val apkConfigFile = file("src/main/java/br/com/drukstech/codeapp/core/apkbuilder/ApkConfig.kt")
val shellConfigFile = file("src/main/java/br/com/drukstech/codeapp/core/shell/ShellModeManager.kt")
val allowlist = rootProject.file("scripts/config_field_drift_allowlist.json")
// Configuration-cache safe: capture values at configuration time; the doLast action
// must not reach through Project.
val rootDir = rootProject.projectDir
inputs.files(script, payloadFile, apkConfigFile, shellConfigFile, allowlist)
outputs.upToDateWhen { false }
doLast {
val pb = ProcessBuilder(resolvePython3Command() + script.absolutePath)
pb.directory(rootDir)
pb.redirectErrorStream(true)
val proc = pb.start()
val log = proc.inputStream.bufferedReader().readText()
val code = proc.waitFor()
logger.lifecycle(log.trim())
if (code != 0) {
throw GradleException("checkConfigFieldDrift failed ($code)")
}
}
}

315
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,315 @@
# DruksCode ProGuard Rules
#
# 策略:启用代码收缩(移除未使用代码)+ 资源压缩(移除未使用资源),
# 禁用混淆与激进优化。这是经过线上事故反复验证后的"稳定优先"配置,
# 减包体积仍能保留 30%+ 收益,但完全避免反射/泛型/ServiceLoader/JNI 类陷阱。
#
# 收益与权衡:
# - shrink: 保留,移除未使用类/方法(约 25% 体积削减)
# - obfuscate: 关闭(开源项目无意义,反而引入 Gson/Koin/反射 bug)
# - optimize: 关闭部分激进优化(保留默认收缩,避免内联引发的 NPE)
#
# 出问题时排查方法:
# 1. ./gradlew :app:assembleRelease -PandroidProguardPrintUsage=true
# 会在 build/outputs/mapping/release/usage.txt 写出被 R8 删除的所有内容
# 2. ./gradlew :app:assembleRelease -PandroidProguardPrintSeeds=true
# 会写出被显式 keep 的所有内容
# 3. 把崩溃栈对照 build/outputs/mapping/release/mapping.txt 反推
-dontobfuscate
# 关掉一组容易破坏反射 / Compose / 协程语义的优化模式
# 这些是 R8 历史上反复出现 bug 的优化通道,关掉它们体积代价可忽略
-optimizations !class/merging/*,!field/*,!method/marking/static,!method/inlining/*,!code/allocation/variable
-optimizationpasses 1
# ============================================================
# 调试 / 崩溃堆栈可读
# ============================================================
-keepattributes SourceFile,LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes *Annotation*
-keepattributes EnclosingMethod
-keepattributes InnerClasses
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations
-keepattributes RuntimeInvisibleAnnotations,RuntimeInvisibleParameterAnnotations
-keepattributes RuntimeVisibleTypeAnnotations,RuntimeInvisibleTypeAnnotations
-keepattributes AnnotationDefault
-keepattributes MethodParameters
# 让崩溃栈打印 R8 重命名前的类名 / 行号
-renamesourcefileattribute SourceFile
# ============================================================
# Android 组件 — Manifest 引用,必须保名
# ============================================================
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.accessibilityservice.AccessibilityService
-keep public class * extends androidx.work.ListenableWorker
-keep public class * extends android.app.Application
-keep public class * extends androidx.fragment.app.Fragment
-keep public class * extends androidx.lifecycle.ViewModel
-keep public class * extends androidx.lifecycle.AndroidViewModel
# ============================================================
# 通用反射 — Parcelable / Serializable / enum / native
# ============================================================
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static java.io.ObjectStreamField[] serialPersistentFields;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
# JNI native 方法
-keepclasseswithmembernames class * {
native <methods>;
}
# WebView @JavascriptInterface
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# View 的 XML inflate / setOnClick 反射回调
-keepclassmembers class * extends android.view.View {
void set*(***);
*** get*();
}
-keepclassmembers class * extends android.app.Activity {
public void *(android.view.View);
}
# ============================================================
# Kotlin
# ============================================================
-keep class kotlin.Metadata { *; }
-keep class kotlin.reflect.** { *; }
-keepclassmembers class **$WhenMappings { <fields>; }
-keepclassmembers class kotlin.Lazy { public <methods>; }
-keep class kotlin.jvm.internal.DefaultConstructorMarker { *; }
-keepclassmembers class kotlin.coroutines.jvm.internal.** { *; }
-dontwarn kotlin.**
-dontwarn kotlinx.**
# Kotlin Coroutines — ServiceLoader 加载 Dispatchers.Main
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory
-keepnames class kotlinx.coroutines.android.AndroidDispatcherFactory
-keep class kotlinx.coroutines.android.AndroidDispatcherFactory { *; }
-keep class kotlinx.coroutines.android.AndroidExceptionPreHandler { *; }
-keep class kotlinx.coroutines.internal.MainDispatcherFactory { *; }
-keep class kotlinx.coroutines.CoroutineExceptionHandler { *; }
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
# Coroutines 调试 / 异常 hooks
-keepnames class kotlinx.coroutines.flow.** { *; }
# ============================================================
# 项目自身代码 — 全部保留(开源 + 重反射)
# ============================================================
-keep class br.com.drukstech.codeapp.** { *; }
-keepclassmembers class br.com.drukstech.codeapp.** { *; }
-keep enum br.com.drukstech.codeapp.** { *; }
-keep interface br.com.drukstech.codeapp.** { *; }
# data class 的合成构造器(含默认参数)— Gson 反序列化必须
-keepclassmembers class br.com.drukstech.codeapp.data.model.** {
<init>(...);
}
# ============================================================
# Room — KSP 生成的 _Impl 类已经在 br.com.drukstech.codeapp.** 范围内
# ============================================================
-keep class * extends androidx.room.RoomDatabase { *; }
-keep @androidx.room.Entity class *
-keep @androidx.room.Dao class *
-keepclassmembers class * {
@androidx.room.* <methods>;
}
-dontwarn androidx.room.paging.**
# ============================================================
# Gson — 序列化 / 反序列化通过反射
# ============================================================
-keep class sun.misc.Unsafe { *; }
-keep class com.google.gson.** { *; }
-keep class * implements com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
-keep class * implements com.google.gson.InstanceCreator
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
@com.google.gson.annotations.JsonAdapter <fields>;
@com.google.gson.annotations.Expose <fields>;
}
# Gson TypeToken — R8 full mode 会把 `object : TypeToken<List<...>>() {}`
# 匿名子类的 Signature 属性剥掉,运行时抛 IllegalStateException。
# 参考: https://github.com/google/gson/blob/main/Troubleshooting.md#r8
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# ============================================================
# Koin DI — 大量反射 + ServiceLoader
# ============================================================
-keep class org.koin.** { *; }
-keepclassmembers class * {
public <init>(org.koin.core.scope.Scope);
}
-keepclassmembers class * extends org.koin.core.module.Module { *; }
-dontwarn org.koin.**
# ============================================================
# OkHttp / Okio — Platform 反射检测 OS 安全栈
# ============================================================
-keep class okhttp3.internal.platform.** { *; }
-keep class okhttp3.internal.publicsuffix.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
# ============================================================
# Coil — ServiceLoader 加载 fetcher / decoder / mapper
# ============================================================
-keep class coil.util.** { *; }
-keep class coil.fetch.** { *; }
-keep class coil.decode.** { *; }
-keep class coil.map.** { *; }
-dontwarn coil.**
# ============================================================
# apksig — 反射读取 @Asn1Class/@Asn1Field 注解序列化 PKCS#7
# ============================================================
-keep class com.android.apksig.** { *; }
-keepclassmembers class com.android.apksig.** { *; }
-keep @com.android.apksig.internal.asn1.Asn1Class class *
-keepclassmembers class * {
@com.android.apksig.internal.asn1.Asn1Field *;
}
-dontwarn com.android.apksig.**
-keep class com.android.aapt.** { *; }
-keepclassmembers class com.android.aapt.** { *; }
-keep class com.android.bundle.** { *; }
-keepclassmembers class com.android.bundle.** { *; }
# ============================================================
# GeckoView — 大量 JNI / 注解反射
# ============================================================
-keep class org.mozilla.geckoview.** { *; }
-keep class org.mozilla.gecko.** { *; }
-keepclassmembers class * extends org.mozilla.geckoview.GeckoSession$* { *; }
-dontwarn org.mozilla.geckoview.**
# ============================================================
# ZXing — 反射查找编码格式 (仅 core,embedded 已移除)
# ============================================================
-keep class com.google.zxing.** { *; }
-dontwarn com.google.zxing.**
# BillingClient — AIDL stub
# ============================================================
-keep class com.android.vending.billing.** { *; }
-keep class com.android.billingclient.** { *; }
-keep class com.google.android.gms.internal.** { *; }
-dontwarn com.android.billingclient.**
# ============================================================
# Credentials API + GoogleId — 反射解析 ID Token
# ============================================================
-keep class androidx.credentials.** { *; }
-keep class com.google.android.libraries.identity.** { *; }
-keep class com.google.android.gms.auth.api.identity.** { *; }
-dontwarn androidx.credentials.**
-dontwarn com.google.android.libraries.identity.**
# ============================================================
# DataStore Preferences
# ============================================================
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# ============================================================
# Security crypto (alpha — 可能有反射)
# ============================================================
-keep class androidx.security.crypto.** { *; }
-keep class com.google.crypto.tink.** { *; }
-dontwarn androidx.security.crypto.**
-dontwarn com.google.crypto.tink.**
# ============================================================
# Compress / xz — ServiceLoader 加载格式
# ============================================================
-keep class org.apache.commons.compress.compressors.FileNameUtil { *; }
-keep class org.apache.commons.compress.archivers.** { *; }
-keep class org.tukaani.xz.** { *; }
-dontwarn org.apache.commons.compress.**
-dontwarn org.tukaani.xz.**
-dontwarn org.brotli.dec.**
-dontwarn org.yaml.snakeyaml.**
# ============================================================
# Compose Runtime — 已有 consumer rules,仅压制 warn
# ============================================================
-dontwarn androidx.compose.**
# ============================================================
# Material / AppCompat — 已有 consumer rules,仅兜底
# ============================================================
-dontwarn com.google.android.material.**
# ============================================================
# Custom Tabs (browser)
# ============================================================
-keep class androidx.browser.** { *; }
-dontwarn androidx.browser.**
# ============================================================
# Haze (背景模糊)
# ============================================================
-keep class dev.chrisbanes.haze.** { *; }
-dontwarn dev.chrisbanes.haze.**
# Node.js JNI output bridge (R8 may rename onOutput otherwise)
-keep class br.com.drukstech.codeapp.core.nodejs.NodeBridge { *; }
-keep class br.com.drukstech.codeapp.core.nodejs.NodeJniOutputBridge {
<init>(...);
public void onOutput(java.lang.String, boolean);
}
# Firebase / FCM
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.firebase.**
-dontwarn com.google.android.gms.**
# ============================================================
# Cronet (forced HTTP/3 upstream)
# ============================================================
# Cronet's native code reaches its Java classes via JNI and ServiceLoader
# (org.chromium.net.impl.** / org.chromium.base.**); R8 must not rename or
# strip them. The AAR ships consumer rules, this is the belt-and-braces copy.
-keep class org.chromium.net.** { *; }
-keep class org.chromium.base.** { *; }
-keep class org.chromium.components.** { *; }
-dontwarn org.chromium.**
-keepdirectories META-INF/services/**

View File

@ -0,0 +1,563 @@
{
"formatVersion": 1,
"database": {
"version": 45,
"identityHash": "ea6b40911e9117204ec107ff693031d7",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ea6b40911e9117204ec107ff693031d7')"
]
}
}

View File

@ -0,0 +1,573 @@
{
"formatVersion": 1,
"database": {
"version": 36,
"identityHash": "edf2cccde4bb442d6b044c0811e03272",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodes` TEXT NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `forcedRunConfig` TEXT, `blackTechConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodes",
"columnName": "activationCodes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "forcedRunConfig",
"columnName": "forcedRunConfig",
"affinity": "TEXT"
},
{
"fieldPath": "blackTechConfig",
"columnName": "blackTechConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'edf2cccde4bb442d6b044c0811e03272')"
]
}
}

View File

@ -0,0 +1,567 @@
{
"formatVersion": 1,
"database": {
"version": 37,
"identityHash": "643c6f9480593253e00702649c9f1737",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `forcedRunConfig` TEXT, `blackTechConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "forcedRunConfig",
"columnName": "forcedRunConfig",
"affinity": "TEXT"
},
{
"fieldPath": "blackTechConfig",
"columnName": "blackTechConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '643c6f9480593253e00702649c9f1737')"
]
}
}

View File

@ -0,0 +1,578 @@
{
"formatVersion": 1,
"database": {
"version": 38,
"identityHash": "983cf11423592851e73dc30dd6bb36e3",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `forcedRunConfig` TEXT, `blackTechConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "forcedRunConfig",
"columnName": "forcedRunConfig",
"affinity": "TEXT"
},
{
"fieldPath": "blackTechConfig",
"columnName": "blackTechConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '983cf11423592851e73dc30dd6bb36e3')"
]
}
}

View File

@ -0,0 +1,578 @@
{
"formatVersion": 1,
"database": {
"version": 39,
"identityHash": "983cf11423592851e73dc30dd6bb36e3",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `forcedRunConfig` TEXT, `blackTechConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "forcedRunConfig",
"columnName": "forcedRunConfig",
"affinity": "TEXT"
},
{
"fieldPath": "blackTechConfig",
"columnName": "blackTechConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '983cf11423592851e73dc30dd6bb36e3')"
]
}
}

View File

@ -0,0 +1,578 @@
{
"formatVersion": 1,
"database": {
"version": 40,
"identityHash": "983cf11423592851e73dc30dd6bb36e3",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `forcedRunConfig` TEXT, `blackTechConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "forcedRunConfig",
"columnName": "forcedRunConfig",
"affinity": "TEXT"
},
{
"fieldPath": "blackTechConfig",
"columnName": "blackTechConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '983cf11423592851e73dc30dd6bb36e3')"
]
}
}

View File

@ -0,0 +1,573 @@
{
"formatVersion": 1,
"database": {
"version": 41,
"identityHash": "d5e920fa16e9de9b732ae1a50957f218",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `forcedRunConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "forcedRunConfig",
"columnName": "forcedRunConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5e920fa16e9de9b732ae1a50957f218')"
]
}
}

View File

@ -0,0 +1,568 @@
{
"formatVersion": 1,
"database": {
"version": 42,
"identityHash": "c7e4c9285c607540b9f15d79ad87c359",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `disguiseConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "disguiseConfig",
"columnName": "disguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c7e4c9285c607540b9f15d79ad87c359')"
]
}
}

View File

@ -0,0 +1,563 @@
{
"formatVersion": 1,
"database": {
"version": 43,
"identityHash": "ea6b40911e9117204ec107ff693031d7",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ea6b40911e9117204ec107ff693031d7')"
]
}
}

View File

@ -0,0 +1,568 @@
{
"formatVersion": 1,
"database": {
"version": 44,
"identityHash": "0a034258a95fdd6ab5f7d94147e9de43",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `appLockConfig` TEXT, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appLockConfig",
"columnName": "appLockConfig",
"affinity": "TEXT"
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0a034258a95fdd6ab5f7d94147e9de43')"
]
}
}

View File

@ -0,0 +1,563 @@
{
"formatVersion": 1,
"database": {
"version": 45,
"identityHash": "ea6b40911e9117204ec107ff693031d7",
"entities": [
{
"tableName": "web_apps",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `iconPath` TEXT, `packageName` TEXT, `appType` TEXT NOT NULL, `mediaConfig` TEXT, `galleryConfig` TEXT, `htmlConfig` TEXT, `wordpressConfig` TEXT, `nodejsConfig` TEXT, `phpAppConfig` TEXT, `pythonAppConfig` TEXT, `goAppConfig` TEXT, `multiWebConfig` TEXT, `activationEnabled` INTEGER NOT NULL, `activationCodeList` TEXT NOT NULL, `activationRequireEveryTime` INTEGER NOT NULL, `activationRemoteConfig` TEXT, `isActivated` INTEGER NOT NULL, `adsEnabled` INTEGER NOT NULL, `adConfig` TEXT, `announcementEnabled` INTEGER NOT NULL, `announcement` TEXT, `adBlockEnabled` INTEGER NOT NULL, `adBlockRules` TEXT NOT NULL, `adBlockSubscriptions` TEXT NOT NULL, `webViewConfig` TEXT NOT NULL, `splashEnabled` INTEGER NOT NULL, `splashConfig` TEXT, `bgmEnabled` INTEGER NOT NULL, `bgmConfig` TEXT, `apkExportConfig` TEXT, `themeType` TEXT NOT NULL, `translateEnabled` INTEGER NOT NULL, `translateConfig` TEXT, `extensionEnabled` INTEGER NOT NULL, `extensionModuleIds` TEXT NOT NULL, `extensionFabIcon` TEXT, `autoStartConfig` TEXT, `browserDisguiseConfig` TEXT, `deviceDisguiseConfig` TEXT, `activationDialogConfig` TEXT, `categoryId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "iconPath",
"columnName": "iconPath",
"affinity": "TEXT"
},
{
"fieldPath": "packageName",
"columnName": "packageName",
"affinity": "TEXT"
},
{
"fieldPath": "appType",
"columnName": "appType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaConfig",
"columnName": "mediaConfig",
"affinity": "TEXT"
},
{
"fieldPath": "galleryConfig",
"columnName": "galleryConfig",
"affinity": "TEXT"
},
{
"fieldPath": "htmlConfig",
"columnName": "htmlConfig",
"affinity": "TEXT"
},
{
"fieldPath": "wordpressConfig",
"columnName": "wordpressConfig",
"affinity": "TEXT"
},
{
"fieldPath": "nodejsConfig",
"columnName": "nodejsConfig",
"affinity": "TEXT"
},
{
"fieldPath": "phpAppConfig",
"columnName": "phpAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "pythonAppConfig",
"columnName": "pythonAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "goAppConfig",
"columnName": "goAppConfig",
"affinity": "TEXT"
},
{
"fieldPath": "multiWebConfig",
"columnName": "multiWebConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationEnabled",
"columnName": "activationEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationCodeList",
"columnName": "activationCodeList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "activationRequireEveryTime",
"columnName": "activationRequireEveryTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activationRemoteConfig",
"columnName": "activationRemoteConfig",
"affinity": "TEXT"
},
{
"fieldPath": "isActivated",
"columnName": "isActivated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adsEnabled",
"columnName": "adsEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adConfig",
"columnName": "adConfig",
"affinity": "TEXT"
},
{
"fieldPath": "announcementEnabled",
"columnName": "announcementEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "announcement",
"columnName": "announcement",
"affinity": "TEXT"
},
{
"fieldPath": "adBlockEnabled",
"columnName": "adBlockEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "adBlockRules",
"columnName": "adBlockRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "adBlockSubscriptions",
"columnName": "adBlockSubscriptions",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "webViewConfig",
"columnName": "webViewConfig",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "splashEnabled",
"columnName": "splashEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "splashConfig",
"columnName": "splashConfig",
"affinity": "TEXT"
},
{
"fieldPath": "bgmEnabled",
"columnName": "bgmEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bgmConfig",
"columnName": "bgmConfig",
"affinity": "TEXT"
},
{
"fieldPath": "apkExportConfig",
"columnName": "apkExportConfig",
"affinity": "TEXT"
},
{
"fieldPath": "themeType",
"columnName": "themeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "translateEnabled",
"columnName": "translateEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "translateConfig",
"columnName": "translateConfig",
"affinity": "TEXT"
},
{
"fieldPath": "extensionEnabled",
"columnName": "extensionEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "extensionModuleIds",
"columnName": "extensionModuleIds",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "extensionFabIcon",
"columnName": "extensionFabIcon",
"affinity": "TEXT"
},
{
"fieldPath": "autoStartConfig",
"columnName": "autoStartConfig",
"affinity": "TEXT"
},
{
"fieldPath": "browserDisguiseConfig",
"columnName": "browserDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "deviceDisguiseConfig",
"columnName": "deviceDisguiseConfig",
"affinity": "TEXT"
},
{
"fieldPath": "activationDialogConfig",
"columnName": "activationDialogConfig",
"affinity": "TEXT"
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER"
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_web_apps_updatedAt",
"unique": false,
"columnNames": [
"updatedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
},
{
"name": "index_web_apps_categoryId",
"unique": false,
"columnNames": [
"categoryId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_categoryId` ON `${TABLE_NAME}` (`categoryId`)"
},
{
"name": "index_web_apps_isActivated",
"unique": false,
"columnNames": [
"isActivated"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_isActivated` ON `${TABLE_NAME}` (`isActivated`)"
},
{
"name": "index_web_apps_appType_url",
"unique": false,
"columnNames": [
"appType",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_url` ON `${TABLE_NAME}` (`appType`, `url`)"
},
{
"name": "index_web_apps_appType_iconPath_url",
"unique": false,
"columnNames": [
"appType",
"iconPath",
"url"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_web_apps_appType_iconPath_url` ON `${TABLE_NAME}` (`appType`, `iconPath`, `url`)"
}
]
},
{
"tableName": "app_categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `color` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"tableName": "app_usage_stats",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `launchCount` INTEGER NOT NULL, `totalUsageMs` INTEGER NOT NULL, `lastUsedAt` INTEGER NOT NULL, `lastSessionDurationMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "launchCount",
"columnName": "launchCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "totalUsageMs",
"columnName": "totalUsageMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "lastUsedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSessionDurationMs",
"columnName": "lastSessionDurationMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_usage_stats_appId",
"unique": true,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_app_usage_stats_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_usage_stats_lastUsedAt",
"unique": false,
"columnNames": [
"lastUsedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_lastUsedAt` ON `${TABLE_NAME}` (`lastUsedAt`)"
},
{
"name": "index_app_usage_stats_launchCount",
"unique": false,
"columnNames": [
"launchCount"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_usage_stats_launchCount` ON `${TABLE_NAME}` (`launchCount`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "app_health_records",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `appId` INTEGER NOT NULL, `url` TEXT NOT NULL, `status` TEXT NOT NULL, `responseTimeMs` INTEGER NOT NULL, `httpStatusCode` INTEGER NOT NULL, `errorMessage` TEXT, `checkedAt` INTEGER NOT NULL, FOREIGN KEY(`appId`) REFERENCES `web_apps`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "appId",
"columnName": "appId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "responseTimeMs",
"columnName": "responseTimeMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "httpStatusCode",
"columnName": "httpStatusCode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "errorMessage",
"columnName": "errorMessage",
"affinity": "TEXT"
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_app_health_records_appId",
"unique": false,
"columnNames": [
"appId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_appId` ON `${TABLE_NAME}` (`appId`)"
},
{
"name": "index_app_health_records_checkedAt",
"unique": false,
"columnNames": [
"checkedAt"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_app_health_records_checkedAt` ON `${TABLE_NAME}` (`checkedAt`)"
}
],
"foreignKeys": [
{
"table": "web_apps",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"appId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ea6b40911e9117204ec107ff693031d7')"
]
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</domain-config>
</network-security-config>

View File

@ -0,0 +1,522 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
==========================================================================
权限声明策略(2026-05 修订)
==========================================================================
本 manifest 只声明「宿主 app 本体」运行需要的权限和组件。
设计原则:
1. 用户打包的子 APK 如需 SMS / 通话 / 通讯录 / 日历 / 定位 / 传感器 /
NFC / USB / 后台服务 等高危能力,由 ApkBuilder 在打包时按用户勾选
动态注入到**子包**的 manifest,不预声明在宿主 manifest 里。
2. 仅供子 APK 使用的运行时组件(如后台运行服务 / Node.js 子进程前台
服务等)只在 shell 模板 manifest 中声明,不混入宿主 manifest。
宿主自身不会实例化这些组件,预声明只会让国产安全引擎做"组件指纹
聚合"时把宿主误判为驻留型可疑应用。
若有新的宿主功能真的需要某个权限/组件,请:
1. 在本文件添加并注释"宿主使用:xxx 功能"
2. 在代码里能被 grep 到对应 API 调用
禁止再把"给子 APK 准备"的权限/组件写进本文件。
==========================================================================
-->
<!-- ====== 网络(宿主必需:加载网页、下载运行时、推送请求)====== -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 宿主使用:预览/分析局域网地址。Android 16 opt-in、17 强制(部分 ROM 不分
targetSdk 直接强制);缺失时 LAN 请求超时或报 ERR_LOCAL_NETWORK_PERMISSION_MISSING。
旧系统会忽略未知权限,无副作用。 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- ====== 存储 / 媒体(宿主必需:选择图标、导入 HTML、素材库)====== -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<!-- ====== APK 安装 / 桌面快捷方式(宿主必需:打包完成后一键安装子 APK)====== -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<!-- ====== 通知(宿主必需:打包任务前台通知)====== -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- ====== 相机(宿主使用:扫码导入网址)====== -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- ====== 音频(宿主使用:录音、媒体回放音量调整)====== -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- ====== 定位(宿主使用:预览需要地理位置的网站,与生成的 APK 保持一致)====== -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- ====== WiFi(宿主使用:网络状态查询、WiFi 扫描;写权限留给子 APK 注入)====== -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- ====== 蓝牙(宿主使用:基本扫描/连接)====== -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- ====== 生物识别(宿主使用:应用锁、隐私保护)====== -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- ====== 悬浮窗(宿主使用:FloatingWindowService 悬浮小窗预览模式)====== -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- ====== 电池 / 勿扰(宿主使用:打包任务防杀后台、勿扰模式)====== -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<!-- ====== 前台服务(宿主必需:打包任务 + 悬浮窗预览 + WebView 媒体播放)====== -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- ====== 系统运行(宿主必需:打包 / 启动等长任务保持唤醒)====== -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- ====== 自启动(宿主使用:BootReceiver 开机拉起用户指定的子应用、定时启动)====== -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- ====== uses-feature(标注设备能力,全部 required=false 不限制安装)====== -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.camera.flash" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-feature android:name="android.hardware.wifi" android:required="false" />
<uses-feature android:name="android.hardware.screen.portrait" android:required="false" />
<uses-feature android:name="android.hardware.screen.landscape" android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tel" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="mailto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="sms" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="smsto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="mms" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="mmsto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="geo" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="market" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="magnet" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="thunder" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="ed2k" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="*" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<package android:name="idm.internet.download.manager" />
<package android:name="idm.internet.download.manager.plus" />
<package android:name="idm.internet.download.manager.adm.lite" />
<package android:name="com.dv.adm" />
<package android:name="com.dv.adm.pay" />
<package android:name="com.mxtech.videoplayer.ad" />
<package android:name="com.mxtech.videoplayer.pro" />
<package android:name="org.videolan.vlc" />
<package android:name="com.kmplayer" />
<package android:name="com.xunlei.downloadprovider" />
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
<intent>
<action android:name="br.com.drukstech.codeapp.action.PORT_QUERY" />
</intent>
<intent>
<action android:name="br.com.drukstech.codeapp.action.PORT_RELEASE" />
</intent>
</queries>
<!--
==========================================================================
android:enableOnBackInvokedCallback="true"
==========================================================================
Android 13+ 的「可预测返回手势」(predictive back) 要求 app 显式声明这个
flag,否则 OEM(华为 / 小米 / OPPO / 三星等)的侧边返回手势 / 三角形
返回键会**绕过**老的 OnBackPressedDispatcher,默认行为是 finish() 当前
Activity,于是用户在 webapp 里滑边返回会**直接退到桌面**而不是回退到
上一个网页。
打开这个 flag 后,AndroidX activity 1.8+ 的 OnBackPressedDispatcher 会
自动桥接到系统的 OnBackInvokedDispatcher——所有现有的
OnBackPressedCallback / BackHandler 不需要改动,就能在 Android 13+
收到手势/三角键的返回事件,按已实现的 WebView.goBack() 逻辑做网页历史
回退。
Android 12 及以下忽略此 flag,行为与原本完全一致。
==========================================================================
-->
<application
android:name=".DruksCodeApplication"
android:allowBackup="false"
android:largeHeap="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.DruksCode"
android:usesCleartextTraffic="true"
android:enableOnBackInvokedCallback="true"
android:pageSizeCompat="enabled"
tools:targetApi="31">
<meta-data
android:name="br.com.drukstech.codeapp.DKC_APP_MARKER"
android:value="true" />
<property
android:name="android.app.property.PAGE_SIZE_COMPATIBILITY"
android:value="16" />
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.DruksCode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- OAuth callback -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="br.com.drukstech.codeapp"
android:host="oauth2callback" />
</intent-filter>
<!-- Browser intent: handle http/https URLs so Android offers DruksCode as a browser -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<!-- Share intent: receive text/URLs from other apps via share sheet -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity
android:name=".ui.webview.WebViewActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize|uiMode"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.DruksCode.Shell" />
<activity
android:name=".ui.shell.ShellActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize|uiMode"
android:exported="false"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.DruksCode.Shell" />
<activity
android:name=".ui.media.MediaAppActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="false"
android:hardwareAccelerated="true"
android:theme="@style/Theme.DruksCode.Fullscreen" />
<activity
android:name=".ui.gallery.GalleryPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize"
android:exported="false"
android:hardwareAccelerated="true"
android:theme="@style/Theme.DruksCode.Fullscreen" />
<activity
android:name=".ui.codepreview.HtmlPreviewActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.DruksCode" />
<activity
android:name=".ui.splash.SplashLauncherActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:hardwareAccelerated="true"
android:noHistory="true"
android:excludeFromRecents="true"
android:taskAffinity=""
android:theme="@style/Theme.DruksCode.Transparent" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".core.agent.runtime.AgentService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!--
WebMediaPlaybackService - WebView 内媒体播放的后台保活前台服务。
音频本身仍在 WebView 内播放;该服务提供 mediaPlayback 前台服务 + 唤醒锁 +
媒体通知生命周期(播放中启动 / 暂停分离 / 停止移除)。
-->
<service
android:name=".core.webview.WebMediaPlaybackService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="false" />
<!--
NodeService - Node.js 嵌入式运行时的独立子进程容器。
为什么单独开一个 OS 进程:
libnode.so 一旦 dlopen + V8 init 就**永远 reset 不了**。如果跑在主进程:
1) 用户 server.js 抛 SyntaxError / 调 process.exit 会让宿主进程死
2) 切换项目 / 上次崩了想重试都没法做,必须用户从最近任务列表把
App 划掉再重开——非常糟糕的用户体验
把它关到 :nodejs 子进程后,V8 死活都关在子进程里,主进程稳如磐石。
要重启 V8 时主进程让子进程自杀,Android Framework 自动重建——零感知。
android:process=":nodejs" → 同 APK 内的独立 OS 进程(前导冒号表示
private process,跟 webtoapp 共享 UID
但有独立的 zygote-fork 出来的进程)
isolatedProcess=false → 我们要访问 filesDir / nativeLibraryDir,
isolatedProcess 模式下连这些都拿不到
stopWithTask=false → 用户从最近任务划掉 App 时仍然让子进程
一起死(true 是默认值,写出来是为了
清楚——避免误以为子进程会比主进程
活更久)
-->
<service
android:name=".core.nodejs.NodeService"
android:enabled="true"
android:exported="false"
android:process=":nodejs"
android:stopWithTask="true" />
<!--
BootReceiver / ScheduledStartReceiver
宿主使用:用户在 host 内可配置"开机自启某个 webapp"和"定时启动";
BootReceiver 同时也兼容 shell 子 APK(行为等价的 java-override,
含开机/更新后的通知服务恢复——改动需两侧同步)。
-->
<receiver
android:name=".core.autostart.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".core.autostart.ScheduledStartReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="br.com.drukstech.codeapp.ACTION_SCHEDULED_START" />
</intent-filter>
</receiver>
<!--
Port query / release receivers
宿主使用:跨 webapp 端口协调(Node/PHP/Python/Go 多运行时共存时)。
exported=true 是因为多个由 DruksCode 打包出的 APK 之间需要互相广播查询端口。
-->
<receiver
android:name=".core.port.PortQueryReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="br.com.drukstech.codeapp.action.PORT_QUERY" />
</intent-filter>
</receiver>
<receiver
android:name=".core.port.PortReleaseReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="br.com.drukstech.codeapp.action.PORT_RELEASE" />
</intent-filter>
</receiver>
<!--
BridgeAlarmReceiver - 通知调度(页内 Web Notification.scheduleNotification)。
宿主与生成的 APK 均使用(NativeBridge 为 shell 同步代码),shell 模板清单
必须保留同名声明,两边需同步修改。
-->
<receiver
android:name=".core.notification.BridgeAlarmReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="br.com.drukstech.codeapp.bridge.SCHEDULED_NOTIFICATION" />
</intent-filter>
</receiver>
<service
android:name=".core.background.BackgroundRunService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="background_run" />
</service>
<service
android:name=".core.notification.NotificationPollingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="notification_polling" />
</service>
<service
android:name=".core.notification.NotificationWebSocketService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="notification_websocket" />
</service>
<service
android:name=".core.notification.NotificationFcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name=".core.floatingwindow.FloatingWindowService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="floating_window" />
</service>
</application>
</manifest>

View File

@ -0,0 +1,16 @@
背景音乐预置目录
================
将 MP3 音乐文件放入此目录即可作为预置背景音乐使用。
文件命名规则:
- 音乐文件:小乔.mp3
- 封面图片:小乔.png(或 .jpg/.jpeg)
同名的音乐和图片会自动配对,图片将作为音乐的封面图标显示。
封面图片是可选的,仅用于在选择界面方便识别。
示例:
- 小乔.mp3 + 小乔.png
- 背景音乐1.mp3 + 背景音乐1.jpg
- bgm_01.mp3(无封面)

View File

@ -0,0 +1,698 @@
<?php
set_time_limit(0);
$port = (int)($argv[1] ?? 8080);
$docRoot = realpath($argv[2] ?? '.');
$entryFile = $argv[3] ?? 'index.php';
if (!$docRoot || !is_dir($docRoot)) {
fwrite(STDERR, "[RouterServer] ERROR: Document root invalid: {$argv[2]}\n");
exit(1);
}
// ==================== 启动服务器 ====================
$errno = 0;
$errstr = '';
$server = @stream_socket_server(
"tcp://127.0.0.1:$port", $errno, $errstr,
STREAM_SERVER_BIND | STREAM_SERVER_LISTEN
);
if (!$server) {
fwrite(STDERR, "[RouterServer] ERROR: Cannot bind to 127.0.0.1:$port: $errstr ($errno)\n");
exit(1);
}
stream_set_blocking($server, true);
fwrite(STDERR, "[RouterServer] Listening on 127.0.0.1:$port\n");
fwrite(STDERR, "[RouterServer] Document root: $docRoot\n");
fwrite(STDERR, "[RouterServer] Entry file: $entryFile\n");
// 信号处理
$running = true;
$hasPcntl = function_exists('pcntl_fork');
fwrite(STDERR, "[RouterServer] pcntl_fork=" . ($hasPcntl ? 'yes' : 'NO') . "\n");
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGTERM, function () use (&$running) { $running = false; });
pcntl_signal(SIGINT, function () use (&$running) { $running = false; });
pcntl_signal(SIGCHLD, SIG_IGN); // 自动回收子进程
}
// ==================== Header Function Polyfill ====================
// pmmp PHP CLI SAPI 的 header()/headers_list() 不会真正跟踪 HTTP 头。
// 通过 disable_functions 禁用内置版本后,在此提供自定义实现,
// 将所有 header() 调用记录到全局数组,确保响应包含正确的 HTTP 头。
if (!function_exists('header')) {
$GLOBALS['__router_custom_headers'] = [];
function header(string $string, bool $replace = true, int $response_code = 0): void {
// HTTP 状态行: HTTP/1.1 302 Found
if (stripos($string, 'HTTP/') === 0) {
if (preg_match('/HTTP\/[\d.]+\s+(\d+)/', $string, $m)) {
http_response_code((int)$m[1]);
}
return;
}
$colonPos = strpos($string, ':');
if ($colonPos !== false && $replace) {
$headerName = strtolower(trim(substr($string, 0, $colonPos)));
// Set-Cookie 不受 replace 影响(多个 Set-Cookie 可共存)
if ($headerName !== 'set-cookie') {
$GLOBALS['__router_custom_headers'] = array_values(array_filter(
$GLOBALS['__router_custom_headers'],
function($h) use ($headerName) {
$p = strpos($h, ':');
return $p === false || strtolower(trim(substr($h, 0, $p))) !== $headerName;
}
));
}
}
$GLOBALS['__router_custom_headers'][] = $string;
// 处理 response_code 参数
if ($response_code > 0) {
http_response_code($response_code);
} elseif ($colonPos !== false) {
$headerName = strtolower(trim(substr($string, 0, $colonPos)));
if ($headerName === 'location') {
$currentCode = http_response_code();
if ($currentCode < 300 || $currentCode >= 400) {
http_response_code(302);
}
}
}
}
function headers_list(): array {
return $GLOBALS['__router_custom_headers'];
}
function headers_sent(&$filename = null, &$line = null): bool {
return false;
}
function header_remove(?string $name = null): void {
if ($name === null) {
$GLOBALS['__router_custom_headers'] = [];
} else {
$lower = strtolower($name);
$GLOBALS['__router_custom_headers'] = array_values(array_filter(
$GLOBALS['__router_custom_headers'],
function($h) use ($lower) {
$p = strpos($h, ':');
return $p === false || strtolower(trim(substr($h, 0, $p))) !== $lower;
}
));
}
}
function setcookie(
string $name, string $value = '', int|array $expires_or_options = 0,
string $path = '', string $domain = '', bool $secure = false, bool $httponly = false
): bool {
if (is_array($expires_or_options)) {
$o = $expires_or_options;
$cookie = rawurlencode($name) . '=' . rawurlencode($value);
if (!empty($o['expires']) && $o['expires'] > 0) {
$cookie .= '; Expires=' . gmdate('D, d M Y H:i:s T', (int)$o['expires']);
$cookie .= '; Max-Age=' . max(0, (int)$o['expires'] - time());
}
if (!empty($o['path'])) $cookie .= '; Path=' . $o['path'];
if (!empty($o['domain'])) $cookie .= '; Domain=' . $o['domain'];
if (!empty($o['secure'])) $cookie .= '; Secure';
if (!empty($o['httponly'])) $cookie .= '; HttpOnly';
if (!empty($o['samesite'])) $cookie .= '; SameSite=' . $o['samesite'];
} else {
$cookie = rawurlencode($name) . '=' . rawurlencode($value);
if ($expires_or_options > 0) {
$cookie .= '; Expires=' . gmdate('D, d M Y H:i:s T', $expires_or_options);
$cookie .= '; Max-Age=' . max(0, $expires_or_options - time());
}
if ($path !== '') $cookie .= '; Path=' . $path;
if ($domain !== '') $cookie .= '; Domain=' . $domain;
if ($secure) $cookie .= '; Secure';
if ($httponly) $cookie .= '; HttpOnly';
}
header('Set-Cookie: ' . $cookie, false);
return true;
}
function setrawcookie(
string $name, string $value = '', int|array $expires_or_options = 0,
string $path = '', string $domain = '', bool $secure = false, bool $httponly = false
): bool {
if (is_array($expires_or_options)) {
$o = $expires_or_options;
$cookie = $name . '=' . $value;
if (!empty($o['expires']) && $o['expires'] > 0) {
$cookie .= '; Expires=' . gmdate('D, d M Y H:i:s T', (int)$o['expires']);
$cookie .= '; Max-Age=' . max(0, (int)$o['expires'] - time());
}
if (!empty($o['path'])) $cookie .= '; Path=' . $o['path'];
if (!empty($o['domain'])) $cookie .= '; Domain=' . $o['domain'];
if (!empty($o['secure'])) $cookie .= '; Secure';
if (!empty($o['httponly'])) $cookie .= '; HttpOnly';
if (!empty($o['samesite'])) $cookie .= '; SameSite=' . $o['samesite'];
} else {
$cookie = $name . '=' . $value;
if ($expires_or_options > 0) {
$cookie .= '; Expires=' . gmdate('D, d M Y H:i:s T', $expires_or_options);
$cookie .= '; Max-Age=' . max(0, $expires_or_options - time());
}
if ($path !== '') $cookie .= '; Path=' . $path;
if ($domain !== '') $cookie .= '; Domain=' . $domain;
if ($secure) $cookie .= '; Secure';
if ($httponly) $cookie .= '; HttpOnly';
}
header('Set-Cookie: ' . $cookie, false);
return true;
}
}
// ==================== Session Polyfill ====================
// pmmp PHP 没有 session 扩展,提供基于文件的 session 实现
// 对所有 PHP 项目自动生效(包括已有项目)
$GLOBALS['__session_doc_root'] = $docRoot;
if (!function_exists('session_start')) {
$GLOBALS['__session_file'] = null;
$GLOBALS['__session_id'] = '';
$GLOBALS['__session_started'] = false;
$GLOBALS['__session_name'] = 'PHPSESSID';
function session_start(array $options = []): bool {
if ($GLOBALS['__session_started']) return true;
$docRoot = $GLOBALS['__session_doc_root'] ?? '.';
$sessionDir = $docRoot . '/.sessions';
if (!is_dir($sessionDir)) @mkdir($sessionDir, 0777, true);
// 从 cookie 获取 session ID,或生成新的
$name = $GLOBALS['__session_name'];
$sid = $_COOKIE[$name] ?? '';
if (empty($sid) || !preg_match('/^[a-zA-Z0-9,-]{22,256}$/', $sid)) {
$sid = bin2hex(random_bytes(16));
header("Set-Cookie: $name=$sid; Path=/; HttpOnly; SameSite=Lax");
}
$GLOBALS['__session_id'] = $sid;
$GLOBALS['__session_file'] = $sessionDir . '/sess_' . $sid;
$_SESSION = [];
if (file_exists($GLOBALS['__session_file'])) {
$data = @file_get_contents($GLOBALS['__session_file']);
if ($data !== false) {
$decoded = @json_decode($data, true);
if (is_array($decoded)) $_SESSION = $decoded;
}
}
$GLOBALS['__session_started'] = true;
return true;
}
function session_write_close(): bool {
if (!$GLOBALS['__session_started']) return true;
if ($GLOBALS['__session_file']) {
@file_put_contents(
$GLOBALS['__session_file'],
json_encode($_SESSION ?? [], JSON_UNESCAPED_UNICODE)
);
}
$GLOBALS['__session_started'] = false;
return true;
}
function session_commit(): bool {
return session_write_close();
}
function session_destroy(): bool {
$_SESSION = [];
if ($GLOBALS['__session_file'] && file_exists($GLOBALS['__session_file'])) {
@unlink($GLOBALS['__session_file']);
}
$GLOBALS['__session_started'] = false;
return true;
}
function session_id(string $id = ''): string|false {
if ($id !== '') $GLOBALS['__session_id'] = $id;
return $GLOBALS['__session_id'];
}
function session_name(string $name = ''): string|false {
$old = $GLOBALS['__session_name'];
if ($name !== '') $GLOBALS['__session_name'] = $name;
return $old;
}
function session_status(): int {
// PHP_SESSION_ACTIVE = 2, PHP_SESSION_NONE = 1
return $GLOBALS['__session_started'] ? 2 : 1;
}
function session_regenerate_id(bool $delete_old = false): bool {
if (!$GLOBALS['__session_started']) return false;
$oldFile = $GLOBALS['__session_file'];
$sid = bin2hex(random_bytes(16));
$GLOBALS['__session_id'] = $sid;
$name = $GLOBALS['__session_name'];
header("Set-Cookie: $name=$sid; Path=/; HttpOnly; SameSite=Lax");
$docRoot = $GLOBALS['__session_doc_root'] ?? '.';
$GLOBALS['__session_file'] = $docRoot . '/.sessions/sess_' . $sid;
if ($delete_old && $oldFile && file_exists($oldFile)) @unlink($oldFile);
return true;
}
function session_unset(): bool {
$_SESSION = [];
return true;
}
}
// ==================== 主循环 ====================
while ($running) {
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
$read = [$server]; $write = $except = null;
$changed = @stream_select($read, $write, $except, 1);
if ($changed === false || $changed === 0) continue;
$client = @stream_socket_accept($server, 0);
if (!$client) continue;
$request = readHttpRequest($client);
if (!$request) {
fwrite(STDERR, "[RouterServer] Empty request, closing\n");
fclose($client);
continue;
}
fwrite(STDERR, "[RouterServer] {$request['method']} {$request['path']}\n");
// 内置健康检查端点 — 不执行任何 PHP 文件
if ($request['path'] === '/__health') {
sendRawResponse($client, 200, 'OK', ['Content-Type: text/plain'], 'OK');
fclose($client);
continue;
}
$filePath = resolveFilePath($request['path'], $docRoot, $entryFile);
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
// 静态文件 — 主进程直接响应
if ($ext !== 'php' && file_exists($filePath) && !is_dir($filePath)) {
serveStaticFile($client, $filePath);
fclose($client);
continue;
}
if (!file_exists($filePath)) {
sendRawResponse($client, 404, 'Not Found', [], 'Not Found');
fclose($client);
continue;
}
// PHP 文件 — fork 子进程隔离执行
if ($hasPcntl) {
$pid = pcntl_fork();
if ($pid === -1) {
executePHP($client, $filePath, $request, $docRoot, $port);
fclose($client);
} elseif ($pid === 0) {
fclose($server);
executePHP($client, $filePath, $request, $docRoot, $port);
fclose($client);
exit(0);
} else {
fclose($client);
}
} else {
executePHP($client, $filePath, $request, $docRoot, $port);
fclose($client);
}
}
fclose($server);
fwrite(STDERR, "[RouterServer] Stopped\n");
exit(0);
// ==================== HTTP 请求解析 ====================
function readHttpRequest($client): ?array {
stream_set_timeout($client, 10);
$requestLine = fgets($client, 8192);
if (!$requestLine) return null;
if (!preg_match('/^(\w+)\s+(\S+)\s+HTTP\/([\d.]+)/', trim($requestLine), $m)) {
return null;
}
$method = strtoupper($m[1]);
$uri = $m[2];
$headers = [];
while (($line = fgets($client, 8192)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '') break;
if (($pos = strpos($line, ':')) !== false) {
$headers[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1));
}
}
$body = '';
$contentLength = (int)($headers['content-length'] ?? 0);
if ($contentLength > 0) {
$remaining = $contentLength;
while ($remaining > 0) {
$chunk = fread($client, min($remaining, 65536));
if ($chunk === false || $chunk === '') break;
$body .= $chunk;
$remaining -= strlen($chunk);
}
}
$parts = parse_url($uri);
$path = urldecode($parts['path'] ?? '/');
$queryString = $parts['query'] ?? '';
return compact('method', 'uri', 'path', 'queryString', 'headers', 'body');
}
// ==================== 路径解析 ====================
function resolveFilePath(string $path, string $docRoot, string $entryFile): string {
$path = '/' . ltrim(str_replace('\\', '/', $path), '/');
if (strpos($path, '..') !== false) return '';
$filePath = $docRoot . $path;
$requestedExt = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (is_dir($filePath)) {
foreach (['index.php', 'index.html', 'index.htm'] as $idx) {
$c = $filePath . '/' . $idx;
if (file_exists($c)) return $c;
}
}
if (file_exists($filePath) && !is_dir($filePath)) return $filePath;
if ($requestedExt !== '' && in_array($requestedExt, [
'css','js','mjs','json','xml','txt','csv','png','jpg','jpeg','gif','svg','ico',
'webp','avif','woff','woff2','ttf','otf','pdf','zip','mp4','webm','mp3','ogg','wav',
'map','wasm'
], true)) {
return $filePath;
}
return $docRoot . '/' . $entryFile;
}
// ==================== PHP 执行 ====================
function executePHP($client, string $filePath, array $request, string $docRoot, int $port): void {
$method = $request['method'];
$uri = $request['uri'];
$queryString = $request['queryString'];
$headers = $request['headers'];
$body = $request['body'];
$path = $request['path'];
// 标记响应是否已发送,防止 shutdown function 重复发送
$GLOBALS['__router_response_sent'] = false;
// 将 body 写入临时文件并重定向 stdin (fd 0)
// 这样 php://input 可以正确读取请求体
$tmpInput = null;
if (strlen($body) > 0) {
$tmpInput = tempnam(sys_get_temp_dir(), 'phpinput_');
file_put_contents($tmpInput, $body);
fclose(STDIN);
$GLOBALS['STDIN'] = fopen($tmpInput, 'r'); // 获得 fd 0
}
// 构建 $_SERVER
$_SERVER = [
'REQUEST_METHOD' => $method,
'REQUEST_URI' => $uri,
'QUERY_STRING' => $queryString,
'SCRIPT_FILENAME' => $filePath,
'SCRIPT_NAME' => $path,
'PHP_SELF' => $path,
'DOCUMENT_ROOT' => $docRoot,
'SERVER_NAME' => '*********',
'SERVER_PORT' => (string)$port,
'SERVER_PROTOCOL' => 'HTTP/1.1',
'SERVER_SOFTWARE' => 'PHPRouterServer/1.0',
'GATEWAY_INTERFACE' => 'CGI/1.1',
'REDIRECT_STATUS' => '200',
'REMOTE_ADDR' => '*********',
'REMOTE_PORT' => '0',
'HTTP_HOST' => $headers['host'] ?? "*********:$port",
'CONTENT_TYPE' => $headers['content-type'] ?? '',
'CONTENT_LENGTH' => (string)strlen($body),
'REQUEST_TIME' => time(),
'REQUEST_TIME_FLOAT'=> microtime(true),
];
foreach ($headers as $k => $v) {
$_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $k))] = $v;
}
// $_GET
$_GET = [];
parse_str($queryString, $_GET);
// $_POST & $_FILES
$_POST = [];
$_FILES = [];
$ct = $headers['content-type'] ?? '';
if (in_array($method, ['POST', 'PUT', 'PATCH']) && $body !== '') {
if (stripos($ct, 'application/x-www-form-urlencoded') !== false) {
parse_str($body, $_POST);
} elseif (stripos($ct, 'multipart/form-data') !== false) {
parseMultipart($ct, $body, $_POST, $_FILES);
} elseif (stripos($ct, 'application/json') !== false) {
// JSON body → $_POST fallback(当 php://input 在 CLI SAPI 下不可用时)
$decoded = json_decode($body, true);
if (is_array($decoded)) $_POST = $decoded;
}
}
// $_COOKIE
$_COOKIE = [];
if (isset($headers['cookie'])) {
foreach (explode(';', $headers['cookie']) as $c) {
$c = trim($c);
if (($eq = strpos($c, '=')) !== false) {
$_COOKIE[trim(substr($c, 0, $eq))] = urldecode(trim(substr($c, $eq + 1)));
}
}
}
$_REQUEST = array_merge($_GET, $_POST, $_COOKIE);
// 注册 shutdown function — 安全网:当脚本调用 exit() 或发生致命错误时发送响应
$responseClient = $client;
register_shutdown_function(function () use ($responseClient, $tmpInput, $uri) {
// 如果响应已发送(正常路径),仅清理
if (!empty($GLOBALS['__router_response_sent'])) {
if ($tmpInput && file_exists($tmpInput)) @unlink($tmpInput);
return;
}
$GLOBALS['__router_response_sent'] = true;
$output = '';
while (ob_get_level() > 0) {
$output = ob_get_clean() . $output;
}
$code = http_response_code() ?: 200;
$headerList = headers_list();
$lastError = error_get_last();
if ($lastError && in_array($lastError['type'] ?? 0, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
$code = 500;
$message = ($lastError['message'] ?? 'Unknown fatal error')
. ' in ' . ($lastError['file'] ?? 'unknown')
. ':' . ($lastError['line'] ?? 0);
fwrite(STDERR, "[RouterServer] Fatal error: $message\n");
if ($output === '') {
$output = '<h1>500 Internal Server Error</h1><pre>' . htmlspecialchars($message) . '</pre>';
}
}
// Surface the router's own diagnostic (fatal message / trace) to the WebView
// layer: local error pages read this header to show the cause instead of a
// bare "HTTP 500" that hides whether the failure is a fatal, a thrown
// exception, or PHP output. Keep it bounded — response bodies can be large.
if ($code >= 500 && $output !== '' && strlen($output) <= 65536) {
$headerList[] = 'X-Wta-Router-Error: ' . base64_encode($output);
}
if (function_exists('header_remove')) header_remove();
fwrite(STDERR, "[RouterServer] Shutdown response uri=$uri code=$code bytes=" . strlen($output) . " " . describeHeaders($headerList) . "\n");
sendRawResponse($responseClient, $code, httpStatusText($code), $headerList, $output);
@fclose($responseClient);
if ($tmpInput && file_exists($tmpInput)) @unlink($tmpInput);
});
ob_start();
http_response_code(200);
if (function_exists('header_remove')) header_remove();
// 设置全局变量,让 PHP 脚本可以通过 $GLOBALS['__HTTP_RAW_BODY'] 获取原始请求体
// 这是 php://input 在 CLI SAPI + fork 模式下的可靠替代方案
$GLOBALS['__HTTP_RAW_BODY'] = $body;
chdir($docRoot);
try {
include $filePath;
} catch (\Throwable $e) {
while (ob_get_level() > 1) ob_end_clean();
http_response_code(500);
fwrite(STDERR, "[RouterServer] PHP error: " . $e->getMessage() . "\n");
echo "<h1>500 Internal Server Error</h1><pre>"
. htmlspecialchars($e->getMessage()) . "\n"
. htmlspecialchars($e->getTraceAsString()) . "</pre>";
}
// Thrown-exception path: response goes through the normal-path sender below,
// which also stamps X-Wta-Router-Error on 5xx (see the shutdown handler).
// 自动保存 session 数据(模拟 PHP 原生 session 行为)
if (function_exists('session_write_close') && ($GLOBALS['__session_started'] ?? false)) {
session_write_close();
}
// 正常路径:显式发送响应(在 fclose 之前!)
if (!$GLOBALS['__router_response_sent']) {
$GLOBALS['__router_response_sent'] = true;
$output = '';
while (ob_get_level() > 0) {
$output = ob_get_clean() . $output;
}
$code = http_response_code() ?: 200;
$headerList = headers_list();
// Same 5xx diagnostic stamping as the shutdown handler (see there).
if ($code >= 500 && $output !== '' && strlen($output) <= 65536) {
$headerList[] = 'X-Wta-Router-Error: ' . base64_encode($output);
}
if (function_exists('header_remove')) header_remove();
fwrite(STDERR, "[RouterServer] Response uri=$uri code=$code bytes=" . strlen($output) . " " . describeHeaders($headerList) . "\n");
sendRawResponse($client, $code, httpStatusText($code), $headerList, $output);
}
}
// ==================== Multipart 解析 ====================
function parseMultipart(string $contentType, string $body, array &$post, array &$files): void {
if (!preg_match('/boundary=(.+?)(?:;|$)/i', $contentType, $m)) return;
$boundary = trim($m[1], '"');
$parts = explode("--$boundary", $body);
array_shift($parts);
foreach ($parts as $part) {
$part = ltrim($part, "\r\n");
if (str_starts_with(trim($part), '--') || empty(trim($part))) continue;
$headerEnd = strpos($part, "\r\n\r\n");
if ($headerEnd === false) continue;
$ph = substr($part, 0, $headerEnd);
$pb = rtrim(substr($part, $headerEnd + 4), "\r\n");
if (!preg_match('/name="([^"]+)"/', $ph, $nm)) continue;
$name = $nm[1];
if (preg_match('/filename="([^"]*)"/', $ph, $fn) && $fn[1] !== '') {
$tmp = tempnam(sys_get_temp_dir(), 'php_upload_');
file_put_contents($tmp, $pb);
$pct = 'application/octet-stream';
if (preg_match('/Content-Type:\s*(.+)/i', $ph, $ctm)) $pct = trim($ctm[1]);
$files[$name] = ['name'=>$fn[1],'type'=>$pct,'tmp_name'=>$tmp,'error'=>UPLOAD_ERR_OK,'size'=>strlen($pb)];
} else {
$post[$name] = $pb;
}
}
}
// ==================== 响应发送 ====================
function sendRawResponse($client, int $code, string $status, array $headers, string $body): void {
$resp = "HTTP/1.1 $code $status\r\n";
$hasCT = false; $hasCL = false;
foreach ($headers as $h) {
$resp .= "$h\r\n";
if (stripos($h, 'content-type:') === 0) $hasCT = true;
if (stripos($h, 'content-length:') === 0) $hasCL = true;
}
if (!$hasCT) $resp .= "Content-Type: text/html; charset=UTF-8\r\n";
if (!$hasCL) $resp .= "Content-Length: " . strlen($body) . "\r\n";
$resp .= "Connection: close\r\n\r\n" . $body;
@fwrite($client, $resp);
}
function describeHeaders(array $headers): string {
$parts = [];
foreach ($headers as $h) {
if (stripos($h, 'location:') === 0 || stripos($h, 'content-type:') === 0) {
$parts[] = $h;
} elseif (stripos($h, 'set-cookie:') === 0) {
$parts[] = 'Set-Cookie: <redacted>';
}
}
return empty($parts) ? 'headers=[]' : 'headers=[' . implode('; ', $parts) . ']';
}
// ==================== 静态文件服务 ====================
function serveStaticFile($client, string $filePath): void {
$mime = getMimeType($filePath);
$size = filesize($filePath);
$resp = "HTTP/1.1 200 OK\r\nContent-Type: $mime\r\nContent-Length: $size\r\n"
. "Connection: close\r\nCache-Control: public, max-age=3600\r\n\r\n";
fwrite($client, $resp);
$fp = fopen($filePath, 'rb');
if ($fp) {
while (!feof($fp)) { @fwrite($client, fread($fp, 65536)); }
fclose($fp);
}
}
function getMimeType(string $f): string {
static $t = [
'html'=>'text/html','htm'=>'text/html','css'=>'text/css',
'js'=>'application/javascript','mjs'=>'application/javascript',
'json'=>'application/json','xml'=>'application/xml',
'txt'=>'text/plain','csv'=>'text/csv',
'png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg',
'gif'=>'image/gif','svg'=>'image/svg+xml','ico'=>'image/x-icon',
'webp'=>'image/webp','avif'=>'image/avif',
'woff'=>'font/woff','woff2'=>'font/woff2','ttf'=>'font/ttf','otf'=>'font/otf',
'pdf'=>'application/pdf','zip'=>'application/zip',
'mp4'=>'video/mp4','webm'=>'video/webm',
'mp3'=>'audio/mpeg','ogg'=>'audio/ogg','wav'=>'audio/wav',
'map'=>'application/json','wasm'=>'application/wasm',
];
return $t[strtolower(pathinfo($f, PATHINFO_EXTENSION))] ?? 'application/octet-stream';
}
function httpStatusText(int $c): string {
static $t = [
200=>'OK',201=>'Created',204=>'No Content',
301=>'Moved Permanently',302=>'Found',304=>'Not Modified',
307=>'Temporary Redirect',308=>'Permanent Redirect',
400=>'Bad Request',401=>'Unauthorized',403=>'Forbidden',
404=>'Not Found',405=>'Method Not Allowed',422=>'Unprocessable Entity',
500=>'Internal Server Error',502=>'Bad Gateway',503=>'Service Unavailable',
];
return $t[$c] ?? 'Unknown';
}

View File

@ -0,0 +1,328 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>وثائق Hexo</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--blue: #0ea5e9;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
direction: rtl
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(14, 165, 233, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #0ea5e9, #38bdf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(14, 165, 233, 0.1);
color: var(--blue)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: monospace;
font-size: 12px;
color: #38bdf8;
margin: 8px 0;
direction: ltr;
text-align: left
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(14, 165, 233, 0.1);
color: var(--blue);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(14, 165, 233, 0.04);
border: 1px solid rgba(14, 165, 233, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
.card:nth-child(1) {
animation-delay: .1s
}
.card:nth-child(2) {
animation-delay: .15s
}
.card:nth-child(3) {
animation-delay: .2s
}
.card:nth-child(4) {
animation-delay: .25s
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">📖</div>
<h1>وثائق Hexo</h1>
<p>إطار عمل مدونات سريع وبسيط مبني على Node.js</p>
<div class="badge-row"><span class="badge active">v7.0</span><span class="badge">Node.js</span><span
class="badge">Markdown</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">🚀</div>
<div class="card-title">البداية السريعة</div>
<div class="card-desc">أنشئ مدونتك في دقائق</div>
</div>
<div class="card">
<div class="card-icon">🔧</div>
<div class="card-title">الإعدادات</div>
<div class="card-desc">خصص كل التفاصيل</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">السمات</div>
<div class="card-desc">مئات السمات الجميلة</div>
</div>
<div class="card">
<div class="card-icon">🔌</div>
<div class="card-title">الإضافات</div>
<div class="card-desc">نظام إضافات غني</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">⚡ البداية السريعة</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">تثبيت Hexo</div>
<div class="code">npm install -g hexo-cli</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">إنشاء المشروع</div>
<div class="code">hexo init my-blog && cd my-blog</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">تشغيل الخادم</div>
<div class="code">hexo server</div>
</div>
</div>
</div>
<div class="ft">
<div class="ft-b">📖 وثائق Hexo · مقدم من WebToApp</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,385 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Hexo Docs</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--blue: #0ea5e9;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(14, 165, 233, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #0ea5e9, #38bdf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(14, 165, 233, 0.1);
color: var(--blue);
border-color: rgba(14, 165, 233, 0.2)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: monospace;
font-size: 12px;
color: #38bdf8;
margin: 8px 0
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(14, 165, 233, 0.1);
color: var(--blue);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.nav-list a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
text-decoration: none;
color: var(--text)
}
.nav-list a:last-child {
border: none
}
.nav-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0
}
.nav-text {
font-size: 13px;
font-weight: 600
}
.nav-arrow {
margin-left: auto;
color: var(--text3)
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(14, 165, 233, 0.04);
border: 1px solid rgba(14, 165, 233, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
.card:nth-child(1) {
animation-delay: .1s
}
.card:nth-child(2) {
animation-delay: .15s
}
.card:nth-child(3) {
animation-delay: .2s
}
.card:nth-child(4) {
animation-delay: .25s
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">📖</div>
<h1>Hexo Docs</h1>
<p>A fast, simple & powerful blog framework built on Node.js</p>
<div class="badge-row"><span class="badge active">v7.0</span><span class="badge">Node.js</span><span
class="badge">Markdown</span><span class="badge">Open Source</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">🚀</div>
<div class="card-title">Quick Start</div>
<div class="card-desc">Create your first blog in minutes</div>
</div>
<div class="card">
<div class="card-icon">🔧</div>
<div class="card-title">Configuration</div>
<div class="card-desc">Customize every detail</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">Themes</div>
<div class="card-desc">Hundreds of beautiful themes</div>
</div>
<div class="card">
<div class="card-icon">🔌</div>
<div class="card-title">Plugins</div>
<div class="card-desc">Rich plugin ecosystem</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">⚡ Getting Started</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">Install Hexo</div>
<div class="code">npm install -g hexo-cli</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">Initialize</div>
<div class="code">hexo init my-blog && cd my-blog</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">Start Server</div>
<div class="code">hexo server</div>
</div>
</div>
</div>
<div class="section" style="animation-delay:.35s">
<div class="section-h">📚 Navigation</div>
<div class="nav-list">
<a href="#">
<div class="nav-icon" style="background:rgba(14,165,233,0.1)">📝</div><span
class="nav-text">Writing</span><span class="nav-arrow">→</span>
</a>
<a href="#">
<div class="nav-icon" style="background:rgba(139,92,246,0.1)">🏷️</div><span class="nav-text">Tags &
Categories</span><span class="nav-arrow">→</span>
</a>
<a href="#">
<div class="nav-icon" style="background:rgba(16,185,129,0.1)">🌐</div><span
class="nav-text">Deployment</span><span class="nav-arrow">→</span>
</a>
<a href="#">
<div class="nav-icon" style="background:rgba(245,158,11,0.1)">⚙️</div><span
class="nav-text">Advanced Config</span><span class="nav-arrow">→</span>
</a>
</div>
</div>
<div class="ft">
<div class="ft-b">📖 Hexo Docs · Powered by WebToApp</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Hexo 文档</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a1a;--card:rgba(255,255,255,0.04);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--blue:#0ea5e9;--radius:14px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 20%,rgba(14,165,233,0.06) 0%,transparent 55%);pointer-events:none}
.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}
.hero{text-align:center;padding:40px 0 32px}
.hero-icon{font-size:48px;margin-bottom:12px;animation:float 3s ease-in-out infinite}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-8px)}}
.hero h1{font-size:28px;font-weight:800;background:linear-gradient(135deg,#0ea5e9,#38bdf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:8px}
.hero p{font-size:14px;color:var(--text2);line-height:1.6;max-width:320px;margin:0 auto}
.badge-row{display:flex;justify-content:center;gap:8px;margin-top:16px;flex-wrap:wrap}
.badge{font-size:11px;font-weight:600;padding:4px 12px;border-radius:20px;background:var(--card);border:1px solid var(--border);color:var(--text2)}
.badge.active{background:rgba(14,165,233,0.1);color:var(--blue);border-color:rgba(14,165,233,0.2)}
.cards{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;backdrop-filter:blur(16px);transition:all .2s;cursor:pointer}
.card:active{transform:scale(.97);background:rgba(255,255,255,0.06)}
.card-icon{font-size:24px;margin-bottom:10px}
.card-title{font-size:14px;font-weight:700;margin-bottom:4px}
.card-desc{font-size:12px;color:var(--text2);line-height:1.5}
.section{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}
.section-h{font-size:15px;font-weight:700;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.step{display:flex;gap:12px;padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
.step:last-child{border:none}
.step-num{width:28px;height:28px;border-radius:8px;background:rgba(14,165,233,0.1);color:var(--blue);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;flex-shrink:0}
.step-info{flex:1}
.step-title{font-size:13px;font-weight:600;margin-bottom:2px}
.step-desc{font-size:11px;color:var(--text2)}
.code{background:rgba(0,0,0,0.4);border-radius:8px;padding:12px 14px;font-family:'SF Mono',Monaco,monospace;font-size:12px;color:#38bdf8;margin:8px 0;overflow-x:auto}
.nav-list a{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03);text-decoration:none;color:var(--text);transition:all .2s}
.nav-list a:last-child{border:none}
.nav-list a:active{opacity:.7}
.nav-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
.nav-text{font-size:13px;font-weight:600}
.nav-arrow{margin-left:auto;color:var(--text3);font-size:14px}
.ft{text-align:center;padding:16px}
.ft-b{display:inline-flex;align-items:center;gap:6px;font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(14,165,233,0.04);border:1px solid rgba(14,165,233,0.08)}
@keyframes fadeIn{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
.card,.section{animation:fadeIn .5s ease backwards}
.card:nth-child(1){animation-delay:.1s}.card:nth-child(2){animation-delay:.15s}.card:nth-child(3){animation-delay:.2s}.card:nth-child(4){animation-delay:.25s}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">📖</div>
<h1>Hexo 文档</h1>
<p>快速、简洁且高效的博客框架,基于 Node.js 构建</p>
<div class="badge-row">
<span class="badge active">v7.0</span>
<span class="badge">Node.js</span>
<span class="badge">Markdown</span>
<span class="badge">开源</span>
</div>
</div>
<div class="cards">
<div class="card"><div class="card-icon">🚀</div><div class="card-title">快速入门</div><div class="card-desc">几分钟内创建你的第一个博客</div></div>
<div class="card"><div class="card-icon">🔧</div><div class="card-title">配置</div><div class="card-desc">自定义网站的每个细节</div></div>
<div class="card"><div class="card-icon">🎨</div><div class="card-title">主题</div><div class="card-desc">数百个精美主题可选</div></div>
<div class="card"><div class="card-icon">🔌</div><div class="card-title">插件</div><div class="card-desc">丰富的插件生态系统</div></div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">⚡ 快速开始</div>
<div class="step"><div class="step-num">1</div><div class="step-info"><div class="step-title">安装 Hexo</div><div class="code">npm install -g hexo-cli</div></div></div>
<div class="step"><div class="step-num">2</div><div class="step-info"><div class="step-title">初始化项目</div><div class="code">hexo init my-blog && cd my-blog</div></div></div>
<div class="step"><div class="step-num">3</div><div class="step-info"><div class="step-title">启动服务</div><div class="code">hexo server</div></div></div>
</div>
<div class="section" style="animation-delay:.35s">
<div class="section-h">📚 文档导航</div>
<div class="nav-list">
<a href="#"><div class="nav-icon" style="background:rgba(14,165,233,0.1)">📝</div><span class="nav-text">写作</span><span class="nav-arrow">→</span></a>
<a href="#"><div class="nav-icon" style="background:rgba(139,92,246,0.1)">🏷️</div><span class="nav-text">标签与分类</span><span class="nav-arrow">→</span></a>
<a href="#"><div class="nav-icon" style="background:rgba(16,185,129,0.1)">🌐</div><span class="nav-text">部署</span><span class="nav-arrow">→</span></a>
<a href="#"><div class="nav-icon" style="background:rgba(245,158,11,0.1)">⚙️</div><span class="nav-text">高级配置</span><span class="nav-arrow">→</span></a>
<a href="#"><div class="nav-icon" style="background:rgba(236,72,153,0.1)">❓</div><span class="nav-text">常见问题</span><span class="nav-arrow">→</span></a>
</div>
</div>
<div class="ft"><div class="ft-b">📖 Hexo 文档 · 由 WebToApp 提供</div></div>
</div>
</body>
</html>

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>وثائق MkDocs</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--teal: #14b8a6;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
direction: rtl
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(20, 184, 166, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #14b8a6, #2dd4bf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(20, 184, 166, 0.1);
color: var(--teal)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: monospace;
font-size: 12px;
color: #2dd4bf;
margin: 8px 0;
direction: ltr;
text-align: left
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(20, 184, 166, 0.1);
color: var(--teal);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(20, 184, 166, 0.04);
border: 1px solid rgba(20, 184, 166, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">📗</div>
<h1>وثائق MkDocs</h1>
<p>أنشئ وثائق المشروع بـ Markdown مع سمة Material</p>
<div class="badge-row"><span class="badge active">Material</span><span class="badge">Python</span><span
class="badge">Markdown</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">🚀</div>
<div class="card-title">بداية سريعة</div>
<div class="card-desc">إعداد الوثائق في 5 دقائق</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">سمة Material</div>
<div class="card-desc">تصميم عصري ووضع داكن</div>
</div>
<div class="card">
<div class="card-icon">🔍</div>
<div class="card-title">البحث</div>
<div class="card-desc">محرك بحث نصي مدمج</div>
</div>
<div class="card">
<div class="card-icon">📱</div>
<div class="card-title">متجاوب</div>
<div class="card-desc">دعم مثالي للهاتف</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">⚡ البداية السريعة</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">تثبيت MkDocs</div>
<div class="code">pip install mkdocs-material</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">إنشاء مشروع</div>
<div class="code">mkdocs new my-docs && cd my-docs</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">معاينة</div>
<div class="code">mkdocs serve</div>
</div>
</div>
</div>
<div class="ft">
<div class="ft-b">📗 MkDocs Material · مقدم من WebToApp</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
site_name: MkDocs Demo
theme: material

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>MkDocs Docs</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--teal: #14b8a6;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(20, 184, 166, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #14b8a6, #2dd4bf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(20, 184, 166, 0.1);
color: var(--teal)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: monospace;
font-size: 12px;
color: #2dd4bf;
margin: 8px 0
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(20, 184, 166, 0.1);
color: var(--teal);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(20, 184, 166, 0.04);
border: 1px solid rgba(20, 184, 166, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
.card:nth-child(1) {
animation-delay: .1s
}
.card:nth-child(2) {
animation-delay: .15s
}
.card:nth-child(3) {
animation-delay: .2s
}
.card:nth-child(4) {
animation-delay: .25s
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">📗</div>
<h1>MkDocs</h1>
<p>Build project documentation with Markdown, powered by Material theme</p>
<div class="badge-row"><span class="badge active">Material</span><span class="badge">Python</span><span
class="badge">Markdown</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">🚀</div>
<div class="card-title">Quick Start</div>
<div class="card-desc">Set up docs in 5 minutes</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">Material Theme</div>
<div class="card-desc">Modern design, dark mode</div>
</div>
<div class="card">
<div class="card-icon">🔍</div>
<div class="card-title">Search</div>
<div class="card-desc">Built-in full-text search</div>
</div>
<div class="card">
<div class="card-icon">📱</div>
<div class="card-title">Responsive</div>
<div class="card-desc">Perfect mobile support</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">⚡ Getting Started</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">Install MkDocs</div>
<div class="code">pip install mkdocs-material</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">Create Project</div>
<div class="code">mkdocs new my-docs && cd my-docs</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">Start Preview</div>
<div class="code">mkdocs serve</div>
</div>
</div>
</div>
<div class="ft">
<div class="ft-b">📗 MkDocs Material · Powered by WebToApp</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
site_name: MkDocs Demo
theme: material

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>MkDocs 文档</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--teal: #14b8a6;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(20, 184, 166, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #14b8a6, #2dd4bf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(20, 184, 166, 0.1);
color: var(--teal);
border-color: rgba(20, 184, 166, 0.2)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
backdrop-filter: blur(16px);
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
color: #2dd4bf;
margin: 8px 0;
overflow-x: auto
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(20, 184, 166, 0.1);
color: var(--teal);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.nav-list a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
text-decoration: none;
color: var(--text)
}
.nav-list a:last-child {
border: none
}
.nav-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0
}
.nav-text {
font-size: 13px;
font-weight: 600
}
.nav-arrow {
margin-left: auto;
color: var(--text3)
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(20, 184, 166, 0.04);
border: 1px solid rgba(20, 184, 166, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
.card:nth-child(1) {
animation-delay: .1s
}
.card:nth-child(2) {
animation-delay: .15s
}
.card:nth-child(3) {
animation-delay: .2s
}
.card:nth-child(4) {
animation-delay: .25s
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">📗</div>
<h1>MkDocs 文档</h1>
<p>使用 Markdown 构建项目文档,Material 主题打造极致体验</p>
<div class="badge-row"><span class="badge active">Material</span><span class="badge">Python</span><span
class="badge">Markdown</span><span class="badge">开源</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">🚀</div>
<div class="card-title">快速入门</div>
<div class="card-desc">5 分钟搭建文档站</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">Material 主题</div>
<div class="card-desc">现代化设计,深色模式</div>
</div>
<div class="card">
<div class="card-icon">🔍</div>
<div class="card-title">搜索</div>
<div class="card-desc">内置全文搜索引擎</div>
</div>
<div class="card">
<div class="card-icon">📱</div>
<div class="card-title">响应式</div>
<div class="card-desc">完美适配移动设备</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">⚡ 快速开始</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">安装 MkDocs</div>
<div class="code">pip install mkdocs-material</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">新建项目</div>
<div class="code">mkdocs new my-docs && cd my-docs</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">启动预览</div>
<div class="code">mkdocs serve</div>
</div>
</div>
</div>
<div class="section" style="animation-delay:.35s">
<div class="section-h">📚 文档导航</div>
<div class="nav-list">
<a href="#">
<div class="nav-icon" style="background:rgba(20,184,166,0.1)">📐</div><span
class="nav-text">配置</span><span class="nav-arrow">→</span>
</a>
<a href="#">
<div class="nav-icon" style="background:rgba(139,92,246,0.1)">🎯</div><span
class="nav-text">自定义主题</span><span class="nav-arrow">→</span>
</a>
<a href="#">
<div class="nav-icon" style="background:rgba(16,185,129,0.1)">📦</div><span
class="nav-text">插件扩展</span><span class="nav-arrow">→</span>
</a>
<a href="#">
<div class="nav-icon" style="background:rgba(245,158,11,0.1)">🌐</div><span
class="nav-text">部署</span><span class="nav-arrow">→</span>
</a>
</div>
</div>
<div class="ft">
<div class="ft-b">📗 MkDocs Material · 由 WebToApp 提供</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
site_name: MkDocs Demo
theme: material

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>وثائق VitePress</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--green: #10b981;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Tahoma, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
direction: rtl
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(16, 185, 129, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #10b981, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(16, 185, 129, 0.1);
color: var(--green)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: monospace;
font-size: 12px;
color: #34d399;
margin: 8px 0;
direction: ltr;
text-align: left
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(16, 185, 129, 0.1);
color: var(--green);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(16, 185, 129, 0.04);
border: 1px solid rgba(16, 185, 129, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">⚡</div>
<h1>VitePress</h1>
<p>مولد مواقع ثابتة مدعوم بـ Vite مصمم للتوثيق</p>
<div class="badge-row"><span class="badge active">v1.0</span><span class="badge">Vue 3</span><span
class="badge">Vite</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">⚡</div>
<div class="card-title">فائق السرعة</div>
<div class="card-desc">إعادة تحميل فورية</div>
</div>
<div class="card">
<div class="card-icon">📝</div>
<div class="card-title">Markdown</div>
<div class="card-desc">صيغة Markdown محسنة</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">السمات</div>
<div class="card-desc">سمة افتراضية جميلة</div>
</div>
<div class="card">
<div class="card-icon">🔧</div>
<div class="card-title">قابل للتخصيص</div>
<div class="card-desc">تكامل عميق مع Vue</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">🚀 البداية السريعة</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">إنشاء</div>
<div class="code">npx vitepress init</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">تطوير</div>
<div class="code">npx vitepress dev docs</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">بناء</div>
<div class="code">npx vitepress build docs</div>
</div>
</div>
</div>
<div class="ft">
<div class="ft-b">⚡ VitePress · مقدم من WebToApp</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VitePress Docs</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--green: #10b981;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(16, 185, 129, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #10b981, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(16, 185, 129, 0.1);
color: var(--green)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: monospace;
font-size: 12px;
color: #34d399;
margin: 8px 0
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(16, 185, 129, 0.1);
color: var(--green);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.feature {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.feature:last-child {
border: none
}
.feat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0
}
.feat-info {
flex: 1
}
.feat-title {
font-size: 13px;
font-weight: 600
}
.feat-desc {
font-size: 11px;
color: var(--text2)
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(16, 185, 129, 0.04);
border: 1px solid rgba(16, 185, 129, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
.card:nth-child(1) {
animation-delay: .1s
}
.card:nth-child(2) {
animation-delay: .15s
}
.card:nth-child(3) {
animation-delay: .2s
}
.card:nth-child(4) {
animation-delay: .25s
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">⚡</div>
<h1>VitePress</h1>
<p>Vite-powered static site generator built for documentation</p>
<div class="badge-row"><span class="badge active">v1.0</span><span class="badge">Vue 3</span><span
class="badge">Vite</span><span class="badge">SSG</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">⚡</div>
<div class="card-title">Lightning Fast</div>
<div class="card-desc">Instant hot reload</div>
</div>
<div class="card">
<div class="card-icon">📝</div>
<div class="card-title">Markdown</div>
<div class="card-desc">Enhanced Markdown syntax</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">Themes</div>
<div class="card-desc">Beautiful default theme</div>
</div>
<div class="card">
<div class="card-icon">🔧</div>
<div class="card-title">Customizable</div>
<div class="card-desc">Deep Vue integration</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">🚀 Getting Started</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">Initialize</div>
<div class="code">npx vitepress init</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">Develop</div>
<div class="code">npx vitepress dev docs</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">Build</div>
<div class="code">npx vitepress build docs</div>
</div>
</div>
</div>
<div class="section" style="animation-delay:.35s">
<div class="section-h">✨ Features</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(16,185,129,0.1)">⚡</div>
<div class="feat-info">
<div class="feat-title">Vite Powered</div>
<div class="feat-desc">Instant server start + blazing fast HMR</div>
</div>
</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(139,92,246,0.1)">🎯</div>
<div class="feat-info">
<div class="feat-title">Vue 3 Integration</div>
<div class="feat-desc">Embed Vue components in Markdown</div>
</div>
</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(14,165,233,0.1)">🔍</div>
<div class="feat-info">
<div class="feat-title">Built-in Search</div>
<div class="feat-desc">Full-text search powered by minisearch</div>
</div>
</div>
</div>
<div class="ft">
<div class="ft-b">⚡ VitePress · Powered by WebToApp</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>VitePress 文档</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #0a0a1a;
--card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.07);
--text: #f0f0ff;
--text2: rgba(240, 240, 255, 0.55);
--text3: rgba(240, 240, 255, 0.3);
--green: #10b981;
--radius: 14px
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased
}
body::before {
content: '';
position: fixed;
top: -40%;
left: -40%;
width: 180%;
height: 180%;
background: radial-gradient(ellipse at 50% 20%, rgba(16, 185, 129, 0.06) 0%, transparent 55%);
pointer-events: none
}
.c {
max-width: 460px;
margin: 0 auto;
padding: 0 14px 32px;
position: relative;
z-index: 1
}
.hero {
text-align: center;
padding: 40px 0 32px
}
.hero-icon {
font-size: 48px;
margin-bottom: 12px;
animation: float 3s ease-in-out infinite
}
@keyframes float {
0%,
100% {
transform: translateY(0)
}
50% {
transform: translateY(-8px)
}
}
.hero h1 {
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #10b981, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px
}
.hero p {
font-size: 14px;
color: var(--text2);
line-height: 1.6;
max-width: 320px;
margin: 0 auto
}
.badge-row {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap
}
.badge {
font-size: 11px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
background: var(--card);
border: 1px solid var(--border);
color: var(--text2)
}
.badge.active {
background: rgba(16, 185, 129, 0.1);
color: var(--green);
border-color: rgba(16, 185, 129, 0.2)
}
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
cursor: pointer;
transition: all .2s
}
.card:active {
transform: scale(.97)
}
.card-icon {
font-size: 24px;
margin-bottom: 10px
}
.card-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px
}
.card-desc {
font-size: 12px;
color: var(--text2);
line-height: 1.5
}
.section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px
}
.section-h {
font-size: 15px;
font-weight: 700;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 8px
}
.code {
background: rgba(0, 0, 0, 0.4);
border-radius: 8px;
padding: 12px 14px;
font-family: 'SF Mono', Monaco, monospace;
font-size: 12px;
color: #34d399;
margin: 8px 0
}
.step {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.step:last-child {
border: none
}
.step-num {
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(16, 185, 129, 0.1);
color: var(--green);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
flex-shrink: 0
}
.step-info {
flex: 1
}
.step-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 2px
}
.feature {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03)
}
.feature:last-child {
border: none
}
.feat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0
}
.feat-info {
flex: 1
}
.feat-title {
font-size: 13px;
font-weight: 600
}
.feat-desc {
font-size: 11px;
color: var(--text2)
}
.ft {
text-align: center;
padding: 16px
}
.ft-b {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: var(--text3);
padding: 5px 12px;
border-radius: 20px;
background: rgba(16, 185, 129, 0.04);
border: 1px solid rgba(16, 185, 129, 0.08)
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(16px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.card,
.section {
animation: fadeIn .5s ease backwards
}
.card:nth-child(1) {
animation-delay: .1s
}
.card:nth-child(2) {
animation-delay: .15s
}
.card:nth-child(3) {
animation-delay: .2s
}
.card:nth-child(4) {
animation-delay: .25s
}
</style>
</head>
<body>
<div class="c">
<div class="hero">
<div class="hero-icon">⚡</div>
<h1>VitePress</h1>
<p>由 Vite 驱动的静态站点生成器,为文档而生</p>
<div class="badge-row"><span class="badge active">v1.0</span><span class="badge">Vue 3</span><span
class="badge">Vite</span><span class="badge">SSG</span></div>
</div>
<div class="cards">
<div class="card">
<div class="card-icon">⚡</div>
<div class="card-title">极速启动</div>
<div class="card-desc">毫秒级热更新体验</div>
</div>
<div class="card">
<div class="card-icon">📝</div>
<div class="card-title">Markdown</div>
<div class="card-desc">增强版 Markdown 语法</div>
</div>
<div class="card">
<div class="card-icon">🎨</div>
<div class="card-title">主题</div>
<div class="card-desc">默认主题即开即用</div>
</div>
<div class="card">
<div class="card-icon">🔧</div>
<div class="card-title">自定义</div>
<div class="card-desc">Vue 组件深度集成</div>
</div>
</div>
<div class="section" style="animation-delay:.3s">
<div class="section-h">🚀 快速开始</div>
<div class="step">
<div class="step-num">1</div>
<div class="step-info">
<div class="step-title">安装 VitePress</div>
<div class="code">npx vitepress init</div>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-info">
<div class="step-title">启动开发</div>
<div class="code">npx vitepress dev docs</div>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-info">
<div class="step-title">构建发布</div>
<div class="code">npx vitepress build docs</div>
</div>
</div>
</div>
<div class="section" style="animation-delay:.35s">
<div class="section-h">✨ 核心特性</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(16,185,129,0.1)">⚡</div>
<div class="feat-info">
<div class="feat-title">Vite 驱动</div>
<div class="feat-desc">即时服务器启动 + 闪电般的 HMR</div>
</div>
</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(139,92,246,0.1)">🎯</div>
<div class="feat-info">
<div class="feat-title">Vue 3 集成</div>
<div class="feat-desc">在 Markdown 中嵌入 Vue 组件</div>
</div>
</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(14,165,233,0.1)">🔍</div>
<div class="feat-info">
<div class="feat-title">内置搜索</div>
<div class="feat-desc">基于 minisearch 的全文搜索</div>
</div>
</div>
<div class="feature">
<div class="feat-icon" style="background:rgba(245,158,11,0.1)">📊</div>
<div class="feat-info">
<div class="feat-title">SEO 优化</div>
<div class="feat-desc">自动生成 sitemap + meta 标签</div>
</div>
</div>
</div>
<div class="ft">
<div class="ft-b">⚡ VitePress · 由 WebToApp 提供</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,14 @@
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;display:flex;min-height:100vh;background:#fafafa}
.sidebar{width:240px;background:#fff;border-right:1px solid #eee;padding:20px}
.logo{font-size:20px;font-weight:700;margin-bottom:24px;padding:8px 0}
.sidebar a{display:block;padding:10px 16px;border-radius:8px;color:#333;text-decoration:none;margin-bottom:4px;font-size:14px}
.sidebar a.active{background:#e8f5e9;color:#2e7d32;font-weight:600}
.sidebar a:hover{background:#f5f5f5}
.content{flex:1;padding:40px 60px;max-width:800px}
h1{font-size:32px;color:#1a1a1a;margin-bottom:16px}
p{color:#666;line-height:1.8;margin-bottom:24px}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:32px}
.feature{background:#fff;border:1px solid #eee;border-radius:12px;padding:20px}
.feature h3{font-size:16px;margin-bottom:8px}
.feature p{font-size:14px;color:#888;margin:0}

View File

@ -0,0 +1,5 @@
module github.com/demo/echo-server
go 1.21
require github.com/labstack/echo/v4 v4.11.4

View File

@ -0,0 +1,25 @@
package main
// Echo Service Monitor (AR)
import ("fmt";"math/rand";"net/http";"os";"time";"github.com/labstack/echo/v4";"github.com/labstack/echo/v4/middleware")
var startTime = time.Now()
func main() {
e := echo.New(); e.HideBanner = true; e.Use(middleware.Logger())
e.GET("/", func(c echo.Context) error { return c.HTML(http.StatusOK, dash()) })
e.GET("/health", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{"status":"healthy"}) })
port := os.Getenv("PORT"); if port == "" { port = "1323" }; e.Logger.Fatal(e.Start(":"+port))
}
func dash() string {
up := time.Since(startTime).Round(time.Second)
svcs := []struct{n,s,i,c string}{{"API Gateway","يعمل","🌐","#00d4aa"},{"قاعدة البيانات","يعمل","🗄️","#3b82f6"},{"التخزين المؤقت","يعمل","⚡","#f59e0b"},{"قائمة الرسائل","يعمل","📨","#8b5cf6"},{"خدمة المصادقة","يعمل","🔐","#ec4899"},{"التخزين","يعمل","💾","#06b6d4"}}
sh := ""; for _,s := range svcs { sh += fmt.Sprintf(`<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)"><div style="width:36px;height:36px;border-radius:10px;background:%s18;display:flex;align-items:center;justify-content:center;font-size:16px">%s</div><div style="flex:1"><div style="font-size:13px;font-weight:600">%s</div><div style="font-size:11px;color:%s">● %s</div></div></div>`,s.c,s.i,s.n,s.c,s.s) }
bars := ""; days := []string{"إثنين","ثلاثاء","أربعاء","خميس","جمعة","سبت","أحد"}
for i,d := range days { h:=30+rand.Intn(60); bars+=fmt.Sprintf(`<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end"><div style="width:100%%;max-width:28px;height:%d%%;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#22d3ee,#0891b2);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards;animation-delay:%.2fs"></div><div style="font-size:9px;color:rgba(240,240,255,0.3)">%s</div></div>`,h,0.3+float64(i)*0.08,d) }
return fmt.Sprintf(`<!DOCTYPE html><html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Echo</title><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Tahoma,sans-serif;background:#080816;color:#f0f0ff;min-height:100vh;direction:rtl}@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}</style></head><body>
<div style="max-width:460px;margin:0 auto;padding:0 14px 32px">
<div style="padding:20px 2px;display:flex;justify-content:space-between;align-items:center"><div><h1 style="font-size:22px;font-weight:800;background:linear-gradient(135deg,#06b6d4,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent">📡 Echo مراقب</h1><p style="font-size:12px;color:rgba(240,240,255,0.55)">لوحة مراقبة الخدمات</p></div><div style="font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(6,182,212,0.1);color:#06b6d4">الكل يعمل</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px"><div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:14px;text-align:center"><div style="font-size:20px;font-weight:800;color:#00d4aa">%s</div><div style="font-size:10px;color:rgba(240,240,255,0.55)">وقت التشغيل</div></div><div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:14px;text-align:center"><div style="font-size:20px;font-weight:800;color:#06b6d4">6</div><div style="font-size:10px;color:rgba(240,240,255,0.55)">الخدمات</div></div><div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:14px;text-align:center"><div style="font-size:20px;font-weight:800;color:#8b5cf6">99.9%%</div><div style="font-size:10px;color:rgba(240,240,255,0.55)">التوفر</div></div></div>
<div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:18px;margin-bottom:14px"><div style="font-size:15px;font-weight:700;margin-bottom:14px">حالة الخدمات</div>%s</div>
<div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:18px;margin-bottom:14px"><div style="font-size:15px;font-weight:700;margin-bottom:14px">حركة الزيارات</div><div style="display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px;direction:ltr">%s</div></div>
<div style="text-align:center;padding:12px;font-size:10px;color:rgba(240,240,255,0.3)">Echo v4 · Go 1.22 · يعمل</div>
</div></body></html>`,up,sh,bars)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/echo-server
go 1.21
require github.com/labstack/echo/v4 v4.11.4

View File

@ -0,0 +1,24 @@
package main
// Echo Service Monitor (EN)
import ("fmt";"math/rand";"net/http";"os";"time";"github.com/labstack/echo/v4";"github.com/labstack/echo/v4/middleware")
var startTime = time.Now()
func main() {
e := echo.New(); e.HideBanner = true; e.Use(middleware.Logger())
e.GET("/", func(c echo.Context) error { return c.HTML(http.StatusOK, dash()) })
e.GET("/health", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{"status":"healthy","uptime":time.Since(startTime).String()}) })
e.GET("/api/services", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]interface{}{"services":[]map[string]string{{"name":"API Gateway","status":"running"},{"name":"Database","status":"running"},{"name":"Cache","status":"running"}}}) })
port := os.Getenv("PORT"); if port == "" { port = "1323" }; e.Logger.Fatal(e.Start(":"+port))
}
func dash() string {
up := time.Since(startTime).Round(time.Second)
svcs := []struct{n,s,l,i,c string}{{"API Gateway","Running","2ms","🌐","#00d4aa"},{"Database","Running","5ms","🗄️","#3b82f6"},{"Redis Cache","Running","<1ms","⚡","#f59e0b"},{"Message Queue","Running","3ms","📨","#8b5cf6"},{"Auth Service","Running","4ms","🔐","#ec4899"},{"Storage","Running","8ms","💾","#06b6d4"}}
sh := ""; for _,s := range svcs { sh += fmt.Sprintf(`<div class="svc"><div class="si" style="background:%s18">%s</div><div class="sf"><div class="sn">%s</div><div class="ss" style="color:%s">● %s</div></div><div class="sl">%s</div></div>`,s.c,s.i,s.n,s.c,s.s,s.l) }
bars := ""; days := []string{"Mon","Tue","Wed","Thu","Fri","Sat","Sun"}
for i,d := range days { h:=30+rand.Intn(60); bars+=fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%.2fs"></div><div class="bl">%s</div></div>`,h,0.3+float64(i)*0.08,d) }
return fmt.Sprintf(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Echo Monitor</title><style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#080816;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--cyan:#06b6d4;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#06b6d4,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2)}.badge{font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(6,182,212,0.1);color:var(--cyan)}.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}.m-val{font-size:20px;font-weight:800}.m-label{font-size:10px;color:var(--text2);text-transform:uppercase}.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--cyan)}.m:nth-child(3) .m-val{color:#8b5cf6}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(6,182,212,0.1);color:var(--cyan)}.svc{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}.svc:last-child{border:none}.si{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}.sf{flex:1}.sn{font-size:13px;font-weight:600}.ss{font-size:11px}.sl{font-size:11px;color:var(--text3);font-family:monospace}.chart{display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px}.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#22d3ee,#0891b2);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}.bl{font-size:9px;color:var(--text3)}.ft{text-align:center;padding:12px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(6,182,212,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>📡 Echo Monitor</h1><p>Service Monitoring Panel</p></div><div class="badge">All OK</div></div>
<div class="row"><div class="m"><div class="m-val">%s</div><div class="m-label">Uptime</div></div><div class="m"><div class="m-val">6</div><div class="m-label">Services</div></div><div class="m"><div class="m-val">99.9%%</div><div class="m-label">Avail</div></div></div>
<div class="card"><div class="card-h"><span class="card-t">Services</span><span class="card-b">🟢 All Online</span></div>%s</div>
<div class="card"><div class="card-h"><span class="card-t">Traffic</span><span class="card-b">📊 Weekly</span></div><div class="chart">%s</div></div>
<div class="ft"><div class="ft-b">Echo v4 · Go 1.22 · Running</div></div></div></body></html>`,up,sh,bars)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/echo-server
go 1.21
require github.com/labstack/echo/v4 v4.11.4

View File

@ -0,0 +1,117 @@
package main
// Echo 服务监控面板 — 精美的 Go Web 应用示例
import (
"fmt"
"math/rand"
"net/http"
"os"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Status struct {
Service string `json:"service"`
Status string `json:"status"`
Uptime string `json:"uptime"`
}
var startTime = time.Now()
func main() {
e := echo.New()
e.HideBanner = true
e.Use(middleware.Logger())
e.GET("/", dashboardHandler)
e.GET("/health", func(c echo.Context) error {
return c.JSON(http.StatusOK, Status{"echo-dashboard", "healthy", time.Since(startTime).String()})
})
e.GET("/api/services", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{
"services": []map[string]interface{}{
{"name": "API Gateway", "status": "running", "latency": "2ms"},
{"name": "Database", "status": "running", "latency": "5ms"},
{"name": "Cache", "status": "running", "latency": "<1ms"},
{"name": "Queue", "status": "running", "latency": "3ms"},
},
})
})
port := os.Getenv("PORT")
if port == "" {
port = "1323"
}
e.Logger.Fatal(e.Start(":" + port))
}
func dashboardHandler(c echo.Context) error {
uptime := time.Since(startTime).Round(time.Second)
services := []struct{ name, status, latency, icon, color string }{
{"API Gateway", "运行中", "2ms", "🌐", "#00d4aa"},
{"Database", "运行中", "5ms", "🗄️", "#3b82f6"},
{"Redis Cache", "运行中", "<1ms", "⚡", "#f59e0b"},
{"Message Queue", "运行中", "3ms", "📨", "#8b5cf6"},
{"Auth Service", "运行中", "4ms", "🔐", "#ec4899"},
{"Storage", "运行中", "8ms", "💾", "#06b6d4"},
}
svcHTML := ""
for _, s := range services {
svcHTML += fmt.Sprintf(`<div class="svc"><div class="svc-i" style="background:%s18">%s</div><div class="svc-info"><div class="svc-n">%s</div><div class="svc-s" style="color:%s">● %s</div></div><div class="svc-l">%s</div></div>`, s.color, s.icon, s.name, s.color, s.status, s.latency)
}
bars := ""
days := []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
for i, d := range days {
h := 30 + rand.Intn(60)
bars += fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%.2fs"></div><div class="bl">%s</div></div>`, h, 0.3+float64(i)*0.08, d)
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Echo Monitor</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#080816;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--cyan:#06b6d4;--radius:14px}
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
body::before{content:'';position:fixed;top:-40%%;left:-40%%;width:180%%;height:180%%;background:radial-gradient(ellipse at 50%% 30%%,rgba(6,182,212,0.06) 0%%,transparent 55%%);pointer-events:none}
.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}
.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}
.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#06b6d4,#22d3ee);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr p{font-size:12px;color:var(--text2);margin-top:2px}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(6,182,212,0.1);color:var(--cyan);border:1px solid rgba(6,182,212,0.15)}
.badge .dot{width:7px;height:7px;border-radius:50%%;background:#00d4aa;box-shadow:0 0 10px #00d4aa;animation:p 2s infinite}
@keyframes p{0%%,100%%{opacity:1}50%%{opacity:.5}}
.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}
.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}
.m-val{font-size:20px;font-weight:800;margin-bottom:2px}
.m-label{font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:500}
.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--cyan)}.m:nth-child(3) .m-val{color:#8b5cf6}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}
.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(6,182,212,0.1);color:var(--cyan)}
.svc{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}.svc:last-child{border:none}
.svc-i{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.svc-info{flex:1}.svc-n{font-size:13px;font-weight:600}.svc-s{font-size:11px;font-weight:500}
.svc-l{font-size:11px;color:var(--text3);font-family:monospace;font-weight:600}
.chart{display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px}
.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}
.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#22d3ee,#0891b2);box-shadow:0 0 8px rgba(6,182,212,0.2);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}
@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}.bl{font-size:9px;color:var(--text3);font-weight:500}
.ft{text-align:center;padding:12px}.ft-b{display:inline-flex;align-items:center;gap:6px;font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(6,182,212,0.04);border:1px solid rgba(6,182,212,0.08)}
@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}
</style></head><body>
<div class="c">
<div class="hdr"><div><h1>📡 Echo Monitor</h1><p>服务监控面板</p></div><div class="badge"><span class="dot"></span>全部正常</div></div>
<div class="row">
<div class="m"><div class="m-val">%s</div><div class="m-label">运行时间</div></div>
<div class="m"><div class="m-val">6</div><div class="m-label">服务数</div></div>
<div class="m"><div class="m-val">99.9%%</div><div class="m-label">可用性</div></div>
</div>
<div class="card"><div class="card-h"><span class="card-t">服务状态</span><span class="card-b">🟢 全部在线</span></div>%s</div>
<div class="card"><div class="card-h"><span class="card-t">流量趋势</span><span class="card-b">📊 本周</span></div><div class="chart">%s</div></div>
<div class="ft"><div class="ft-b"><span class="dot" style="width:5px;height:5px;border-radius:50%%;background:#00d4aa;box-shadow:0 0 6px #00d4aa"></span>Echo v4 · Go 1.22 · 运行中</div></div>
</div></body></html>`, uptime, svcHTML, bars)
return c.HTML(http.StatusOK, html)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/fiber-app
go 1.21
require github.com/gofiber/fiber/v2 v2.52.0

View File

@ -0,0 +1,26 @@
package main
// Fiber Performance Dashboard (AR)
import ("fmt";"math/rand";"os";"runtime";"time";"github.com/gofiber/fiber/v2";"github.com/gofiber/fiber/v2/middleware/logger")
var startTime = time.Now()
func main() {
app := fiber.New(fiber.Config{AppName:"Fiber Dashboard",DisableStartupMessage:true}); app.Use(logger.New())
app.Get("/", func(c *fiber.Ctx) error { c.Set("Content-Type","text/html; charset=utf-8"); return c.SendString(dash()) })
app.Get("/api/users", func(c *fiber.Ctx) error { return c.JSON([]fiber.Map{{"id":1,"name":"أحمد"},{"id":2,"name":"فاطمة"},{"id":3,"name":"محمد"}}) })
app.Get("/api/health", func(c *fiber.Ctx) error { var m runtime.MemStats; runtime.ReadMemStats(&m); return c.JSON(fiber.Map{"status":"healthy","memory_mb":m.Alloc/1024/1024}) })
port := os.Getenv("PORT"); if port == "" { port = "3000" }; app.Listen(":"+port)
}
func dash() string {
up := time.Since(startTime).Round(time.Second); var m runtime.MemStats; runtime.ReadMemStats(&m); mb := m.Alloc/1024/1024; gr := runtime.NumGoroutine()
bars := ""; days := []string{"إثنين","ثلاثاء","أربعاء","خميس","جمعة","سبت","أحد"}
for i,d := range days { h:=25+rand.Intn(65); bars+=fmt.Sprintf(`<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end"><div style="width:100%%;max-width:28px;height:%d%%;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#2dd4bf,#0d9488);transform-origin:bottom;animation:g .7s cubic-bezier(.34,1.56,.64,1) backwards;animation-delay:%.2fs"></div><div style="font-size:9px;color:rgba(240,240,255,0.3)">%s</div></div>`,h,0.3+float64(i)*0.08,d) }
mets := []struct{l,v,i,c string}{{"زمن الاستجابة","<1ms","⚡","#f59e0b"},{"الذاكرة",fmt.Sprintf("%dMB",mb),"💾","#8b5cf6"},{"الخيوط",fmt.Sprintf("%d",gr),"🔄","#06b6d4"},{"QPS",fmt.Sprintf("%d",1000+rand.Intn(4000)),"📊","#ec4899"}}
mh := ""; for _,mt := range mets { mh += fmt.Sprintf(`<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)"><div style="width:36px;height:36px;border-radius:10px;background:%s18;display:flex;align-items:center;justify-content:center;font-size:16px">%s</div><div style="flex:1"><div style="font-size:12px;color:rgba(240,240,255,0.55)">%s</div><div style="font-size:16px;font-weight:700;color:%s">%s</div></div></div>`,mt.c,mt.i,mt.l,mt.c,mt.v) }
return fmt.Sprintf(`<!DOCTYPE html><html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Fiber</title><style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Tahoma,sans-serif;background:#080816;color:#f0f0ff;min-height:100vh;direction:rtl}@keyframes g{from{transform:scaleY(0)}to{transform:scaleY(1)}}</style></head><body>
<div style="max-width:460px;margin:0 auto;padding:0 14px 32px">
<div style="padding:20px 2px;display:flex;justify-content:space-between;align-items:center"><div><h1 style="font-size:22px;font-weight:800;background:linear-gradient(135deg,#14b8a6,#2dd4bf);-webkit-background-clip:text;-webkit-text-fill-color:transparent">🔥 Fiber لوحة</h1><p style="font-size:12px;color:rgba(240,240,255,0.55)">إطار عمل Go فائق السرعة</p></div><div style="font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(20,184,166,0.1);color:#14b8a6">يعمل</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px"><div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:14px;text-align:center"><div style="font-size:20px;font-weight:800;color:#00d4aa">%s</div><div style="font-size:10px;color:rgba(240,240,255,0.55)">التشغيل</div></div><div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:14px;text-align:center"><div style="font-size:20px;font-weight:800;color:#14b8a6">%dMB</div><div style="font-size:10px;color:rgba(240,240,255,0.55)">الذاكرة</div></div><div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:14px;text-align:center"><div style="font-size:20px;font-weight:800;color:#8b5cf6">%d</div><div style="font-size:10px;color:rgba(240,240,255,0.55)">الخيوط</div></div></div>
<div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:18px;margin-bottom:14px"><div style="font-size:15px;font-weight:700;margin-bottom:14px">مقاييس الأداء</div>%s</div>
<div style="background:rgba(255,255,255,0.035);border:1px solid rgba(255,255,255,0.07);border-radius:14px;padding:18px;margin-bottom:14px"><div style="font-size:15px;font-weight:700;margin-bottom:14px">الإنتاجية</div><div style="display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px;direction:ltr">%s</div></div>
<div style="text-align:center;padding:12px;font-size:10px;color:rgba(240,240,255,0.3)">Fiber v2 · Go · يعمل</div>
</div></body></html>`,up,mb,gr,mh,bars)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/fiber-app
go 1.21
require github.com/gofiber/fiber/v2 v2.52.0

View File

@ -0,0 +1,26 @@
package main
// Fiber Performance Dashboard (EN)
import ("fmt";"math/rand";"os";"runtime";"time";"github.com/gofiber/fiber/v2";"github.com/gofiber/fiber/v2/middleware/logger")
var startTime = time.Now()
func main() {
app := fiber.New(fiber.Config{AppName:"Fiber Dashboard",DisableStartupMessage:true}); app.Use(logger.New())
app.Get("/", func(c *fiber.Ctx) error { c.Set("Content-Type","text/html; charset=utf-8"); return c.SendString(dash()) })
app.Get("/api/users", func(c *fiber.Ctx) error { return c.JSON([]fiber.Map{{"id":1,"name":"Alice"},{"id":2,"name":"Bob"},{"id":3,"name":"Carol"}}) })
app.Get("/api/health", func(c *fiber.Ctx) error { var m runtime.MemStats; runtime.ReadMemStats(&m); return c.JSON(fiber.Map{"status":"healthy","memory_mb":m.Alloc/1024/1024,"goroutines":runtime.NumGoroutine()}) })
port := os.Getenv("PORT"); if port == "" { port = "3000" }; app.Listen(":"+port)
}
func dash() string {
up := time.Since(startTime).Round(time.Second); var m runtime.MemStats; runtime.ReadMemStats(&m); mb := m.Alloc/1024/1024; gr := runtime.NumGoroutine()
bars := ""; days := []string{"Mon","Tue","Wed","Thu","Fri","Sat","Sun"}
for i,d := range days { h:=25+rand.Intn(65); bars+=fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%.2fs"></div><div class="bl">%s</div></div>`,h,0.3+float64(i)*0.08,d) }
mets := []struct{l,v,i,c string}{{"Response","<1ms","⚡","#f59e0b"},{"Memory",fmt.Sprintf("%dMB",mb),"💾","#8b5cf6"},{"Goroutines",fmt.Sprintf("%d",gr),"🔄","#06b6d4"},{"QPS",fmt.Sprintf("%d",1000+rand.Intn(4000)),"📊","#ec4899"}}
mh := ""; for _,mt := range mets { mh += fmt.Sprintf(`<div class="pf"><div class="pi" style="background:%s18">%s</div><div class="px"><div class="pl">%s</div><div class="pv" style="color:%s">%s</div></div></div>`,mt.c,mt.i,mt.l,mt.c,mt.v) }
return fmt.Sprintf(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Fiber</title><style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#080816;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--teal:#14b8a6;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#14b8a6,#2dd4bf);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2)}.badge{font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(20,184,166,0.1);color:var(--teal)}.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}.m-val{font-size:20px;font-weight:800}.m-label{font-size:10px;color:var(--text2);text-transform:uppercase}.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--teal)}.m:nth-child(3) .m-val{color:#8b5cf6}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(20,184,166,0.1);color:var(--teal)}.pf{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}.pf:last-child{border:none}.pi{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}.px{flex:1}.pl{font-size:12px;color:var(--text2)}.pv{font-size:16px;font-weight:700}.chart{display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px}.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#2dd4bf,#0d9488);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}.bl{font-size:9px;color:var(--text3)}.try{background:rgba(20,184,166,0.1);color:var(--teal);border:1px solid rgba(20,184,166,0.2);padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer}.result{margin-top:10px;padding:12px;background:rgba(0,0,0,0.3);border-radius:8px;font-family:monospace;font-size:11px;color:#00d4aa;max-height:180px;overflow-y:auto;display:none;white-space:pre-wrap}.ft{text-align:center;padding:12px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(20,184,166,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>🔥 Fiber Dashboard</h1><p>Ultra-Fast Go Web Framework</p></div><div class="badge">Running</div></div>
<div class="row"><div class="m"><div class="m-val">%s</div><div class="m-label">Uptime</div></div><div class="m"><div class="m-val">%dMB</div><div class="m-label">Memory</div></div><div class="m"><div class="m-val">%d</div><div class="m-label">Goroutines</div></div></div>
<div class="card"><div class="card-h"><span class="card-t">Performance</span><span class="card-b">⚡ Live</span></div>%s</div>
<div class="card"><div class="card-h"><span class="card-t">Throughput</span><span class="card-b">📊 Weekly</span></div><div class="chart">%s</div></div>
<div class="card"><div class="card-h"><span class="card-t">Live Test</span><span class="card-b">🧪</span></div><div style="display:flex;gap:8px"><button class="try" onclick="tryApi('/api/users')">👥 Users</button><button class="try" onclick="tryApi('/api/health')">💚 Health</button></div><div class="result" id="r"></div></div>
<div class="ft"><div class="ft-b">Fiber v2 · Go %s · Running</div></div></div>
<script>function tryApi(u){var r=document.getElementById('r');r.style.display='block';r.textContent='⏳';fetch(u).then(function(x){return x.json()}).then(function(d){r.textContent=JSON.stringify(d,null,2)}).catch(function(e){r.textContent='❌ '+e.message})}</script></body></html>`,up,mb,gr,mh,bars,runtime.Version())
}

View File

@ -0,0 +1,5 @@
module github.com/demo/fiber-app
go 1.21
require github.com/gofiber/fiber/v2 v2.52.0

View File

@ -0,0 +1,126 @@
package main
// Fiber 性能分析面板 — 精美的 Go Web 应用示例
import (
"fmt"
"math/rand"
"os"
"runtime"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
)
var startTime = time.Now()
func main() {
app := fiber.New(fiber.Config{AppName: "Fiber Dashboard v2.0", DisableStartupMessage: true})
app.Use(logger.New())
app.Get("/", dashboardHandler)
app.Get("/api/users", func(c *fiber.Ctx) error {
return c.JSON([]fiber.Map{
{"id": 1, "name": "Alice", "role": "Admin"},
{"id": 2, "name": "Bob", "role": "User"},
{"id": 3, "name": "Carol", "role": "Editor"},
})
})
app.Get("/api/health", func(c *fiber.Ctx) error {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return c.JSON(fiber.Map{
"status": "healthy",
"framework": "Fiber",
"uptime": time.Since(startTime).String(),
"memory_mb": m.Alloc / 1024 / 1024,
"goroutines": runtime.NumGoroutine(),
})
})
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
app.Listen(":" + port)
}
func dashboardHandler(c *fiber.Ctx) error {
uptime := time.Since(startTime).Round(time.Second)
var m runtime.MemStats
runtime.ReadMemStats(&m)
memMB := m.Alloc / 1024 / 1024
goroutines := runtime.NumGoroutine()
bars := ""
days := []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
for i, d := range days {
h := 25 + rand.Intn(65)
bars += fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%.2fs"></div><div class="bl">%s</div></div>`, h, 0.3+float64(i)*0.08, d)
}
metrics := []struct{ label, value, icon, color string }{
{"响应时间", "<1ms", "⚡", "#f59e0b"},
{"内存使用", fmt.Sprintf("%dMB", memMB), "💾", "#8b5cf6"},
{"协程数", fmt.Sprintf("%d", goroutines), "🔄", "#06b6d4"},
{"QPS", fmt.Sprintf("%d", 1000+rand.Intn(4000)), "📊", "#ec4899"},
}
metricHTML := ""
for _, met := range metrics {
metricHTML += fmt.Sprintf(`<div class="perf"><div class="perf-i" style="background:%s18">%s</div><div class="perf-info"><div class="perf-l">%s</div><div class="perf-v" style="color:%s">%s</div></div></div>`, met.color, met.icon, met.label, met.color, met.value)
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Fiber Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#080816;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--teal:#14b8a6;--radius:14px}
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
body::before{content:'';position:fixed;top:-40%%;left:-40%%;width:180%%;height:180%%;background:radial-gradient(ellipse at 50%% 30%%,rgba(20,184,166,0.06) 0%%,transparent 55%%);pointer-events:none}
.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}
.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}
.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#14b8a6,#2dd4bf);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr p{font-size:12px;color:var(--text2);margin-top:2px}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(20,184,166,0.1);color:var(--teal);border:1px solid rgba(20,184,166,0.15)}
.badge .dot{width:7px;height:7px;border-radius:50%%;background:#00d4aa;box-shadow:0 0 10px #00d4aa;animation:p 2s infinite}
@keyframes p{0%%,100%%{opacity:1}50%%{opacity:.5}}
.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}
.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}
.m-val{font-size:20px;font-weight:800;margin-bottom:2px}
.m-label{font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:500}
.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--teal)}.m:nth-child(3) .m-val{color:#8b5cf6}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}
.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(20,184,166,0.1);color:var(--teal)}
.perf{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}.perf:last-child{border:none}
.perf-i{width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.perf-info{flex:1}.perf-l{font-size:12px;color:var(--text2)}.perf-v{font-size:16px;font-weight:700}
.chart{display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px}
.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}
.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#2dd4bf,#0d9488);box-shadow:0 0 8px rgba(20,184,166,0.2);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}
@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}.bl{font-size:9px;color:var(--text3);font-weight:500}
.try{background:rgba(20,184,166,0.1);color:var(--teal);border:1px solid rgba(20,184,166,0.2);padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer}.try:active{transform:scale(.95)}
.result{margin-top:10px;padding:12px;background:rgba(0,0,0,0.3);border-radius:8px;font-family:monospace;font-size:11px;color:#00d4aa;max-height:180px;overflow-y:auto;display:none;white-space:pre-wrap;word-break:break-all}
.ft{text-align:center;padding:12px}.ft-b{display:inline-flex;align-items:center;gap:6px;font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(20,184,166,0.04);border:1px solid rgba(20,184,166,0.08)}
@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}
</style></head><body>
<div class="c">
<div class="hdr"><div><h1>🔥 Fiber Dashboard</h1><p>超快 Go Web 框架</p></div><div class="badge"><span class="dot"></span>运行中</div></div>
<div class="row">
<div class="m"><div class="m-val">%s</div><div class="m-label">运行时间</div></div>
<div class="m"><div class="m-val">%dMB</div><div class="m-label">内存</div></div>
<div class="m"><div class="m-val">%d</div><div class="m-label">协程</div></div>
</div>
<div class="card"><div class="card-h"><span class="card-t">性能指标</span><span class="card-b">⚡ 实时</span></div>%s</div>
<div class="card"><div class="card-h"><span class="card-t">吞吐趋势</span><span class="card-b">📊 本周</span></div><div class="chart">%s</div></div>
<div class="card"><div class="card-h"><span class="card-t">在线测试</span><span class="card-b">🧪 交互</span></div>
<div style="display:flex;gap:8px"><button class="try" onclick="tryApi('/api/users')">👥 用户列表</button><button class="try" onclick="tryApi('/api/health')">💚 健康检查</button></div>
<div class="result" id="r"></div></div>
<div class="ft"><div class="ft-b"><span class="dot" style="width:5px;height:5px;border-radius:50%%;background:#00d4aa;box-shadow:0 0 6px #00d4aa"></span>Fiber v2 · Go %s · 运行中</div></div>
</div>
<script>function tryApi(u){var r=document.getElementById('r');r.style.display='block';r.textContent='⏳ ...';fetch(u).then(function(x){return x.json()}).then(function(d){r.textContent=JSON.stringify(d,null,2)}).catch(function(e){r.textContent='❌ '+e.message})}</script>
</body></html>`, uptime, memMB, goroutines, metricHTML, bars, runtime.Version())
c.Set("Content-Type", "text/html; charset=utf-8")
return c.SendString(html)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/gin-api
go 1.21
require github.com/gin-gonic/gin v1.9.1

View File

@ -0,0 +1,25 @@
package main
// Gin REST API Dashboard — Go Web (AR)
import ("fmt";"math/rand";"net/http";"os";"time";"github.com/gin-gonic/gin")
type Item struct { ID int `json:"id"`; Name string `json:"name"`; Price float64 `json:"price"` }
var items = []Item{{1,"MacBook Pro",1999},{2,"iPhone 15",999},{3,"AirPods Pro",249},{4,"iPad Air",599}}
var startTime = time.Now()
func main() {
gin.SetMode(gin.ReleaseMode); r := gin.Default()
r.GET("/", func(c *gin.Context) { c.Header("Content-Type","text/html; charset=utf-8"); c.String(http.StatusOK, dash()) })
r.GET("/api/items", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"data":items}) })
r.GET("/api/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status":"healthy","uptime":time.Since(startTime).String()}) })
port := os.Getenv("PORT"); if port == "" { port = "8080" }; r.Run(":"+port)
}
func dash() string {
up := time.Since(startTime).Round(time.Second); bars := ""
days := []string{"إثنين","ثلاثاء","أربعاء","خميس","جمعة","سبت","أحد"}
for i,d := range days { h:=20+rand.Intn(70); bars+=fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%.2fs"></div><div class="bl">%s</div></div>`,h,0.3+float64(i)*0.08,d) }
return fmt.Sprintf(`<!DOCTYPE html><html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Gin</title><style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#080818;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--blue:#00b4d8;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,Tahoma,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;direction:rtl}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#00b4d8,#48cae4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2)}.badge{font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(0,180,216,0.1);color:var(--blue)}.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}.m-val{font-size:20px;font-weight:800}.m-label{font-size:10px;color:var(--text2)}.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--blue)}.m:nth-child(3) .m-val{color:#8b5cf6}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(0,180,216,0.1);color:var(--blue)}.chart{display:flex;align-items:flex-end;justify-content:space-between;height:120px;gap:6px;direction:ltr}.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#48cae4,#0096c7);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}.bl{font-size:9px;color:var(--text3)}.try{background:rgba(0,180,216,0.1);color:var(--blue);border:1px solid rgba(0,180,216,0.2);padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer}.result{margin-top:10px;padding:12px;background:rgba(0,0,0,0.3);border-radius:8px;font-family:monospace;font-size:11px;color:#00d4aa;max-height:180px;overflow-y:auto;display:none;white-space:pre-wrap}.ft{text-align:center;padding:12px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(0,180,216,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>🚀 Gin لوحة</h1><p>إطار عمل Go عالي الأداء</p></div><div class="badge">يعمل</div></div>
<div class="row"><div class="m"><div class="m-val">%s</div><div class="m-label">وقت التشغيل</div></div><div class="m"><div class="m-val">%d</div><div class="m-label">المنتجات</div></div><div class="m"><div class="m-val">4</div><div class="m-label">المسارات</div></div></div>
<div class="card"><div class="card-h"><span class="card-t">اتجاه الزيارات</span><span class="card-b">📊 أسبوعي</span></div><div class="chart">%s</div></div>
<div class="card"><div class="card-h"><span class="card-t">اختبار مباشر</span><span class="card-b">🧪</span></div><div style="display:flex;gap:8px"><button class="try" onclick="tryApi('/api/items')">📦 المنتجات</button><button class="try" onclick="tryApi('/api/health')">💚 الصحة</button></div><div class="result" id="r"></div></div>
<div class="ft"><div class="ft-b">Gin · Go 1.22 · يعمل</div></div></div>
<script>function tryApi(u){var r=document.getElementById('r');r.style.display='block';r.textContent='⏳';fetch(u).then(function(x){return x.json()}).then(function(d){r.textContent=JSON.stringify(d,null,2)}).catch(function(e){r.textContent='❌ '+e.message})}</script></body></html>`,up,len(items),bars)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/gin-api
go 1.21
require github.com/gin-gonic/gin v1.9.1

View File

@ -0,0 +1,52 @@
package main
// Gin REST API Dashboard — Go Web App Demo (EN)
import (
"fmt"
"math/rand"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
var items = []Item{{1, "MacBook Pro", 1999.00}, {2, "iPhone 15", 999.00}, {3, "AirPods Pro", 249.00}, {4, "iPad Air", 599.00}}
var startTime = time.Now()
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.GET("/", func(c *gin.Context) { c.Header("Content-Type", "text/html; charset=utf-8"); c.String(http.StatusOK, dashboard()) })
api := r.Group("/api")
{
api.GET("/items", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "data": items}) })
api.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "healthy", "uptime": time.Since(startTime).String()}) })
}
port := os.Getenv("PORT"); if port == "" { port = "8080" }
r.Run(":" + port)
}
func dashboard() string {
up := time.Since(startTime).Round(time.Second)
bars := ""
days := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
for i, d := range days {
h := 20 + rand.Intn(70)
bars += fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%.2fs"></div><div class="bl">%s</div></div>`, h, 0.3+float64(i)*0.08, d)
}
return fmt.Sprintf(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Gin Dashboard</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#080818;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--blue:#00b4d8;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}body::before{content:'';position:fixed;top:-40%%;left:-40%%;width:180%%;height:180%%;background:radial-gradient(ellipse at 50%% 30%%,rgba(0,180,216,0.06) 0%%,transparent 55%%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#00b4d8,#48cae4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2);margin-top:2px}.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(0,180,216,0.1);color:var(--blue);border:1px solid rgba(0,180,216,0.15)}.badge .dot{width:7px;height:7px;border-radius:50%%;background:var(--blue);box-shadow:0 0 10px var(--blue);animation:p 2s infinite}@keyframes p{0%%,100%%{opacity:1}50%%{opacity:.5}}.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}.m-val{font-size:20px;font-weight:800;margin-bottom:2px}.m-label{font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px}.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--blue)}.m:nth-child(3) .m-val{color:#8b5cf6}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px}.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(0,180,216,0.1);color:var(--blue)}.chart{display:flex;align-items:flex-end;justify-content:space-between;height:130px;gap:6px}.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#48cae4,#0096c7);box-shadow:0 0 10px rgba(0,180,216,0.2);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}.bl{font-size:9px;color:var(--text3);font-weight:500}.try{background:rgba(0,180,216,0.1);color:var(--blue);border:1px solid rgba(0,180,216,0.2);padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer}.result{margin-top:10px;padding:12px;background:rgba(0,0,0,0.3);border-radius:8px;font-family:monospace;font-size:11px;color:#00d4aa;max-height:180px;overflow-y:auto;display:none;white-space:pre-wrap;word-break:break-all}.ft{text-align:center;padding:12px}.ft-b{display:inline-flex;align-items:center;gap:6px;font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(0,180,216,0.04);border:1px solid rgba(0,180,216,0.08)}@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>🚀 Gin Dashboard</h1><p>High-Performance Go Web Framework</p></div><div class="badge"><span class="dot"></span>Running</div></div>
<div class="row"><div class="m"><div class="m-val">%s</div><div class="m-label">Uptime</div></div><div class="m"><div class="m-val">%d</div><div class="m-label">Items</div></div><div class="m"><div class="m-val">4</div><div class="m-label">Routes</div></div></div>
<div class="card"><div class="card-h"><span class="card-t">Traffic Trends</span><span class="card-b">📊 Weekly</span></div><div class="chart">%s</div></div>
<div class="card"><div class="card-h"><span class="card-t">Live Testing</span><span class="card-b">🧪 Try It</span></div><div style="display:flex;gap:8px"><button class="try" onclick="tryApi('/api/items')">📦 Items</button><button class="try" onclick="tryApi('/api/health')">💚 Health</button></div><div class="result" id="r"></div></div>
<div class="ft"><div class="ft-b"><span class="dot" style="width:5px;height:5px;border-radius:50%%;background:var(--blue);box-shadow:0 0 6px var(--blue)"></span>Gin · Go 1.22 · Running</div></div></div>
<script>function tryApi(u){var r=document.getElementById('r');r.style.display='block';r.textContent='⏳ ...';fetch(u).then(function(x){return x.json()}).then(function(d){r.textContent=JSON.stringify(d,null,2)}).catch(function(e){r.textContent='❌ '+e.message})}</script>
</body></html>`, up, len(items), bars)
}

View File

@ -0,0 +1,5 @@
module github.com/demo/gin-api
go 1.21
require github.com/gin-gonic/gin v1.9.1

View File

@ -0,0 +1,137 @@
package main
// Gin REST API 仪表盘 — 精美的 Go Web 应用示例
import (
"fmt"
"math/rand"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
var items = []Item{
{1, "MacBook Pro", 1999.00},
{2, "iPhone 15", 999.00},
{3, "AirPods Pro", 249.00},
{4, "iPad Air", 599.00},
}
var startTime = time.Now()
func main() {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, getDashboardHTML())
})
api := r.Group("/api")
{
api.GET("/items", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "data": items, "count": len(items)})
})
api.GET("/items/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "data": items[0]})
})
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"framework": "Gin",
"uptime": time.Since(startTime).String(),
"go": "1.22",
})
})
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
r.Run(":" + port)
}
func getDashboardHTML() string {
uptime := time.Since(startTime).Round(time.Second)
bars := ""
days := []string{"周一", "周二", "周三", "周四", "周五", "周六", "周日"}
for i, d := range days {
h := 20 + rand.Intn(70)
delay := fmt.Sprintf("%.2f", 0.3+float64(i)*0.08)
bars += fmt.Sprintf(`<div class="bg"><div class="b" style="height:%d%%;animation-delay:%ss"></div><div class="bl">%s</div></div>`, h, delay, d)
}
routes := []struct{ method, path, desc, ms string }{
{"GET", "/", "仪表盘首页", "<1ms"},
{"GET", "/api/items", "获取商品列表", "~1ms"},
{"GET", "/api/items/:id", "获取商品详情", "~1ms"},
{"GET", "/api/health", "健康检查", "<1ms"},
}
routeHTML := ""
for _, r := range routes {
color := "#00d4aa"
routeHTML += fmt.Sprintf(`<div class="ep"><span class="mt" style="background:%s18;color:%s">%s</span><div class="ei"><div class="ep-p">%s</div><div class="ep-d">%s</div></div><div class="ep-ms">%s</div></div>`, color, color, r.method, r.path, r.desc, r.ms)
}
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Gin Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#080818;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--blue:#00b4d8;--blue2:#0096c7;--radius:14px}
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
body::before{content:'';position:fixed;top:-40%%;left:-40%%;width:180%%;height:180%%;background:radial-gradient(ellipse at 50%% 30%%,rgba(0,180,216,0.06) 0%%,transparent 55%%);pointer-events:none}
.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}
.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}
.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#00b4d8,#48cae4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr p{font-size:12px;color:var(--text2);margin-top:2px}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(0,180,216,0.1);color:var(--blue);border:1px solid rgba(0,180,216,0.15)}
.badge .dot{width:7px;height:7px;border-radius:50%%;background:var(--blue);box-shadow:0 0 10px var(--blue);animation:p 2s infinite}
@keyframes p{0%%,100%%{opacity:1}50%%{opacity:.5}}
.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}
.m{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center;backdrop-filter:blur(16px)}
.m-val{font-size:20px;font-weight:800;letter-spacing:-0.5px;margin-bottom:2px}
.m-label{font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;font-weight:500}
.m:nth-child(1) .m-val{color:#00d4aa}.m:nth-child(2) .m-val{color:var(--blue)}.m:nth-child(3) .m-val{color:#8b5cf6}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;margin-bottom:14px;backdrop-filter:blur(16px)}
.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
.card-t{font-size:15px;font-weight:700}
.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(0,180,216,0.1);color:var(--blue)}
.ep{display:flex;align-items:center;gap:10px;padding:11px 0;border-bottom:1px solid rgba(255,255,255,0.03)}.ep:last-child{border:none}
.mt{font-size:10px;font-weight:800;padding:3px 8px;border-radius:6px;min-width:36px;text-align:center}
.ei{flex:1;min-width:0}.ep-p{font-size:13px;font-weight:600;font-family:monospace}.ep-d{font-size:11px;color:var(--text2)}.ep-ms{font-size:11px;color:var(--text3);font-family:monospace}
.chart{display:flex;align-items:flex-end;justify-content:space-between;height:130px;gap:6px}
.bg{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%%;justify-content:flex-end}
.b{width:100%%;max-width:28px;border-radius:5px 5px 3px 3px;background:linear-gradient(180deg,#48cae4,#0096c7);box-shadow:0 0 10px rgba(0,180,216,0.2);transform-origin:bottom;animation:grow .7s cubic-bezier(.34,1.56,.64,1) backwards}
@keyframes grow{from{transform:scaleY(0)}to{transform:scaleY(1)}}
.bl{font-size:9px;color:var(--text3);font-weight:500}
.try{background:rgba(0,180,216,0.1);color:var(--blue);border:1px solid rgba(0,180,216,0.2);padding:6px 14px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer}.try:active{transform:scale(.95)}
.result{margin-top:10px;padding:12px;background:rgba(0,0,0,0.3);border-radius:8px;font-family:monospace;font-size:11px;color:#00d4aa;max-height:180px;overflow-y:auto;display:none;white-space:pre-wrap;word-break:break-all}
.ft{text-align:center;padding:12px}.ft-b{display:inline-flex;align-items:center;gap:6px;font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(0,180,216,0.04);border:1px solid rgba(0,180,216,0.08)}
@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.m,.card{animation:fadeIn .5s ease backwards}
</style></head><body>
<div class="c">
<div class="hdr"><div><h1>🚀 Gin Dashboard</h1><p>高性能 Go Web 框架</p></div><div class="badge"><span class="dot"></span>运行中</div></div>
<div class="row">
<div class="m"><div class="m-val">%s</div><div class="m-label">运行时间</div></div>
<div class="m"><div class="m-val">%d</div><div class="m-label">商品数</div></div>
<div class="m"><div class="m-val">4</div><div class="m-label">路由数</div></div>
</div>
<div class="card"><div class="card-h"><span class="card-t">API 路由</span><span class="card-b">⚡ RESTful</span></div>%s</div>
<div class="card"><div class="card-h"><span class="card-t">请求趋势</span><span class="card-b">📊 本周</span></div><div class="chart">%s</div></div>
<div class="card"><div class="card-h"><span class="card-t">在线测试</span><span class="card-b">🧪 交互</span></div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button class="try" onclick="tryApi('/api/items')">📦 商品列表</button><button class="try" onclick="tryApi('/api/health')">💚 健康检查</button></div>
<div class="result" id="r"></div></div>
<div class="ft"><div class="ft-b"><span class="dot" style="width:5px;height:5px;border-radius:50%%;background:var(--blue);box-shadow:0 0 6px var(--blue)"></span>Gin · Go 1.22 · 运行中</div></div>
</div>
<script>function tryApi(u){var r=document.getElementById('r');r.style.display='block';r.textContent='⏳ ...';var t=performance.now();fetch(u).then(function(x){return x.json()}).then(function(d){r.textContent=JSON.stringify(d,null,2)+'\n⏱️ '+Math.round(performance.now()-t)+'ms'}).catch(function(e){r.textContent='❌ '+e.message})}</script>
</body></html>`, uptime, len(items), routeHTML, bars)
}

View File

@ -0,0 +1,569 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>مستكشف المقاعد</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #10b981;
--primary-dark: #059669;
--bg: #0f172a;
--card: rgba(15, 23, 42, 0.92);
--card-border: rgba(16, 185, 129, 0.15);
--text: #f1f5f9;
--text2: #94a3b8;
--danger: #ef4444;
--warning: #f59e0b;
--radius: 16px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
width: 100vw;
overflow: hidden;
position: relative;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
.leaflet-control-zoom a {
background: var(--card) !important;
color: var(--text) !important;
border: 1px solid var(--card-border) !important;
backdrop-filter: blur(20px);
}
.leaflet-control-zoom a:hover {
background: var(--primary-dark) !important;
color: white !important;
}
.leaflet-control-attribution {
background: rgba(15, 23, 42, 0.7) !important;
color: var(--text2) !important;
font-size: 9px !important;
}
.leaflet-control-attribution a {
color: var(--primary) !important;
}
.top-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 12px 16px;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.95) 0%, rgba(15, 23, 42, 0.7) 70%, transparent 100%);
pointer-events: none;
}
.top-bar>* {
pointer-events: auto;
}
.app-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.app-logo {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), #34d399);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.app-title {
font-size: 18px;
font-weight: 800;
background: linear-gradient(135deg, #10b981, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.app-subtitle {
font-size: 11px;
color: var(--text2);
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
backdrop-filter: blur(20px);
border: 1px solid var(--card-border);
background: var(--card);
animation: fadeInDown 0.5s ease;
}
.status-pill .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
animation: pulse 2s ease-in-out infinite;
}
.status-pill.searching .dot {
background: var(--warning);
}
.status-pill.error .dot {
background: var(--danger);
animation: none;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 0 12px 16px;
pointer-events: none;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.bottom-sheet.hidden {
transform: translateY(calc(100% + 20px));
}
.bottom-sheet>* {
pointer-events: auto;
}
.info-card {
background: var(--card);
backdrop-filter: blur(30px);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 18px;
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.4);
animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.info-card .handle {
width: 36px;
height: 4px;
background: rgba(148, 163, 184, 0.3);
border-radius: 2px;
margin: 0 auto 14px;
}
.bench-info {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
.bench-icon-box {
width: 52px;
height: 52px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(52, 211, 153, 0.1));
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.bench-name {
font-size: 16px;
font-weight: 700;
}
.bench-detail {
font-size: 12px;
color: var(--text2);
margin-top: 2px;
}
.stats-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.stat-item {
flex: 1;
background: rgba(16, 185, 129, 0.06);
border: 1px solid rgba(16, 185, 129, 0.1);
border-radius: 12px;
padding: 10px;
text-align: center;
}
.stat-val {
font-size: 18px;
font-weight: 800;
color: var(--primary);
}
.stat-label {
font-size: 10px;
color: var(--text2);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
background: linear-gradient(135deg, var(--primary), #34d399);
color: white;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.nav-btn:active {
transform: scale(0.97);
}
.nav-btn.secondary {
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: none;
color: var(--text2);
margin-top: 8px;
}
.locate-btn {
position: fixed;
left: 16px;
bottom: 200px;
z-index: 1000;
width: 48px;
height: 48px;
background: var(--card);
backdrop-filter: blur(20px);
border: 1px solid var(--card-border);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.locate-btn:active {
transform: scale(0.9);
}
.locate-btn.locating {
border-color: var(--primary);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
.loading-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
transition: opacity 0.5s ease, visibility 0.5s ease;
}
.loading-overlay.hide {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(16, 185, 129, 0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 14px;
color: var(--text2);
animation: fadeInDown 0.5s ease 0.3s backwards;
}
.custom-marker {
background: none;
border: none;
}
.marker-pin {
width: 36px;
height: 36px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.marker-pin.bench {
background: linear-gradient(135deg, var(--primary), #34d399);
}
.marker-pin.user {
background: linear-gradient(135deg, #3b82f6, #60a5fa);
}
.marker-pin.nearest {
background: linear-gradient(135deg, var(--warning), #fbbf24);
animation: marker-bounce 0.6s ease;
}
.marker-pin span {
transform: rotate(45deg);
font-size: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes marker-bounce {
0% {
transform: rotate(-45deg) scale(0);
}
60% {
transform: rotate(-45deg) scale(1.2);
}
100% {
transform: rotate(-45deg) scale(1);
}
}
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="app-logo" style="width:64px;height:64px;font-size:32px;border-radius:18px;">🪑</div>
<div
style="font-size:22px;font-weight:800;background:linear-gradient(135deg,#10b981,#34d399);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">
مستكشف المقاعد</div>
<div class="loading-spinner"></div>
<div class="loading-text">جاري تحميل الخريطة...</div>
</div>
<div id="map"></div>
<div class="top-bar">
<div class="app-header">
<div class="app-logo">🪑</div>
<div>
<div class="app-title">مستكشف المقاعد</div>
<div class="app-subtitle">ابحث عن أقرب مقعد للراحة</div>
</div>
</div>
<div class="status-pill searching" id="statusPill">
<div class="dot"></div>
<span id="statusText">جاري تحديد الموقع...</span>
</div>
</div>
<div class="locate-btn" id="locateBtn" onclick="locateMe()">📍</div>
<div class="bottom-sheet hidden" id="bottomSheet">
<div class="info-card">
<div class="handle"></div>
<div class="bench-info">
<div class="bench-icon-box">🪑</div>
<div>
<div class="bench-name" id="benchName">أقرب مقعد</div>
<div class="bench-detail" id="benchDetail">جاري البحث...</div>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<div class="stat-val" id="statDist">--</div>
<div class="stat-label">المسافة</div>
</div>
<div class="stat-item">
<div class="stat-val" id="statTime">--</div>
<div class="stat-label">المشي</div>
</div>
<div class="stat-item">
<div class="stat-val" id="statTotal">--</div>
<div class="stat-label">القريبة</div>
</div>
</div>
<button class="nav-btn" id="navBtn" onclick="navigateToNearest()">🧭 بدء الملاحة</button>
<button class="nav-btn secondary" onclick="searchNearby()">🔄 بحث مرة أخرى</button>
</div>
</div>
<script>
let map, userMarker, routeLine, benchMarkers = [];
let userLat, userLng, nearestBench = null, allBenches = [];
function initMap() {
map = L.map('map', { center: [48.8566, 2.3522], zoom: 15, zoomControl: false, attributionControl: true });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', maxZoom: 19 }).addTo(map);
L.control.zoom({ position: 'topleft' }).addTo(map);
setTimeout(() => { document.getElementById('loadingOverlay').classList.add('hide'); }, 1000);
locateMe();
}
function locateMe() {
const btn = document.getElementById('locateBtn'); btn.classList.add('locating');
updateStatus('searching', 'جاري تحديد الموقع...');
if (!navigator.geolocation) { updateStatus('error', 'تحديد الموقع غير مدعوم'); btn.classList.remove('locating'); onLocationFound(48.8584, 2.2945); return; }
navigator.geolocation.getCurrentPosition(
(pos) => { btn.classList.remove('locating'); onLocationFound(pos.coords.latitude, pos.coords.longitude); },
() => { btn.classList.remove('locating'); updateStatus('error', 'فشل تحديد الموقع'); onLocationFound(48.8584, 2.2945); },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function onLocationFound(lat, lng) {
userLat = lat; userLng = lng; map.setView([lat, lng], 16);
if (userMarker) map.removeLayer(userMarker);
userMarker = L.marker([lat, lng], { icon: L.divIcon({ className: 'custom-marker', html: '<div class="marker-pin user"><span>📱</span></div>', iconSize: [36, 36], iconAnchor: [18, 36] }) }).addTo(map).bindPopup('<b>📱 موقعي</b>');
L.circle([lat, lng], { radius: 50, color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.08, weight: 1, opacity: 0.3 }).addTo(map);
searchNearby();
}
function searchNearby() {
if (!userLat || !userLng) { locateMe(); return; }
updateStatus('searching', 'جاري البحث عن مقاعد...');
document.getElementById('bottomSheet').classList.add('hidden');
benchMarkers.forEach(m => map.removeLayer(m)); benchMarkers = [];
if (routeLine) map.removeLayer(routeLine);
const query = `[out:json][timeout:15];(node["amenity"="bench"](around:500,${userLat},${userLng});node["leisure"="picnic_table"](around:500,${userLat},${userLng}););out body;`;
fetch('https://overpass-api.de/api/interpreter?data=' + encodeURIComponent(query))
.then(r => r.json()).then(d => { allBenches = d.elements || []; if (!allBenches.length) allBenches = genBenches(userLat, userLng); displayBenches(allBenches); })
.catch(() => { allBenches = genBenches(userLat, userLng); displayBenches(allBenches); });
}
function genBenches(lat, lng) {
const n = ['مقعد الحديقة', 'مقعد الشارع', 'مقعد الحديقة العامة', 'مقعد البحيرة', 'مقعد الساحة', 'مقعد الظل', 'مقعد المسار', 'مقعد المنظر', 'مقعد المجتمع', 'مقعد النهر'];
const b = []; for (let i = 0; i < 10 + Math.floor(Math.random() * 6); i++) {
const a = Math.random() * Math.PI * 2, d = 50 + Math.random() * 450;
b.push({ lat: lat + (d / 111320) * Math.cos(a), lon: lng + (d / (111320 * Math.cos(lat * Math.PI / 180))) * Math.sin(a), tags: { name: n[i % n.length], amenity: 'bench', material: ['خشب', 'معدن', 'حجر', 'خرسانة'][Math.floor(Math.random() * 4)], backrest: Math.random() > 0.3 ? 'yes' : 'no' } });
} return b;
}
function displayBenches(benches) {
if (!benches.length) { updateStatus('error', 'لم يتم العثور على مقاعد'); return; }
let nearest = null, minDist = Infinity;
benches.forEach((b, i) => {
const dist = getDist(userLat, userLng, b.lat, b.lon);
if (dist < minDist) { minDist = dist; nearest = { ...b, distance: dist, index: i }; }
benchMarkers.push(L.marker([b.lat, b.lon], { icon: L.divIcon({ className: 'custom-marker', html: '<div class="marker-pin bench"><span>🪑</span></div>', iconSize: [36, 36], iconAnchor: [18, 36] }) }).addTo(map).bindPopup('<b>🪑 ' + (b.tags?.name || 'مقعد') + '</b><br>📏 ' + fmtDist(dist)));
});
if (nearest) {
nearestBench = nearest;
if (benchMarkers[nearest.index]) map.removeLayer(benchMarkers[nearest.index]);
benchMarkers[nearest.index] = L.marker([nearest.lat, nearest.lon], { icon: L.divIcon({ className: 'custom-marker', html: '<div class="marker-pin nearest"><span>⭐</span></div>', iconSize: [40, 40], iconAnchor: [20, 40] }) }).addTo(map).bindPopup('<b>⭐ أقرب مقعد</b><br>' + fmtDist(nearest.distance)).openPopup();
drawRoute(userLat, userLng, nearest.lat, nearest.lon);
document.getElementById('benchName').textContent = nearest.tags?.name || 'مقعد عام';
document.getElementById('benchDetail').textContent = 'المادة: ' + (nearest.tags?.material || 'غير معروف');
document.getElementById('statDist').textContent = fmtDist(nearest.distance);
document.getElementById('statTime').textContent = fmtTime(nearest.distance);
document.getElementById('statTotal').textContent = benches.length;
document.getElementById('bottomSheet').classList.remove('hidden');
updateStatus('', 'تم العثور على ' + benches.length + ' مقعد');
map.fitBounds(L.latLngBounds([userLat, userLng], [nearest.lat, nearest.lon]).pad(0.3));
}
}
function drawRoute(fLat, fLng, tLat, tLng) {
if (routeLine) map.removeLayer(routeLine);
fetch(`https://router.project-osrm.org/route/v1/foot/${fLng},${fLat};${tLng},${tLat}?overview=full&geometries=geojson`)
.then(r => r.json()).then(d => {
if (d.routes?.length) {
const c = d.routes[0].geometry.coordinates.map(p => [p[1], p[0]]);
L.polyline(c, { color: '#064e3b', weight: 8, opacity: 0.4, lineCap: 'round' }).addTo(map);
routeLine = L.polyline(c, { color: '#10b981', weight: 5, opacity: 0.9, lineCap: 'round', dashArray: '12, 8' }).addTo(map);
if (d.routes[0].distance) document.getElementById('statDist').textContent = fmtDist(d.routes[0].distance);
if (d.routes[0].duration) document.getElementById('statTime').textContent = Math.ceil(d.routes[0].duration / 60) + 'د';
}
}).catch(() => { routeLine = L.polyline([[fLat, fLng], [tLat, tLng]], { color: '#10b981', weight: 4, opacity: 0.7, dashArray: '10,10' }).addTo(map); });
}
function navigateToNearest() { if (nearestBench) { map.setView([nearestBench.lat, nearestBench.lon], 18); benchMarkers[nearestBench.index]?.openPopup(); } }
function getDist(a, b, c, d) { const R = 6371000, dL = (c - a) * Math.PI / 180, dG = (d - b) * Math.PI / 180, x = Math.sin(dL / 2) ** 2 + Math.cos(a * Math.PI / 180) * Math.cos(c * Math.PI / 180) * Math.sin(dG / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)); }
function fmtDist(m) { return m < 1000 ? Math.round(m) + 'م' : (m / 1000).toFixed(1) + 'كم'; }
function fmtTime(m) { const min = Math.ceil(m / 80); return min < 1 ? '<1د' : min + 'د'; }
function updateStatus(t, x) { document.getElementById('statusPill').className = 'status-pill' + (t ? ' ' + t : ''); document.getElementById('statusText').textContent = x; }
initMap();
</script>
</body>
</html>

View File

@ -0,0 +1,639 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Bench Explorer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #10b981;
--primary-dark: #059669;
--bg: #0f172a;
--card: rgba(15, 23, 42, 0.92);
--card-border: rgba(16, 185, 129, 0.15);
--text: #f1f5f9;
--text2: #94a3b8;
--danger: #ef4444;
--warning: #f59e0b;
--radius: 16px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
width: 100vw;
overflow: hidden;
position: relative;
}
#map {
width: 100%;
height: 100%;
z-index: 1;
}
.leaflet-control-zoom a {
background: var(--card) !important;
color: var(--text) !important;
border: 1px solid var(--card-border) !important;
backdrop-filter: blur(20px);
}
.leaflet-control-zoom a:hover {
background: var(--primary-dark) !important;
color: white !important;
}
.leaflet-control-attribution {
background: rgba(15, 23, 42, 0.7) !important;
color: var(--text2) !important;
font-size: 9px !important;
backdrop-filter: blur(10px);
}
.leaflet-control-attribution a {
color: var(--primary) !important;
}
.top-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 12px 16px;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.95) 0%, rgba(15, 23, 42, 0.7) 70%, transparent 100%);
pointer-events: none;
}
.top-bar>* {
pointer-events: auto;
}
.app-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.app-logo {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), #34d399);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.app-title {
font-size: 18px;
font-weight: 800;
background: linear-gradient(135deg, #10b981, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.app-subtitle {
font-size: 11px;
color: var(--text2);
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
backdrop-filter: blur(20px);
border: 1px solid var(--card-border);
background: var(--card);
animation: fadeInDown 0.5s ease;
}
.status-pill .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
animation: pulse 2s ease-in-out infinite;
}
.status-pill.searching .dot {
background: var(--warning);
}
.status-pill.error .dot {
background: var(--danger);
animation: none;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
padding: 0 12px 16px;
pointer-events: none;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.bottom-sheet.hidden {
transform: translateY(calc(100% + 20px));
}
.bottom-sheet>* {
pointer-events: auto;
}
.info-card {
background: var(--card);
backdrop-filter: blur(30px);
border: 1px solid var(--card-border);
border-radius: var(--radius);
padding: 18px;
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.4);
animation: slideUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.info-card .handle {
width: 36px;
height: 4px;
background: rgba(148, 163, 184, 0.3);
border-radius: 2px;
margin: 0 auto 14px;
}
.bench-info {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
.bench-icon-box {
width: 52px;
height: 52px;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(52, 211, 153, 0.1));
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
}
.bench-name {
font-size: 16px;
font-weight: 700;
}
.bench-detail {
font-size: 12px;
color: var(--text2);
margin-top: 2px;
}
.stats-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.stat-item {
flex: 1;
background: rgba(16, 185, 129, 0.06);
border: 1px solid rgba(16, 185, 129, 0.1);
border-radius: 12px;
padding: 10px;
text-align: center;
}
.stat-val {
font-size: 18px;
font-weight: 800;
color: var(--primary);
}
.stat-label {
font-size: 10px;
color: var(--text2);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
background: linear-gradient(135deg, var(--primary), #34d399);
color: white;
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
}
.nav-btn:active {
transform: scale(0.97);
}
.nav-btn.secondary {
background: rgba(148, 163, 184, 0.1);
border: 1px solid rgba(148, 163, 184, 0.2);
box-shadow: none;
color: var(--text2);
margin-top: 8px;
}
.locate-btn {
position: fixed;
right: 16px;
bottom: 200px;
z-index: 1000;
width: 48px;
height: 48px;
background: var(--card);
backdrop-filter: blur(20px);
border: 1px solid var(--card-border);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
}
.locate-btn:active {
transform: scale(0.9);
}
.locate-btn.locating {
border-color: var(--primary);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
.loading-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
transition: opacity 0.5s ease, visibility 0.5s ease;
}
.loading-overlay.hide {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(16, 185, 129, 0.2);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
font-size: 14px;
color: var(--text2);
animation: fadeInDown 0.5s ease 0.3s backwards;
}
.custom-marker {
background: none;
border: none;
}
.marker-pin {
width: 36px;
height: 36px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.marker-pin.bench {
background: linear-gradient(135deg, var(--primary), #34d399);
}
.marker-pin.user {
background: linear-gradient(135deg, #3b82f6, #60a5fa);
}
.marker-pin.nearest {
background: linear-gradient(135deg, var(--warning), #fbbf24);
animation: marker-bounce 0.6s ease;
}
.marker-pin span {
transform: rotate(45deg);
font-size: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes marker-bounce {
0% {
transform: rotate(-45deg) scale(0);
}
60% {
transform: rotate(-45deg) scale(1.2);
}
100% {
transform: rotate(-45deg) scale(1);
}
}
</style>
</head>
<body>
<div class="loading-overlay" id="loadingOverlay">
<div class="app-logo" style="width:64px;height:64px;font-size:32px;border-radius:18px;">🪑</div>
<div
style="font-size:22px;font-weight:800;background:linear-gradient(135deg,#10b981,#34d399);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">
Bench Explorer</div>
<div class="loading-spinner"></div>
<div class="loading-text">Loading map...</div>
</div>
<div id="map"></div>
<div class="top-bar">
<div class="app-header">
<div class="app-logo">🪑</div>
<div>
<div class="app-title">Bench Explorer</div>
<div class="app-subtitle">Find the nearest bench to rest</div>
</div>
</div>
<div class="status-pill searching" id="statusPill">
<div class="dot"></div>
<span id="statusText">Getting location...</span>
</div>
</div>
<div class="locate-btn" id="locateBtn" onclick="locateMe()">📍</div>
<div class="bottom-sheet hidden" id="bottomSheet">
<div class="info-card">
<div class="handle"></div>
<div class="bench-info">
<div class="bench-icon-box">🪑</div>
<div>
<div class="bench-name" id="benchName">Nearest Bench</div>
<div class="bench-detail" id="benchDetail">Searching...</div>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<div class="stat-val" id="statDist">--</div>
<div class="stat-label">Distance</div>
</div>
<div class="stat-item">
<div class="stat-val" id="statTime">--</div>
<div class="stat-label">Walk</div>
</div>
<div class="stat-item">
<div class="stat-val" id="statTotal">--</div>
<div class="stat-label">Nearby</div>
</div>
</div>
<button class="nav-btn" id="navBtn" onclick="navigateToNearest()">
🧭 Start Navigation
</button>
<button class="nav-btn secondary" onclick="searchNearby()">
🔄 Search Again
</button>
</div>
</div>
<script>
let map, userMarker, routeLine, benchMarkers = [];
let userLat, userLng;
let nearestBench = null;
let allBenches = [];
function initMap() {
map = L.map('map', {
center: [48.8566, 2.3522],
zoom: 15,
zoomControl: false,
attributionControl: true
});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}).addTo(map);
L.control.zoom({ position: 'topright' }).addTo(map);
setTimeout(() => { document.getElementById('loadingOverlay').classList.add('hide'); }, 1000);
locateMe();
}
function locateMe() {
const btn = document.getElementById('locateBtn');
btn.classList.add('locating');
updateStatus('searching', 'Getting location...');
if (!navigator.geolocation) {
updateStatus('error', 'Geolocation not supported');
btn.classList.remove('locating');
onLocationFound(48.8584, 2.2945);
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => { btn.classList.remove('locating'); onLocationFound(pos.coords.latitude, pos.coords.longitude); },
(err) => { btn.classList.remove('locating'); updateStatus('error', 'Location failed, using default'); onLocationFound(48.8584, 2.2945); },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
);
}
function onLocationFound(lat, lng) {
userLat = lat; userLng = lng;
map.setView([lat, lng], 16);
if (userMarker) map.removeLayer(userMarker);
const userIcon = L.divIcon({ className: 'custom-marker', html: '<div class="marker-pin user"><span>📱</span></div>', iconSize: [36, 36], iconAnchor: [18, 36] });
userMarker = L.marker([lat, lng], { icon: userIcon }).addTo(map).bindPopup('<b>📱 My Location</b><br>Lat: ' + lat.toFixed(6) + '<br>Lng: ' + lng.toFixed(6));
L.circle([lat, lng], { radius: 50, color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.08, weight: 1, opacity: 0.3 }).addTo(map);
searchNearby();
}
function searchNearby() {
if (!userLat || !userLng) { locateMe(); return; }
updateStatus('searching', 'Searching for benches...');
document.getElementById('bottomSheet').classList.add('hidden');
benchMarkers.forEach(m => map.removeLayer(m));
benchMarkers = [];
if (routeLine) map.removeLayer(routeLine);
const query = `[out:json][timeout:15];(node["amenity"="bench"](around:500,${userLat},${userLng});node["leisure"="picnic_table"](around:500,${userLat},${userLng}););out body;`;
fetch('https://overpass-api.de/api/interpreter?data=' + encodeURIComponent(query))
.then(res => res.json())
.then(data => {
allBenches = data.elements || [];
if (allBenches.length === 0) { allBenches = generateSimulatedBenches(userLat, userLng); updateStatus('', 'Using simulated bench data'); }
displayBenches(allBenches);
})
.catch(err => { allBenches = generateSimulatedBenches(userLat, userLng); displayBenches(allBenches); });
}
function generateSimulatedBenches(lat, lng) {
const names = ['Park Bench', 'Street Seat', 'Garden Bench', 'Lakeside Seat', 'Plaza Bench', 'Shaded Bench', 'Trail Rest', 'Viewpoint Seat', 'Community Bench', 'Riverside Bench', 'Campus Bench', 'Mall Bench'];
const benches = [];
const count = 8 + Math.floor(Math.random() * 8);
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = 50 + Math.random() * 450;
const dLat = (dist / 111320) * Math.cos(angle);
const dLng = (dist / (111320 * Math.cos(lat * Math.PI / 180))) * Math.sin(angle);
benches.push({ lat: lat + dLat, lon: lng + dLng, tags: { name: names[i % names.length], amenity: 'bench', material: ['wood', 'metal', 'stone', 'concrete'][Math.floor(Math.random() * 4)], backrest: Math.random() > 0.3 ? 'yes' : 'no', seats: (2 + Math.floor(Math.random() * 4)).toString() } });
}
return benches;
}
function displayBenches(benches) {
if (benches.length === 0) { updateStatus('error', 'No benches found nearby'); return; }
let nearest = null, minDist = Infinity;
benches.forEach((bench, idx) => {
const bLat = bench.lat, bLng = bench.lon;
const dist = getDistance(userLat, userLng, bLat, bLng);
if (dist < minDist) { minDist = dist; nearest = { ...bench, distance: dist, index: idx }; }
const benchIcon = L.divIcon({ className: 'custom-marker', html: '<div class="marker-pin bench"><span>🪑</span></div>', iconSize: [36, 36], iconAnchor: [18, 36] });
const name = bench.tags?.name || 'Public Bench';
const material = bench.tags?.material || 'Unknown';
const backrest = bench.tags?.backrest === 'yes' ? 'Yes' : 'No';
const marker = L.marker([bLat, bLng], { icon: benchIcon }).addTo(map).bindPopup('<b>🪑 ' + name + '</b><br>📏 Distance: ' + formatDistance(dist) + '<br>🪵 Material: ' + material + '<br>🔙 Backrest: ' + backrest);
benchMarkers.push(marker);
});
if (nearest) {
nearestBench = nearest;
const nearestIcon = L.divIcon({ className: 'custom-marker', html: '<div class="marker-pin nearest"><span>⭐</span></div>', iconSize: [40, 40], iconAnchor: [20, 40] });
if (benchMarkers[nearest.index]) map.removeLayer(benchMarkers[nearest.index]);
const nearestMarker = L.marker([nearest.lat, nearest.lon], { icon: nearestIcon }).addTo(map).bindPopup('<b>⭐ Nearest Bench</b><br>Distance: ' + formatDistance(nearest.distance)).openPopup();
benchMarkers[nearest.index] = nearestMarker;
drawRoute(userLat, userLng, nearest.lat, nearest.lon);
document.getElementById('benchName').textContent = nearest.tags?.name || 'Public Bench';
document.getElementById('benchDetail').textContent = 'Material: ' + (nearest.tags?.material || 'Unknown') + ' · Backrest: ' + (nearest.tags?.backrest === 'yes' ? 'Yes' : 'No');
document.getElementById('statDist').textContent = formatDistance(nearest.distance);
document.getElementById('statTime').textContent = formatWalkTime(nearest.distance);
document.getElementById('statTotal').textContent = benches.length;
document.getElementById('bottomSheet').classList.remove('hidden');
updateStatus('', 'Found ' + benches.length + ' benches');
map.fitBounds(L.latLngBounds([userLat, userLng], [nearest.lat, nearest.lon]).pad(0.3));
}
}
function drawRoute(fromLat, fromLng, toLat, toLng) {
if (routeLine) map.removeLayer(routeLine);
fetch(`https://router.project-osrm.org/route/v1/foot/${fromLng},${fromLat};${toLng},${toLat}?overview=full&geometries=geojson`)
.then(res => res.json())
.then(data => {
if (data.routes && data.routes.length > 0) {
const coords = data.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
L.polyline(coords, { color: '#064e3b', weight: 8, opacity: 0.4, lineCap: 'round', lineJoin: 'round' }).addTo(map);
routeLine = L.polyline(coords, { color: '#10b981', weight: 5, opacity: 0.9, lineCap: 'round', lineJoin: 'round', dashArray: '12, 8' }).addTo(map);
const route = data.routes[0];
if (route.distance && route.duration) {
document.getElementById('statDist').textContent = formatDistance(route.distance);
document.getElementById('statTime').textContent = Math.ceil(route.duration / 60) + 'min';
}
}
})
.catch(() => { routeLine = L.polyline([[fromLat, fromLng], [toLat, toLng]], { color: '#10b981', weight: 4, opacity: 0.7, dashArray: '10, 10' }).addTo(map); });
}
function navigateToNearest() {
if (!nearestBench) return;
map.setView([nearestBench.lat, nearestBench.lon], 18);
benchMarkers[nearestBench.index]?.openPopup();
}
function getDistance(lat1, lng1, lat2, lng2) {
const R = 6371000, dLat = (lat2 - lat1) * Math.PI / 180, dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function formatDistance(m) { return m < 1000 ? Math.round(m) + 'm' : (m / 1000).toFixed(1) + 'km'; }
function formatWalkTime(m) { const min = Math.ceil(m / 80); return min < 1 ? '<1min' : min >= 60 ? Math.floor(min / 60) + 'h' + (min % 60) + 'min' : min + 'min'; }
function updateStatus(type, text) { const p = document.getElementById('statusPill'); p.className = 'status-pill' + (type ? ' ' + type : ''); document.getElementById('statusText').textContent = text; }
initMap();
</script>
</body>
</html>

View File

@ -0,0 +1,7 @@
{
"name": "todo-app",
"version": "1.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": {}
}

View File

@ -0,0 +1,38 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, 'todos.json');
const loadTodos = () => { try { if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { } return [{ id: 1, text: 'مرحباً بتطبيق المهام 👋', done: false, createdAt: Date.now() }, { id: 2, text: 'اضغط الدائرة لإكمال المهمة ✓', done: true, createdAt: Date.now() - 1000 }, { id: 3, text: 'اضغط × لحذف المهمة', done: false, createdAt: Date.now() - 2000 }]; };
const saveTodos = t => fs.writeFileSync(DATA_FILE, JSON.stringify(t, null, 2));
let todos = loadTodos(), nextId = Math.max(...todos.map(t => t.id), 0) + 1;
function parseBody(req) { return new Promise(r => { let b = ''; req.on('data', c => b += c); req.on('end', () => { try { r(JSON.parse(b)) } catch (e) { r({}) } }) }) }
function sendJson(res, data, s = 200) { res.writeHead(s, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)) }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (m === 'GET' && p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()) }
if (m === 'GET' && p === '/api/todos') return sendJson(res, { success: true, data: todos });
if (m === 'POST' && p === '/api/todos') { const { text } = await parseBody(req); if (!text?.trim()) return sendJson(res, { success: false }, 400); const t = { id: nextId++, text: text.trim(), done: false, createdAt: Date.now() }; todos.unshift(t); saveTodos(todos); return sendJson(res, { success: true, data: t }) }
const tog = p.match(/^\/api\/todos\/(\d+)\/toggle$/); if (m === 'PATCH' && tog) { const t = todos.find(x => x.id === parseInt(tog[1])); if (!t) return sendJson(res, { success: false }, 404); t.done = !t.done; saveTodos(todos); return sendJson(res, { success: true, data: t }) }
if (m === 'DELETE' && p === '/api/todos/completed/all') { todos = todos.filter(t => !t.done); saveTodos(todos); return sendJson(res, { success: true }) }
const del = p.match(/^\/api\/todos\/(\d+)$/); if (m === 'DELETE' && del) { const i = todos.findIndex(t => t.id === parseInt(del[1])); if (i === -1) return sendJson(res, { success: false }, 404); todos.splice(i, 1); saveTodos(todos); return sendJson(res, { success: true }) }
sendJson(res, { error: 'Not Found' }, 404);
});
server.listen(PORT, '0.0.0.0', () => console.log('Todo on http://0.0.0.0:' + PORT));
function getPage() {
return `<!DOCTYPE html><html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>مهام</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#0a0a1a;--card:rgba(255,255,255,0.04);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--purple:#a855f7;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,Tahoma,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;direction:rtl}body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(168,85,247,0.06) 0%,transparent 55%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{text-align:center;padding:28px 0 20px}.hdr h1{font-size:24px;font-weight:800;background:linear-gradient(135deg,#a855f7,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2);margin-top:4px}.stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}.stat-val{font-size:22px;font-weight:800;color:var(--purple)}.stat-label{font-size:10px;color:var(--text2);margin-top:2px}.input-row{display:flex;gap:8px;margin-bottom:16px}.input-row input{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 14px;color:var(--text);font-size:14px;outline:none}.input-row input:focus{border-color:var(--purple)}.input-row input::placeholder{color:var(--text3)}.add-btn{background:linear-gradient(135deg,#a855f7,#ec4899);color:#fff;border:none;border-radius:10px;padding:0 18px;font-size:18px;font-weight:800;cursor:pointer}.filter-row{display:flex;gap:6px;margin-bottom:14px}.filter{flex:1;padding:8px;border-radius:8px;background:var(--card);border:1px solid var(--border);color:var(--text2);font-size:12px;font-weight:600;text-align:center;cursor:pointer}.filter.active{background:rgba(168,85,247,0.1);color:var(--purple)}.todo-list{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}.todo{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,0.03);cursor:pointer}.todo:last-child{border:none}.todo.done .t-text{text-decoration:line-through;opacity:.4}.check{width:22px;height:22px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}.todo.done .check{background:var(--purple);border-color:var(--purple);color:#fff}.t-text{flex:1;font-size:14px;font-weight:500}.del{width:28px;height:28px;border-radius:8px;background:rgba(239,68,68,0.1);border:none;color:#ef4444;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center}.empty{text-align:center;padding:32px;color:var(--text3)}.ft{text-align:center;padding:16px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(168,85,247,0.04)}.clr{background:rgba(168,85,247,0.1);color:var(--purple);border:1px solid rgba(168,85,247,0.2);padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;margin-top:12px}.clr:disabled{opacity:.3}@keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.stat,.todo{animation:fadeIn .4s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><h1>📝 قائمة المهام</h1><p>Express + Node.js</p></div>
<div class="stats"><div class="stat"><div class="stat-val" id="total">0</div><div class="stat-label">الكل</div></div><div class="stat"><div class="stat-val" id="active">0</div><div class="stat-label">نشط</div></div><div class="stat"><div class="stat-val" id="done">0</div><div class="stat-label">مكتمل</div></div></div>
<div class="input-row"><input type="text" id="inp" placeholder="أضف مهمة..." maxlength="100" onkeydown="if(event.key==='Enter')addTodo()"><button class="add-btn" onclick="addTodo()">+</button></div>
<div class="filter-row"><div class="filter active" onclick="setFilter('all',this)">الكل</div><div class="filter" onclick="setFilter('active',this)">نشط</div><div class="filter" onclick="setFilter('done',this)">مكتمل</div></div>
<div class="todo-list" id="list"></div>
<div style="text-align:center"><button class="clr" id="clrBtn" onclick="clearDone()" disabled>حذف المكتمل</button></div>
<div class="ft"><div class="ft-b">📝 Express · Node.js · WebToApp</div></div></div>
<script>var todos=[],filter='all';function load(){fetch('/api/todos').then(function(r){return r.json()}).then(function(j){todos=j.data||[];render()})}function render(){var f=todos.filter(function(t){return filter==='all'?true:filter==='active'?!t.done:t.done});var list=document.getElementById('list');document.getElementById('total').textContent=todos.length;document.getElementById('active').textContent=todos.filter(function(t){return!t.done}).length;var dn=todos.filter(function(t){return t.done}).length;document.getElementById('done').textContent=dn;document.getElementById('clrBtn').disabled=dn===0;if(f.length===0){list.innerHTML='<div class="empty">🎉 لا توجد مهام</div>';return}list.innerHTML=f.map(function(t,i){return'<div class="todo'+(t.done?' done':'')+'" style="animation-delay:'+(i*.04)+'s"><div class="check" onclick="tog('+t.id+')">'+(t.done?'✓':'')+'</div><div class="t-text" onclick="tog('+t.id+')">'+esc(t.text)+'</div><button class="del" onclick="del('+t.id+')">×</button></div>'}).join('')}function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}function addTodo(){var v=document.getElementById('inp').value.trim();if(!v)return;fetch('/api/todos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:v})}).then(function(r){return r.json()}).then(function(j){if(j.success){todos.unshift(j.data);document.getElementById('inp').value='';render()}})}function tog(id){fetch('/api/todos/'+id+'/toggle',{method:'PATCH'}).then(function(r){return r.json()}).then(function(j){if(j.success){var t=todos.find(function(x){return x.id===id});if(t)t.done=j.data.done;render()}})}function del(id){fetch('/api/todos/'+id,{method:'DELETE'}).then(function(r){if(r.ok){todos=todos.filter(function(t){return t.id!==id});render()}})}function clearDone(){fetch('/api/todos/completed/all',{method:'DELETE'}).then(function(r){if(r.ok){todos=todos.filter(function(t){return!t.done});render()}})}function setFilter(f,el){filter=f;document.querySelectorAll('.filter').forEach(function(e){e.classList.remove('active')});el.classList.add('active');render()}load();</script></body></html>`;
}

View File

@ -0,0 +1,10 @@
{
"name": "todo-app",
"version": "1.0.0",
"description": "Todo App - Zero dependency task management with Material Design UI",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {}
}

View File

@ -0,0 +1,51 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, 'todos.json');
const loadTodos = () => {
try { if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { }
return [
{ id: 1, text: 'Welcome to Todo App 👋', done: false, createdAt: Date.now() },
{ id: 2, text: 'Click circle to complete ✓', done: true, createdAt: Date.now() - 1000 },
{ id: 3, text: 'Click × to delete task', done: false, createdAt: Date.now() - 2000 },
{ id: 4, text: 'Data persists locally', done: false, createdAt: Date.now() - 3000 }
];
};
const saveTodos = (t) => fs.writeFileSync(DATA_FILE, JSON.stringify(t, null, 2));
let todos = loadTodos(); let nextId = Math.max(...todos.map(t => t.id), 0) + 1;
function parseBody(req) { return new Promise(r => { let b = ''; req.on('data', c => b += c); req.on('end', () => { try { r(JSON.parse(b)) } catch (e) { r({}) } }); }); }
function sendJson(res, data, s = 200) { res.writeHead(s, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname, m = req.method;
if (m === 'GET' && p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()); }
if (m === 'GET' && p === '/api/todos') return sendJson(res, { success: true, data: todos });
if (m === 'POST' && p === '/api/todos') { const { text } = await parseBody(req); if (!text?.trim()) return sendJson(res, { success: false }, 400); const t = { id: nextId++, text: text.trim(), done: false, createdAt: Date.now() }; todos.unshift(t); saveTodos(todos); return sendJson(res, { success: true, data: t }); }
const tog = p.match(/^\/api\/todos\/(\d+)\/toggle$/); if (m === 'PATCH' && tog) { const t = todos.find(x => x.id === parseInt(tog[1])); if (!t) return sendJson(res, { success: false }, 404); t.done = !t.done; saveTodos(todos); return sendJson(res, { success: true, data: t }) }
if (m === 'DELETE' && p === '/api/todos/completed/all') { todos = todos.filter(t => !t.done); saveTodos(todos); return sendJson(res, { success: true }) }
const del = p.match(/^\/api\/todos\/(\d+)$/); if (m === 'DELETE' && del) { const i = todos.findIndex(t => t.id === parseInt(del[1])); if (i === -1) return sendJson(res, { success: false }, 404); todos.splice(i, 1); saveTodos(todos); return sendJson(res, { success: true }) }
sendJson(res, { error: 'Not Found' }, 404);
});
server.listen(PORT, '0.0.0.0', () => console.log('Todo on http://0.0.0.0:' + PORT));
function getPage() {
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Todo</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#0a0a1a;--card:rgba(255,255,255,0.04);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--purple:#a855f7;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(168,85,247,0.06) 0%,transparent 55%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{text-align:center;padding:28px 0 20px}.hdr h1{font-size:24px;font-weight:800;background:linear-gradient(135deg,#a855f7,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2);margin-top:4px}.stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}.stat-val{font-size:22px;font-weight:800;color:var(--purple)}.stat-label{font-size:10px;color:var(--text2);text-transform:uppercase;margin-top:2px}.input-row{display:flex;gap:8px;margin-bottom:16px}.input-row input{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 14px;color:var(--text);font-size:14px;outline:none}.input-row input:focus{border-color:var(--purple)}.input-row input::placeholder{color:var(--text3)}.add-btn{background:linear-gradient(135deg,#a855f7,#ec4899);color:#fff;border:none;border-radius:10px;padding:0 18px;font-size:18px;font-weight:800;cursor:pointer}.filter-row{display:flex;gap:6px;margin-bottom:14px}.filter{flex:1;padding:8px;border-radius:8px;background:var(--card);border:1px solid var(--border);color:var(--text2);font-size:12px;font-weight:600;text-align:center;cursor:pointer}.filter.active{background:rgba(168,85,247,0.1);color:var(--purple)}.todo-list{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}.todo{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,0.03);cursor:pointer}.todo:last-child{border:none}.todo.done .t-text{text-decoration:line-through;opacity:.4}.check{width:22px;height:22px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}.todo.done .check{background:var(--purple);border-color:var(--purple);color:#fff}.t-text{flex:1;font-size:14px;font-weight:500}.del{width:28px;height:28px;border-radius:8px;background:rgba(239,68,68,0.1);border:none;color:#ef4444;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center}.empty{text-align:center;padding:32px;color:var(--text3)}.ft{text-align:center;padding:16px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(168,85,247,0.04)}.clr{background:rgba(168,85,247,0.1);color:var(--purple);border:1px solid rgba(168,85,247,0.2);padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;margin-top:12px}.clr:disabled{opacity:.3}@keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.stat,.todo{animation:fadeIn .4s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><h1>📝 Todo App</h1><p>Express + Node.js</p></div>
<div class="stats"><div class="stat"><div class="stat-val" id="total">0</div><div class="stat-label">Total</div></div><div class="stat"><div class="stat-val" id="active">0</div><div class="stat-label">Active</div></div><div class="stat"><div class="stat-val" id="done">0</div><div class="stat-label">Done</div></div></div>
<div class="input-row"><input type="text" id="inp" placeholder="Add a task..." maxlength="100" onkeydown="if(event.key==='Enter')addTodo()"><button class="add-btn" onclick="addTodo()">+</button></div>
<div class="filter-row"><div class="filter active" onclick="setFilter('all',this)">All</div><div class="filter" onclick="setFilter('active',this)">Active</div><div class="filter" onclick="setFilter('done',this)">Done</div></div>
<div class="todo-list" id="list"></div>
<div style="text-align:center"><button class="clr" id="clrBtn" onclick="clearDone()" disabled>Clear Completed</button></div>
<div class="ft"><div class="ft-b">📝 Express · Node.js · WebToApp</div></div></div>
<script>var todos=[],filter='all';function load(){fetch('/api/todos').then(function(r){return r.json()}).then(function(j){todos=j.data||[];render()})}function render(){var f=todos.filter(function(t){return filter==='all'?true:filter==='active'?!t.done:t.done});var list=document.getElementById('list');document.getElementById('total').textContent=todos.length;document.getElementById('active').textContent=todos.filter(function(t){return!t.done}).length;var dn=todos.filter(function(t){return t.done}).length;document.getElementById('done').textContent=dn;document.getElementById('clrBtn').disabled=dn===0;if(f.length===0){list.innerHTML='<div class="empty">🎉 No tasks</div>';return}list.innerHTML=f.map(function(t,i){return'<div class="todo'+(t.done?' done':'')+'" style="animation-delay:'+(i*.04)+'s"><div class="check" onclick="tog('+t.id+')">'+(t.done?'✓':'')+'</div><div class="t-text" onclick="tog('+t.id+')">'+esc(t.text)+'</div><button class="del" onclick="del('+t.id+')">×</button></div>'}).join('')}function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}function addTodo(){var v=document.getElementById('inp').value.trim();if(!v)return;fetch('/api/todos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:v})}).then(function(r){return r.json()}).then(function(j){if(j.success){todos.unshift(j.data);document.getElementById('inp').value='';render()}})}function tog(id){fetch('/api/todos/'+id+'/toggle',{method:'PATCH'}).then(function(r){return r.json()}).then(function(j){if(j.success){var t=todos.find(function(x){return x.id===id});if(t)t.done=j.data.done;render()}})}function del(id){fetch('/api/todos/'+id,{method:'DELETE'}).then(function(r){if(r.ok){todos=todos.filter(function(t){return t.id!==id});render()}})}function clearDone(){fetch('/api/todos/completed/all',{method:'DELETE'}).then(function(r){if(r.ok){todos=todos.filter(function(t){return!t.done});render()}})}function setFilter(f,el){filter=f;document.querySelectorAll('.filter').forEach(function(e){e.classList.remove('active')});el.classList.add('active');render()}load();</script></body></html>`;
}

View File

@ -0,0 +1,12 @@
{
"name": "todo-app",
"version": "1.0.0",
"description": "待办事项应用 - 零依赖 Todo App,使用 Node.js 内置 http 模块",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {},
"keywords": ["todo", "nodejs", "zero-dependency"],
"author": "WebToApp"
}

View File

@ -0,0 +1,148 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, 'todos.json');
const loadTodos = () => {
try { if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { }
return [
{ id: 1, text: '欢迎使用待办事项 👋', done: false, createdAt: Date.now() },
{ id: 2, text: '点击圆圈完成任务 ✓', done: true, createdAt: Date.now() - 1000 },
{ id: 3, text: '点击 × 删除任务', done: false, createdAt: Date.now() - 2000 },
{ id: 4, text: '支持数据持久化', done: false, createdAt: Date.now() - 3000 }
];
};
const saveTodos = (todos) => fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2));
let todos = loadTodos();
let nextId = Math.max(...todos.map(t => t.id), 0) + 1;
function parseBody(req) {
return new Promise((resolve) => {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { resolve({}); } });
});
}
function sendJson(res, data, status = 200) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const p = url.pathname, m = req.method;
if (m === 'GET' && p === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(getPage());
}
if (m === 'GET' && p === '/api/todos') return sendJson(res, { success: true, data: todos });
if (m === 'POST' && p === '/api/todos') {
const { text } = await parseBody(req);
if (!text?.trim()) return sendJson(res, { success: false }, 400);
const todo = { id: nextId++, text: text.trim(), done: false, createdAt: Date.now() };
todos.unshift(todo); saveTodos(todos);
return sendJson(res, { success: true, data: todo });
}
const tog = p.match(/^\/api\/todos\/(\d+)\/toggle$/);
if (m === 'PATCH' && tog) {
const todo = todos.find(t => t.id === parseInt(tog[1]));
if (!todo) return sendJson(res, { success: false }, 404);
todo.done = !todo.done; saveTodos(todos);
return sendJson(res, { success: true, data: todo });
}
if (m === 'DELETE' && p === '/api/todos/completed/all') {
todos = todos.filter(t => !t.done); saveTodos(todos);
return sendJson(res, { success: true });
}
const del = p.match(/^\/api\/todos\/(\d+)$/);
if (m === 'DELETE' && del) {
const i = todos.findIndex(t => t.id === parseInt(del[1]));
if (i === -1) return sendJson(res, { success: false }, 404);
todos.splice(i, 1); saveTodos(todos);
return sendJson(res, { success: true });
}
sendJson(res, { error: 'Not Found' }, 404);
});
server.listen(PORT, '0.0.0.0', () => console.log(`✅ Todo on http://0.0.0.0:${PORT}`));
function getPage() {
return `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>待办事项</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a1a;--card:rgba(255,255,255,0.04);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--purple:#a855f7;--radius:14px}
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(168,85,247,0.06) 0%,transparent 55%);pointer-events:none}
.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}
.hdr{text-align:center;padding:28px 0 20px}
.hdr h1{font-size:24px;font-weight:800;background:linear-gradient(135deg,#a855f7,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr p{font-size:12px;color:var(--text2);margin-top:4px}
.stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px}
.stat{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:14px;text-align:center}
.stat-val{font-size:22px;font-weight:800;color:var(--purple)}
.stat-label{font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
.input-row{display:flex;gap:8px;margin-bottom:16px}
.input-row input{flex:1;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:12px 14px;color:var(--text);font-size:14px;outline:none}
.input-row input:focus{border-color:var(--purple)}
.input-row input::placeholder{color:var(--text3)}
.add-btn{background:linear-gradient(135deg,#a855f7,#ec4899);color:#fff;border:none;border-radius:10px;padding:0 18px;font-size:18px;font-weight:800;cursor:pointer}
.add-btn:active{transform:scale(.93)}
.filter-row{display:flex;gap:6px;margin-bottom:14px}
.filter{flex:1;padding:8px;border-radius:8px;background:var(--card);border:1px solid var(--border);color:var(--text2);font-size:12px;font-weight:600;text-align:center;cursor:pointer}
.filter.active{background:rgba(168,85,247,0.1);color:var(--purple);border-color:rgba(168,85,247,0.2)}
.todo-list{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
.todo{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid rgba(255,255,255,0.03);cursor:pointer}
.todo:last-child{border:none}
.todo.done .t-text{text-decoration:line-through;opacity:.4}
.check{width:22px;height:22px;border-radius:50%;border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;transition:all .2s}
.todo.done .check{background:var(--purple);border-color:var(--purple);color:#fff}
.t-text{flex:1;font-size:14px;font-weight:500}
.del{width:28px;height:28px;border-radius:8px;background:rgba(239,68,68,0.1);border:none;color:#ef4444;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center}
.empty{text-align:center;padding:32px;color:var(--text3)}
.ft{text-align:center;padding:16px}
.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(168,85,247,0.04);border:1px solid rgba(168,85,247,0.08)}
.clr{background:rgba(168,85,247,0.1);color:var(--purple);border:1px solid rgba(168,85,247,0.2);padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;margin-top:12px}
.clr:disabled{opacity:.3;cursor:not-allowed}
@keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.stat,.todo{animation:fadeIn .4s ease backwards}
</style></head><body>
<div class="c">
<div class="hdr"><h1>📝 待办事项</h1><p>Express + Node.js 驱动</p></div>
<div class="stats"><div class="stat"><div class="stat-val" id="total">0</div><div class="stat-label">全部</div></div><div class="stat"><div class="stat-val" id="active">0</div><div class="stat-label">待办</div></div><div class="stat"><div class="stat-val" id="done">0</div><div class="stat-label">完成</div></div></div>
<div class="input-row"><input type="text" id="inp" placeholder="添加新任务..." maxlength="100" onkeydown="if(event.key==='Enter')addTodo()"><button class="add-btn" onclick="addTodo()">+</button></div>
<div class="filter-row"><div class="filter active" onclick="setFilter('all',this)">全部</div><div class="filter" onclick="setFilter('active',this)">待办</div><div class="filter" onclick="setFilter('done',this)">已完成</div></div>
<div class="todo-list" id="list"></div>
<div style="text-align:center"><button class="clr" id="clrBtn" onclick="clearDone()" disabled>清除已完成</button></div>
<div class="ft"><div class="ft-b">📝 Express · Node.js · WebToApp</div></div>
</div>
<script>
var todos=[],filter='all';
function load(){fetch('/api/todos').then(function(r){return r.json()}).then(function(j){todos=j.data||[];render()})}
function render(){
var f=todos.filter(function(t){return filter==='all'?true:filter==='active'?!t.done:t.done});
var list=document.getElementById('list');
document.getElementById('total').textContent=todos.length;
document.getElementById('active').textContent=todos.filter(function(t){return !t.done}).length;
var dn=todos.filter(function(t){return t.done}).length;
document.getElementById('done').textContent=dn;
document.getElementById('clrBtn').disabled=dn===0;
if(f.length===0){list.innerHTML='<div class="empty">🎉 '+(filter==='all'?'暂无任务':'没有匹配')+'</div>';return}
list.innerHTML=f.map(function(t,i){return '<div class="todo'+(t.done?' done':'')+'" style="animation-delay:'+(i*0.04)+'s"><div class="check" onclick="tog('+t.id+')">'+(t.done?'✓':'')+'</div><div class="t-text" onclick="tog('+t.id+')">'+esc(t.text)+'</div><button class="del" onclick="del('+t.id+')">×</button></div>'}).join('')}
function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
function addTodo(){var v=document.getElementById('inp').value.trim();if(!v)return;fetch('/api/todos',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:v})}).then(function(r){return r.json()}).then(function(j){if(j.success){todos.unshift(j.data);document.getElementById('inp').value='';render()}})}
function tog(id){fetch('/api/todos/'+id+'/toggle',{method:'PATCH'}).then(function(r){return r.json()}).then(function(j){if(j.success){var t=todos.find(function(x){return x.id===id});if(t)t.done=j.data.done;render()}})}
function del(id){fetch('/api/todos/'+id,{method:'DELETE'}).then(function(r){if(r.ok){todos=todos.filter(function(t){return t.id!==id});render()}})}
function clearDone(){fetch('/api/todos/completed/all',{method:'DELETE'}).then(function(r){if(r.ok){todos=todos.filter(function(t){return !t.done});render()}})}
function setFilter(f,el){filter=f;document.querySelectorAll('.filter').forEach(function(e){e.classList.remove('active')});el.classList.add('active');render()}
load();
</script></body></html>`;
}

View File

@ -0,0 +1,7 @@
{
"name": "system-dashboard",
"version": "1.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": {}
}

View File

@ -0,0 +1,29 @@
const http = require('http');
const os = require('os');
const PORT = process.env.PORT || 3000;
const stats = { requestCount: 0, startTime: Date.now() };
function getStats() { stats.requestCount++; const cpus = os.cpus(); const cu = cpus.map(c => { const t = Object.values(c.times).reduce((a, b) => a + b, 0); return Math.round((1 - c.times.idle / t) * 100) }); const avg = Math.round(cu.reduce((a, b) => a + b, 0) / cu.length); const tm = os.totalmem(), fm = os.freemem(), um = tm - fm, mp = Math.round(um / tm * 100); const p = process.memoryUsage(); return { cpu: { cores: cpus.length, usage: avg, perCore: cu }, memory: { total: fB(tm), used: fB(um), free: fB(fm), percent: mp }, process: { heap: fB(p.heapUsed), rss: fB(p.rss), uptime: fT(process.uptime()) }, server: { requests: stats.requestCount, uptime: fT((Date.now() - stats.startTime) / 1000) }, system: { platform: os.platform(), arch: os.arch(), node: process.version } } }
function fB(b) { if (b < 1048576) return (b / 1024).toFixed(0) + ' KB'; if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB'; return (b / 1073741824).toFixed(2) + ' GB' }
function fT(s) { const m = Math.floor(s / 60), h = Math.floor(m / 60); if (h > 0) return h + 'س ' + m % 60 + 'د'; if (m > 0) return m + 'د ' + Math.floor(s % 60) + 'ث'; return Math.floor(s) + 'ث' }
const server = http.createServer((req, res) => {
const p = new URL(req.url, `http://${req.headers.host}`).pathname;
if (p === '/api/stats') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(getStats())) }
if (p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()) }
res.writeHead(404); res.end('Not Found');
});
server.listen(PORT, '0.0.0.0', () => console.log('Monitor on ' + PORT));
function getPage() {
return `<!DOCTYPE html><html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>مراقبة النظام</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#080818;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--cyan:#00d4ff;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,Tahoma,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;direction:rtl}body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(0,212,255,0.05) 0%,transparent 55%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#00d4ff,#7b2ff7);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2)}.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(0,212,255,0.1);color:var(--cyan)}.badge .dot{width:7px;height:7px;border-radius:50%;background:#00ff88;animation:p 2s infinite}@keyframes p{0%,100%{opacity:1}50%{opacity:.4}}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px}.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(0,212,255,0.1);color:var(--cyan)}.metric{font-size:32px;font-weight:800;margin-bottom:4px}.prog{height:6px;background:rgba(255,255,255,0.05);border-radius:3px;margin-top:8px;overflow:hidden}.prog-fill{height:100%;border-radius:3px;transition:width .5s}.cpu-fill{background:linear-gradient(90deg,#00d4ff,#0099ff)}.mem-fill{background:linear-gradient(90deg,#7b2ff7,#f107a3)}.cores{display:flex;gap:4px;margin-top:10px;direction:ltr}.core{flex:1;min-width:20px;height:40px;background:rgba(255,255,255,0.03);border-radius:4px;position:relative;overflow:hidden}.core-fill{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(0deg,#00d4ff,#00ff88);transition:height .5s}.core-label{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);font-size:8px;color:#fff}.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.info{background:rgba(255,255,255,0.02);padding:10px;border-radius:8px}.info-label{font-size:10px;color:var(--text3);margin-bottom:2px}.info-val{font-size:13px;font-weight:600}.ft{text-align:center;padding:12px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(0,212,255,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.card{animation:fadeIn .5s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>⚡ مراقبة النظام</h1><p>Fastify عالي الأداء</p></div><div class="badge"><span class="dot"></span>مباشر</div></div>
<div class="row"><div class="card"><div class="card-h"><span class="card-t">💻 المعالج</span><span class="card-b" id="cpuCores">-</span></div><div class="metric" id="cpuVal">--%</div><div class="prog"><div class="prog-fill cpu-fill" id="cpuBar" style="width:0"></div></div><div class="cores" id="cores"></div></div><div class="card"><div class="card-h"><span class="card-t">🧠 الذاكرة</span><span class="card-b" id="memDetail">-</span></div><div class="metric" id="memVal">--%</div><div class="prog"><div class="prog-fill mem-fill" id="memBar" style="width:0"></div></div><div class="info-grid" style="margin-top:10px"><div class="info"><div class="info-label">مستخدم</div><div class="info-val" id="memUsed">-</div></div><div class="info"><div class="info-label">متاح</div><div class="info-val" id="memFree">-</div></div></div></div></div>
<div class="card"><div class="card-h"><span class="card-t">🚀 الخادم</span><span class="card-b" style="background:rgba(0,255,136,0.1);color:#00ff88">● يعمل</span></div><div class="info-grid"><div class="info"><div class="info-label">التشغيل</div><div class="info-val" id="uptime">-</div></div><div class="info"><div class="info-label">الطلبات</div><div class="info-val" id="reqs">-</div></div><div class="info"><div class="info-label">Heap</div><div class="info-val" id="heap">-</div></div><div class="info"><div class="info-label">RSS</div><div class="info-val" id="rss">-</div></div></div></div>
<div class="card"><div class="card-h"><span class="card-t">📊 النظام</span><span class="card-b">معلومات</span></div><div class="info-grid"><div class="info"><div class="info-label">المنصة</div><div class="info-val" id="plat">-</div></div><div class="info"><div class="info-label">الهندسة</div><div class="info-val" id="arch">-</div></div><div class="info"><div class="info-label">Node.js</div><div class="info-val" id="node">-</div></div><div class="info"><div class="info-label">PID</div><div class="info-val">${process.pid}</div></div></div></div>
<div class="ft"><div class="ft-b">⚡ Fastify · Node.js · WebToApp</div></div></div>
<script>function fetchStats(){fetch('/api/stats').then(function(r){return r.json()}).then(function(d){document.getElementById('cpuVal').textContent=d.cpu.usage+'%';document.getElementById('cpuCores').textContent=d.cpu.cores+' أنوية';document.getElementById('cpuBar').style.width=d.cpu.usage+'%';var ch='';d.cpu.perCore.forEach(function(u){ch+='<div class="core"><div class="core-fill" style="height:'+u+'%"></div><div class="core-label">'+u+'</div></div>'});document.getElementById('cores').innerHTML=ch;document.getElementById('memVal').textContent=d.memory.percent+'%';document.getElementById('memDetail').textContent=d.memory.used+'/'+d.memory.total;document.getElementById('memBar').style.width=d.memory.percent+'%';document.getElementById('memUsed').textContent=d.memory.used;document.getElementById('memFree').textContent=d.memory.free;document.getElementById('uptime').textContent=d.server.uptime;document.getElementById('reqs').textContent=d.server.requests;document.getElementById('heap').textContent=d.process.heap;document.getElementById('rss').textContent=d.process.rss;document.getElementById('plat').textContent=d.system.platform;document.getElementById('arch').textContent=d.system.arch;document.getElementById('node').textContent=d.system.node}).catch(function(){})}fetchStats();setInterval(fetchStats,2000)</script></body></html>`;
}

View File

@ -0,0 +1,10 @@
{
"name": "system-dashboard",
"version": "1.0.0",
"description": "System Monitor Dashboard - Zero dependency, uses Node.js built-in http module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {}
}

View File

@ -0,0 +1,40 @@
const http = require('http');
const os = require('os');
const PORT = process.env.PORT || 3000;
const stats = { requestCount: 0, startTime: Date.now() };
function getStats() {
stats.requestCount++;
const cpus = os.cpus();
const cpuUsage = cpus.map(cpu => { const t = Object.values(cpu.times).reduce((a, b) => a + b, 0); return Math.round((1 - cpu.times.idle / t) * 100); });
const avgCpu = Math.round(cpuUsage.reduce((a, b) => a + b, 0) / cpuUsage.length);
const totalMem = os.totalmem(), freeMem = os.freemem(), usedMem = totalMem - freeMem, memPct = Math.round(usedMem / totalMem * 100);
const proc = process.memoryUsage();
return { cpu: { cores: cpus.length, usage: avgCpu, perCore: cpuUsage }, memory: { total: fmtB(totalMem), used: fmtB(usedMem), free: fmtB(freeMem), percent: memPct }, process: { heap: fmtB(proc.heapUsed), rss: fmtB(proc.rss), uptime: fmtT(process.uptime()) }, server: { requests: stats.requestCount, uptime: fmtT((Date.now() - stats.startTime) / 1000) }, system: { platform: os.platform(), arch: os.arch(), node: process.version } };
}
function fmtB(b) { if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'; if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'; return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB' }
function fmtT(s) { const m = Math.floor(s / 60), h = Math.floor(m / 60); if (h > 0) return h + 'h ' + m % 60 + 'm'; if (m > 0) return m + 'm ' + Math.floor(s % 60) + 's'; return Math.floor(s) + 's' }
const server = http.createServer((req, res) => {
const p = new URL(req.url, `http://${req.headers.host}`).pathname;
if (p === '/api/stats') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(getStats())) }
if (p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()) }
res.writeHead(404); res.end('Not Found');
});
server.listen(PORT, '0.0.0.0', () => console.log('Monitor on http://0.0.0.0:' + PORT));
function getPage() {
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>System Monitor</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#080818;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--cyan:#00d4ff;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(0,212,255,0.05) 0%,transparent 55%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#00d4ff,#7b2ff7);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2);margin-top:2px}.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(0,212,255,0.1);color:var(--cyan)}.badge .dot{width:7px;height:7px;border-radius:50%;background:#00ff88;box-shadow:0 0 10px #00ff88;animation:p 2s infinite}@keyframes p{0%,100%{opacity:1}50%{opacity:.4}}.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px}.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.card-t{font-size:15px;font-weight:700}.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(0,212,255,0.1);color:var(--cyan)}.metric{font-size:32px;font-weight:800;letter-spacing:-1px;margin-bottom:4px}.metric-sub{font-size:11px;color:var(--text2)}.prog{height:6px;background:rgba(255,255,255,0.05);border-radius:3px;margin-top:8px;overflow:hidden}.prog-fill{height:100%;border-radius:3px;transition:width .5s}.cpu-fill{background:linear-gradient(90deg,#00d4ff,#0099ff)}.mem-fill{background:linear-gradient(90deg,#7b2ff7,#f107a3)}.cores{display:flex;gap:4px;margin-top:10px;flex-wrap:wrap}.core{flex:1;min-width:20px;height:40px;background:rgba(255,255,255,0.03);border-radius:4px;position:relative;overflow:hidden}.core-fill{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(0deg,#00d4ff,#00ff88);transition:height .5s}.core-label{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);font-size:8px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5)}.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.info{background:rgba(255,255,255,0.02);padding:10px;border-radius:8px}.info-label{font-size:10px;color:var(--text3);margin-bottom:2px}.info-val{font-size:13px;font-weight:600}.ft{text-align:center;padding:12px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(0,212,255,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}.card{animation:fadeIn .5s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>⚡ System Monitor</h1><p>Fastify High-Performance</p></div><div class="badge"><span class="dot"></span>Live</div></div>
<div class="row"><div class="card"><div class="card-h"><span class="card-t">💻 CPU</span><span class="card-b" id="cpuCores">-</span></div><div class="metric" id="cpuVal">--%</div><div class="prog"><div class="prog-fill cpu-fill" id="cpuBar" style="width:0"></div></div><div class="cores" id="cores"></div></div><div class="card"><div class="card-h"><span class="card-t">🧠 Memory</span><span class="card-b" id="memDetail">-</span></div><div class="metric" id="memVal">--%</div><div class="prog"><div class="prog-fill mem-fill" id="memBar" style="width:0"></div></div><div class="info-grid" style="margin-top:10px"><div class="info"><div class="info-label">Used</div><div class="info-val" id="memUsed">-</div></div><div class="info"><div class="info-label">Free</div><div class="info-val" id="memFree">-</div></div></div></div></div>
<div class="card"><div class="card-h"><span class="card-t">🚀 Server</span><span class="card-b" style="background:rgba(0,255,136,0.1);color:#00ff88">● Running</span></div><div class="info-grid"><div class="info"><div class="info-label">Uptime</div><div class="info-val" id="uptime">-</div></div><div class="info"><div class="info-label">Requests</div><div class="info-val" id="reqs">-</div></div><div class="info"><div class="info-label">Heap</div><div class="info-val" id="heap">-</div></div><div class="info"><div class="info-label">RSS</div><div class="info-val" id="rss">-</div></div></div></div>
<div class="card"><div class="card-h"><span class="card-t">📊 System</span><span class="card-b">Info</span></div><div class="info-grid"><div class="info"><div class="info-label">Platform</div><div class="info-val" id="plat">-</div></div><div class="info"><div class="info-label">Arch</div><div class="info-val" id="arch">-</div></div><div class="info"><div class="info-label">Node.js</div><div class="info-val" id="node">-</div></div><div class="info"><div class="info-label">PID</div><div class="info-val">${process.pid}</div></div></div></div>
<div class="ft"><div class="ft-b">⚡ Fastify · Node.js · WebToApp</div></div></div>
<script>function fetchStats(){fetch('/api/stats').then(function(r){return r.json()}).then(function(d){document.getElementById('cpuVal').textContent=d.cpu.usage+'%';document.getElementById('cpuCores').textContent=d.cpu.cores+' Cores';document.getElementById('cpuBar').style.width=d.cpu.usage+'%';var ch='';d.cpu.perCore.forEach(function(u){ch+='<div class="core"><div class="core-fill" style="height:'+u+'%"></div><div class="core-label">'+u+'</div></div>'});document.getElementById('cores').innerHTML=ch;document.getElementById('memVal').textContent=d.memory.percent+'%';document.getElementById('memDetail').textContent=d.memory.used+'/'+d.memory.total;document.getElementById('memBar').style.width=d.memory.percent+'%';document.getElementById('memUsed').textContent=d.memory.used;document.getElementById('memFree').textContent=d.memory.free;document.getElementById('uptime').textContent=d.server.uptime;document.getElementById('reqs').textContent=d.server.requests;document.getElementById('heap').textContent=d.process.heap;document.getElementById('rss').textContent=d.process.rss;document.getElementById('plat').textContent=d.system.platform;document.getElementById('arch').textContent=d.system.arch;document.getElementById('node').textContent=d.system.node}).catch(function(){})}fetchStats();setInterval(fetchStats,2000)</script></body></html>`;
}

View File

@ -0,0 +1,12 @@
{
"name": "system-dashboard",
"version": "1.0.0",
"description": "实时系统监控仪表盘 - 零依赖,使用 Node.js 内置 http 模块",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {},
"keywords": ["dashboard", "monitor", "nodejs", "zero-dependency"],
"author": "WebToApp"
}

View File

@ -0,0 +1,113 @@
const http = require('http');
const os = require('os');
const PORT = process.env.PORT || 3000;
const stats = { requestCount: 0, startTime: Date.now() };
function getStats() {
stats.requestCount++;
const cpus = os.cpus();
const cpuUsage = cpus.map(cpu => {
const total = Object.values(cpu.times).reduce((a, b) => a + b, 0);
return Math.round((1 - cpu.times.idle / total) * 100);
});
const avgCpu = Math.round(cpuUsage.reduce((a, b) => a + b, 0) / cpuUsage.length);
const totalMem = os.totalmem(), freeMem = os.freemem(), usedMem = totalMem - freeMem;
const memPct = Math.round((usedMem / totalMem) * 100);
const proc = process.memoryUsage();
return {
cpu: { cores: cpus.length, usage: avgCpu, perCore: cpuUsage },
memory: { total: fmtB(totalMem), used: fmtB(usedMem), free: fmtB(freeMem), percent: memPct },
process: { heap: fmtB(proc.heapUsed), rss: fmtB(proc.rss), uptime: fmtT(process.uptime()) },
server: { requests: stats.requestCount, uptime: fmtT((Date.now() - stats.startTime) / 1000) },
system: { platform: os.platform(), arch: os.arch(), node: process.version }
};
}
function fmtB(b) { if (b < 1024 * 1024) return (b / 1024).toFixed(0) + ' KB'; if (b < 1024 * 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + ' MB'; return (b / 1024 / 1024 / 1024).toFixed(2) + ' GB'; }
function fmtT(s) { const m = Math.floor(s / 60), h = Math.floor(m / 60); if (h > 0) return h + '时' + m % 60 + '分'; if (m > 0) return m + '分' + Math.floor(s % 60) + '秒'; return Math.floor(s) + '秒'; }
const server = http.createServer((req, res) => {
const p = new URL(req.url, `http://${req.headers.host}`).pathname;
if (p === '/api/stats') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(getStats())); }
if (p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()); }
res.writeHead(404); res.end('Not Found');
});
server.listen(PORT, '0.0.0.0', () => console.log(`⚡ Monitor on http://0.0.0.0:${PORT}`));
function getPage() {
return `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>系统监控</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#080818;--card:rgba(255,255,255,0.035);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--cyan:#00d4ff;--radius:14px}
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(0,212,255,0.05) 0%,transparent 55%);pointer-events:none}
.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}
.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}
.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#00d4ff,#7b2ff7);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.hdr p{font-size:12px;color:var(--text2);margin-top:2px}
.badge{display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:5px 12px;border-radius:20px;background:rgba(0,212,255,0.1);color:var(--cyan)}
.badge .dot{width:7px;height:7px;border-radius:50%;background:#00ff88;box-shadow:0 0 10px #00ff88;animation:p 2s infinite}
@keyframes p{0%,100%{opacity:1}50%{opacity:.4}}
.row{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px;backdrop-filter:blur(16px)}
.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.card-t{font-size:15px;font-weight:700}
.card-b{font-size:10px;font-weight:600;padding:3px 9px;border-radius:16px;background:rgba(0,212,255,0.1);color:var(--cyan)}
.metric{font-size:32px;font-weight:800;letter-spacing:-1px;margin-bottom:4px}
.metric-sub{font-size:11px;color:var(--text2)}
.prog{height:6px;background:rgba(255,255,255,0.05);border-radius:3px;margin-top:8px;overflow:hidden}
.prog-fill{height:100%;border-radius:3px;transition:width .5s}
.cpu-fill{background:linear-gradient(90deg,#00d4ff,#0099ff)}
.mem-fill{background:linear-gradient(90deg,#7b2ff7,#f107a3)}
.cores{display:flex;gap:4px;margin-top:10px;flex-wrap:wrap}
.core{flex:1;min-width:20px;height:40px;background:rgba(255,255,255,0.03);border-radius:4px;position:relative;overflow:hidden}
.core-fill{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(0deg,#00d4ff,#00ff88);transition:height .5s;border-radius:0 0 4px 4px}
.core-label{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);font-size:8px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.5)}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.info{background:rgba(255,255,255,0.02);padding:10px;border-radius:8px}
.info-label{font-size:10px;color:var(--text3);margin-bottom:2px}
.info-val{font-size:13px;font-weight:600}
.ft{text-align:center;padding:12px}
.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(0,212,255,0.04);border:1px solid rgba(0,212,255,0.08)}
@keyframes fadeIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
.card{animation:fadeIn .5s ease backwards}
</style></head><body>
<div class="c">
<div class="hdr"><div><h1>⚡ 系统监控</h1><p>Fastify 高性能服务</p></div><div class="badge"><span class="dot"></span>实时更新</div></div>
<div class="row">
<div class="card"><div class="card-h"><span class="card-t">💻 CPU</span><span class="card-b" id="cpuCores">-</span></div><div class="metric" id="cpuVal">--%</div><div class="prog"><div class="prog-fill cpu-fill" id="cpuBar" style="width:0"></div></div><div class="cores" id="cores"></div></div>
<div class="card"><div class="card-h"><span class="card-t">🧠 内存</span><span class="card-b" id="memDetail">-</span></div><div class="metric" id="memVal">--%</div><div class="prog"><div class="prog-fill mem-fill" id="memBar" style="width:0"></div></div><div class="info-grid" style="margin-top:10px"><div class="info"><div class="info-label">已用</div><div class="info-val" id="memUsed">-</div></div><div class="info"><div class="info-label">可用</div><div class="info-val" id="memFree">-</div></div></div></div>
</div>
<div class="card"><div class="card-h"><span class="card-t">🚀 服务器</span><span class="card-b" style="background:rgba(0,255,136,0.1);color:#00ff88">● 运行中</span></div><div class="info-grid"><div class="info"><div class="info-label">运行时间</div><div class="info-val" id="uptime">-</div></div><div class="info"><div class="info-label">请求数</div><div class="info-val" id="reqs">-</div></div><div class="info"><div class="info-label">堆内存</div><div class="info-val" id="heap">-</div></div><div class="info"><div class="info-label">RSS</div><div class="info-val" id="rss">-</div></div></div></div>
<div class="card"><div class="card-h"><span class="card-t">📊 系统</span><span class="card-b">信息</span></div><div class="info-grid"><div class="info"><div class="info-label">平台</div><div class="info-val" id="plat">-</div></div><div class="info"><div class="info-label">架构</div><div class="info-val" id="arch">-</div></div><div class="info"><div class="info-label">Node.js</div><div class="info-val" id="node">-</div></div><div class="info"><div class="info-label">PID</div><div class="info-val">${process.pid}</div></div></div></div>
<div class="ft"><div class="ft-b">⚡ Fastify · Node.js · WebToApp</div></div>
</div>
<script>
function fetchStats(){
fetch('/api/stats').then(function(r){return r.json()}).then(function(d){
document.getElementById('cpuVal').textContent=d.cpu.usage+'%';
document.getElementById('cpuCores').textContent=d.cpu.cores+' 核心';
document.getElementById('cpuBar').style.width=d.cpu.usage+'%';
var ch='';d.cpu.perCore.forEach(function(u){ch+='<div class="core"><div class="core-fill" style="height:'+u+'%"></div><div class="core-label">'+u+'</div></div>'});
document.getElementById('cores').innerHTML=ch;
document.getElementById('memVal').textContent=d.memory.percent+'%';
document.getElementById('memDetail').textContent=d.memory.used+'/'+d.memory.total;
document.getElementById('memBar').style.width=d.memory.percent+'%';
document.getElementById('memUsed').textContent=d.memory.used;
document.getElementById('memFree').textContent=d.memory.free;
document.getElementById('uptime').textContent=d.server.uptime;
document.getElementById('reqs').textContent=d.server.requests;
document.getElementById('heap').textContent=d.process.heap;
document.getElementById('rss').textContent=d.process.rss;
document.getElementById('plat').textContent=d.system.platform;
document.getElementById('arch').textContent=d.system.arch;
document.getElementById('node').textContent=d.system.node;
}).catch(function(){})}
fetchStats();setInterval(fetchStats,2000);
</script></body></html>`;
}

View File

@ -0,0 +1,7 @@
{
"name": "notes-app",
"version": "1.0.0",
"main": "server.js",
"scripts": { "start": "node server.js" },
"dependencies": {}
}

View File

@ -0,0 +1,34 @@
const http = require('http'); const fs = require('fs'); const path = require('path');
const PORT = process.env.PORT || 3000; const DATA_FILE = path.join(__dirname, 'notes.json');
const loadNotes = () => { try { if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { } return [{ id: 1, title: 'مرحباً بتطبيق الملاحظات', content: '# مرحباً\n\nهذا تطبيق ملاحظات يدعم **Markdown**.\n\n## الميزات\n- إنشاء وتعديل\n- معاينة Markdown\n- بحث\n- حفظ محلي', createdAt: Date.now(), updatedAt: Date.now() }, { id: 2, title: 'صياغة Markdown', content: '# Markdown\n\n## التأكيد\n- **غامق**: `**نص**`\n- *مائل*: `*نص*`\n\n## القوائم\n- استخدم - للنقاط\n1. أرقام للترتيب', createdAt: Date.now() - 3600000, updatedAt: Date.now() - 3600000 }]; };
const saveNotes = n => fs.writeFileSync(DATA_FILE, JSON.stringify(n, null, 2));
let notes = loadNotes(), nextId = Math.max(...notes.map(n => n.id), 0) + 1;
function parseBody(req) { return new Promise(r => { let b = ''; req.on('data', c => b += c); req.on('end', () => { try { r(JSON.parse(b)) } catch (e) { r({}) } }) }) }
function sendJson(res, data, s = 200) { res.writeHead(s, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)) }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (m === 'GET' && p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()) }
if (m === 'GET' && p === '/api/notes') { const q = url.searchParams.get('q'); let r = notes; if (q) { const ql = q.toLowerCase(); r = notes.filter(n => n.title.toLowerCase().includes(ql) || n.content.toLowerCase().includes(ql)) } return sendJson(res, { success: true, data: r.map(n => ({ ...n, preview: n.content.substring(0, 80) })) }) }
if (m === 'POST' && p === '/api/notes') { const { title, content } = await parseBody(req); const n = { id: nextId++, title: title || 'بدون عنوان', content: content || '', createdAt: Date.now(), updatedAt: Date.now() }; notes.unshift(n); saveNotes(notes); return sendJson(res, { success: true, data: n }) }
const nm = p.match(/^\/api\/notes\/(\d+)$/);
if (m === 'GET' && nm) { const n = notes.find(x => x.id === parseInt(nm[1])); return n ? sendJson(res, { success: true, data: n }) : sendJson(res, { success: false }, 404) }
if (m === 'PUT' && nm) { const n = notes.find(x => x.id === parseInt(nm[1])); if (!n) return sendJson(res, { success: false }, 404); const { title, content } = await parseBody(req); if (title !== undefined) n.title = title; if (content !== undefined) n.content = content; n.updatedAt = Date.now(); saveNotes(notes); return sendJson(res, { success: true, data: n }) }
if (m === 'DELETE' && nm) { const i = notes.findIndex(x => x.id === parseInt(nm[1])); if (i === -1) return sendJson(res, { success: false }, 404); notes.splice(i, 1); saveNotes(notes); return sendJson(res, { success: true }) }
sendJson(res, { error: 'Not Found' }, 404)
});
server.listen(PORT, '0.0.0.0', () => console.log('Notes on ' + PORT));
function getPage() {
return `<!DOCTYPE html><html lang="ar" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>ملاحظات</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#0a0a1a;--card:rgba(255,255,255,0.04);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--green:#10b981;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,Tahoma,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;direction:rtl}body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(16,185,129,0.05) 0%,transparent 55%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#10b981,#34d399);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2)}.new-btn{background:linear-gradient(135deg,#10b981,#059669);color:#fff;border:none;border-radius:10px;padding:8px 16px;font-size:12px;font-weight:700;cursor:pointer}.search{margin-bottom:14px}.search input{width:100%;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-size:14px;outline:none}.search input:focus{border-color:var(--green)}.search input::placeholder{color:var(--text3)}.notes{display:flex;flex-direction:column;gap:10px}.note{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;position:relative;overflow:hidden}.note::before{content:'';position:absolute;top:0;right:0;width:3px;height:100%}.note:nth-child(1)::before{background:var(--green)}.note:nth-child(2)::before{background:#8b5cf6}.note:nth-child(3)::before{background:#f59e0b}.note-title{font-size:14px;font-weight:700;margin-bottom:4px;padding-left:30px}.note-preview{font-size:12px;color:var(--text2);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.note-time{font-size:10px;color:var(--text3);margin-top:6px}.note-del{position:absolute;top:12px;left:12px;width:24px;height:24px;border-radius:6px;background:rgba(239,68,68,0.1);border:none;color:#ef4444;font-size:12px;cursor:pointer;display:flex;align-items:center;justify-content:center}.editor{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px;display:none}.editor input{width:100%;background:transparent;border:none;border-bottom:1px solid var(--border);padding:8px 0;color:var(--text);font-size:16px;font-weight:700;outline:none;margin-bottom:10px}.editor textarea{width:100%;background:transparent;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-size:13px;font-family:monospace;line-height:1.6;resize:none;outline:none;min-height:150px}.editor textarea:focus{border-color:var(--green)}.editor-actions{display:flex;gap:8px;margin-top:10px}.save-btn{background:var(--green);color:#fff;border:none;border-radius:8px;padding:8px 16px;font-size:12px;font-weight:700;cursor:pointer}.cancel-btn{background:var(--card);color:var(--text2);border:1px solid var(--border);border-radius:8px;padding:8px 16px;font-size:12px;cursor:pointer}.tabs{display:flex;gap:4px;margin-bottom:10px}.tab{padding:6px 14px;border-radius:8px;border:1px solid var(--border);color:var(--text2);font-size:12px;font-weight:600;cursor:pointer}.tab.active{background:rgba(16,185,129,0.1);color:var(--green)}.preview-area{margin-top:10px;padding:12px;background:rgba(0,0,0,0.2);border-radius:8px;font-size:13px;line-height:1.8;display:none}.empty{text-align:center;padding:40px;color:var(--text3)}.ft{text-align:center;padding:16px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(16,185,129,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.note{animation:fadeIn .4s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>📝 ملاحظات Markdown</h1><p>Koa · Node.js</p></div><button class="new-btn" onclick="createNote()">+ جديد</button></div>
<div class="search"><input type="text" id="searchInput" placeholder="بحث..." oninput="searchNotes()"></div>
<div class="editor" id="editor"><input type="text" id="eTitle" placeholder="العنوان"><div class="tabs"><div class="tab active" onclick="switchTab('edit')">تحرير</div><div class="tab" onclick="switchTab('preview')">معاينة</div></div><textarea id="eContent" placeholder="اكتب شيئاً..." oninput="updatePreview()"></textarea><div class="preview-area" id="previewArea"></div><div class="editor-actions"><button class="save-btn" onclick="saveNote()">حفظ</button><button class="cancel-btn" onclick="closeEditor()">إلغاء</button></div></div>
<div class="notes" id="noteList"></div>
<div class="ft"><div class="ft-b">📝 Koa · Node.js · WebToApp</div></div></div>
<script>var notes=[],currentId=null;function load(){var q=document.getElementById('searchInput').value;fetch('/api/notes'+(q?'?q='+encodeURIComponent(q):'')).then(function(r){return r.json()}).then(function(j){notes=j.data||[];renderList()})}function renderList(){var l=document.getElementById('noteList');if(notes.length===0){l.innerHTML='<div class="empty">📝 لا توجد ملاحظات</div>';return}l.innerHTML=notes.map(function(n,i){return'<div class="note" style="animation-delay:'+(i*.05)+'s" onclick="editNote('+n.id+')"><button class="note-del" onclick="event.stopPropagation();delNote('+n.id+')">×</button><div class="note-title">'+esc(n.title)+'</div><div class="note-preview">'+esc(n.preview||'')+'</div><div class="note-time">'+(new Date(n.updatedAt).toLocaleString())+'</div></div>'}).join('')}function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}function createNote(){fetch('/api/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'ملاحظة جديدة',content:''})}).then(function(r){return r.json()}).then(function(j){if(j.success){load();editNote(j.data.id)}})}function editNote(id){fetch('/api/notes/'+id).then(function(r){return r.json()}).then(function(j){if(j.success){currentId=j.data.id;document.getElementById('eTitle').value=j.data.title;document.getElementById('eContent').value=j.data.content;document.getElementById('editor').style.display='block';switchTab('edit')}})}function saveNote(){if(!currentId)return;fetch('/api/notes/'+currentId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.getElementById('eTitle').value,content:document.getElementById('eContent').value})}).then(function(){closeEditor();load()})}function closeEditor(){document.getElementById('editor').style.display='none';currentId=null}function delNote(id){fetch('/api/notes/'+id,{method:'DELETE'}).then(function(){if(currentId===id)closeEditor();load()})}function searchNotes(){load()}function switchTab(t){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});if(t==='edit'){document.querySelector('.tab:first-child').classList.add('active');document.getElementById('eContent').style.display='block';document.getElementById('previewArea').style.display='none'}else{document.querySelector('.tab:last-child').classList.add('active');document.getElementById('eContent').style.display='none';document.getElementById('previewArea').style.display='block';updatePreview()}}function updatePreview(){var c=document.getElementById('eContent').value;document.getElementById('previewArea').innerHTML=simpleMd(c)}function simpleMd(s){return s.replace(/^### (.*$)/gm,'<h3>$1</h3>').replace(/^## (.*$)/gm,'<h2>$1</h2>').replace(/^# (.*$)/gm,'<h1>$1</h1>').replace(/\\*\\*(.*?)\\*\\*/g,'<strong>$1</strong>').replace(/\\*(.*?)\\*/g,'<em>$1</em>').replace(/\`(.*?)\`/g,'<code>$1</code>').replace(/^- (.*$)/gm,'<li>$1</li>').replace(/^> (.*$)/gm,'<blockquote>$1</blockquote>').replace(/\\n/g,'<br>')}load();</script></body></html>`;
}

View File

@ -0,0 +1,10 @@
{
"name": "notes-app",
"version": "1.0.0",
"description": "Markdown Notes App - Zero dependency, uses Node.js built-in http module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {}
}

View File

@ -0,0 +1,38 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3000;
const DATA_FILE = path.join(__dirname, 'notes.json');
const loadNotes = () => { try { if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch (e) { } return [{ id: 1, title: 'Welcome to Notes', content: '# Welcome\n\nThis is a **Markdown** notes app.\n\n## Features\n- Create & edit notes\n- Markdown preview\n- Search\n- Local persistence', createdAt: Date.now(), updatedAt: Date.now() }, { id: 2, title: 'Markdown Syntax', content: '# Markdown\n\n## Emphasis\n- **Bold**: `**text**`\n- *Italic*: `*text*`\n\n## Lists\n- Use - for bullets\n1. Use numbers for ordered\n\n## Code\n`inline code`', createdAt: Date.now() - 3600000, updatedAt: Date.now() - 3600000 }]; };
const saveNotes = n => fs.writeFileSync(DATA_FILE, JSON.stringify(n, null, 2));
let notes = loadNotes(), nextId = Math.max(...notes.map(n => n.id), 0) + 1;
function parseBody(req) { return new Promise(r => { let b = ''; req.on('data', c => b += c); req.on('end', () => { try { r(JSON.parse(b)) } catch (e) { r({}) } }) }) }
function sendJson(res, data, s = 200) { res.writeHead(s, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)) }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (m === 'GET' && p === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(getPage()) }
if (m === 'GET' && p === '/api/notes') { const q = url.searchParams.get('q'); let r = notes; if (q) { const ql = q.toLowerCase(); r = notes.filter(n => n.title.toLowerCase().includes(ql) || n.content.toLowerCase().includes(ql)) } return sendJson(res, { success: true, data: r.map(n => ({ ...n, preview: n.content.substring(0, 80) })) }) }
if (m === 'POST' && p === '/api/notes') { const { title, content } = await parseBody(req); const n = { id: nextId++, title: title || 'Untitled', content: content || '', createdAt: Date.now(), updatedAt: Date.now() }; notes.unshift(n); saveNotes(notes); return sendJson(res, { success: true, data: n }) }
const nm = p.match(/^\/api\/notes\/(\d+)$/);
if (m === 'GET' && nm) { const n = notes.find(x => x.id === parseInt(nm[1])); return n ? sendJson(res, { success: true, data: n }) : sendJson(res, { success: false }, 404) }
if (m === 'PUT' && nm) { const n = notes.find(x => x.id === parseInt(nm[1])); if (!n) return sendJson(res, { success: false }, 404); const { title, content } = await parseBody(req); if (title !== undefined) n.title = title; if (content !== undefined) n.content = content; n.updatedAt = Date.now(); saveNotes(notes); return sendJson(res, { success: true, data: n }) }
if (m === 'DELETE' && nm) { const i = notes.findIndex(x => x.id === parseInt(nm[1])); if (i === -1) return sendJson(res, { success: false }, 404); notes.splice(i, 1); saveNotes(notes); return sendJson(res, { success: true }) }
sendJson(res, { error: 'Not Found' }, 404)
});
server.listen(PORT, '0.0.0.0', () => console.log('Notes on ' + PORT));
function getPage() {
return `<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no"><title>Notes</title>
<style>*{margin:0;padding:0;box-sizing:border-box}:root{--bg:#0a0a1a;--card:rgba(255,255,255,0.04);--border:rgba(255,255,255,0.07);--text:#f0f0ff;--text2:rgba(240,240,255,0.55);--text3:rgba(240,240,255,0.3);--green:#10b981;--radius:14px}body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}body::before{content:'';position:fixed;top:-40%;left:-40%;width:180%;height:180%;background:radial-gradient(ellipse at 50% 30%,rgba(16,185,129,0.05) 0%,transparent 55%);pointer-events:none}.c{max-width:460px;margin:0 auto;padding:0 14px 32px;position:relative;z-index:1}.hdr{padding:20px 2px;display:flex;justify-content:space-between;align-items:center}.hdr h1{font-size:22px;font-weight:800;background:linear-gradient(135deg,#10b981,#34d399);-webkit-background-clip:text;-webkit-text-fill-color:transparent}.hdr p{font-size:12px;color:var(--text2)}.new-btn{background:linear-gradient(135deg,#10b981,#059669);color:#fff;border:none;border-radius:10px;padding:8px 16px;font-size:12px;font-weight:700;cursor:pointer}.search{margin-bottom:14px}.search input{width:100%;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;color:var(--text);font-size:14px;outline:none}.search input:focus{border-color:var(--green)}.search input::placeholder{color:var(--text3)}.notes{display:flex;flex-direction:column;gap:10px}.note{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;position:relative;overflow:hidden}.note::before{content:'';position:absolute;top:0;left:0;width:3px;height:100%}.note:nth-child(1)::before{background:var(--green)}.note:nth-child(2)::before{background:#8b5cf6}.note:nth-child(3)::before{background:#f59e0b}.note:nth-child(4)::before{background:#06b6d4}.note-title{font-size:14px;font-weight:700;margin-bottom:4px;padding-right:30px}.note-preview{font-size:12px;color:var(--text2);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.note-time{font-size:10px;color:var(--text3);margin-top:6px}.note-del{position:absolute;top:12px;right:12px;width:24px;height:24px;border-radius:6px;background:rgba(239,68,68,0.1);border:none;color:#ef4444;font-size:12px;cursor:pointer;display:flex;align-items:center;justify-content:center}.editor{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;margin-bottom:14px;display:none}.editor input{width:100%;background:transparent;border:none;border-bottom:1px solid var(--border);padding:8px 0;color:var(--text);font-size:16px;font-weight:700;outline:none;margin-bottom:10px}.editor textarea{width:100%;background:transparent;border:1px solid var(--border);border-radius:8px;padding:10px;color:var(--text);font-size:13px;font-family:monospace;line-height:1.6;resize:none;outline:none;min-height:150px}.editor textarea:focus{border-color:var(--green)}.editor-actions{display:flex;gap:8px;margin-top:10px}.save-btn{background:var(--green);color:#fff;border:none;border-radius:8px;padding:8px 16px;font-size:12px;font-weight:700;cursor:pointer}.cancel-btn{background:var(--card);color:var(--text2);border:1px solid var(--border);border-radius:8px;padding:8px 16px;font-size:12px;cursor:pointer}.tabs{display:flex;gap:4px;margin-bottom:10px}.tab{padding:6px 14px;border-radius:8px;background:transparent;border:1px solid var(--border);color:var(--text2);font-size:12px;font-weight:600;cursor:pointer}.tab.active{background:rgba(16,185,129,0.1);color:var(--green)}.preview-area{margin-top:10px;padding:12px;background:rgba(0,0,0,0.2);border-radius:8px;font-size:13px;line-height:1.8;display:none}.preview-area h1,.preview-area h2{margin:10px 0 5px}.preview-area code{background:rgba(255,255,255,0.05);padding:2px 6px;border-radius:4px;font-family:monospace}.preview-area ul{margin:5px 0 5px 20px}.preview-area blockquote{border-left:3px solid var(--green);padding-left:12px;color:var(--text2)}.empty{text-align:center;padding:40px;color:var(--text3)}.ft{text-align:center;padding:16px}.ft-b{font-size:10px;font-weight:600;color:var(--text3);padding:5px 12px;border-radius:20px;background:rgba(16,185,129,0.04)}@keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.note{animation:fadeIn .4s ease backwards}</style></head><body>
<div class="c"><div class="hdr"><div><h1>📝 Markdown Notes</h1><p>Koa · Node.js</p></div><button class="new-btn" onclick="createNote()">+ New</button></div>
<div class="search"><input type="text" id="searchInput" placeholder="Search notes..." oninput="searchNotes()"></div>
<div class="editor" id="editor"><input type="text" id="eTitle" placeholder="Title"><div class="tabs"><div class="tab active" onclick="switchTab('edit')">Edit</div><div class="tab" onclick="switchTab('preview')">Preview</div></div><textarea id="eContent" placeholder="Write something..." oninput="updatePreview()"></textarea><div class="preview-area" id="previewArea"></div><div class="editor-actions"><button class="save-btn" onclick="saveNote()">Save</button><button class="cancel-btn" onclick="closeEditor()">Cancel</button></div></div>
<div class="notes" id="noteList"></div>
<div class="ft"><div class="ft-b">📝 Koa · Node.js · WebToApp</div></div></div>
<script>var notes=[],currentId=null;function load(){var q=document.getElementById('searchInput').value;fetch('/api/notes'+(q?'?q='+encodeURIComponent(q):'')).then(function(r){return r.json()}).then(function(j){notes=j.data||[];renderList()})}function renderList(){var l=document.getElementById('noteList');if(notes.length===0){l.innerHTML='<div class="empty">📝 No notes yet</div>';return}l.innerHTML=notes.map(function(n,i){return'<div class="note" style="animation-delay:'+(i*.05)+'s" onclick="editNote('+n.id+')"><button class="note-del" onclick="event.stopPropagation();delNote('+n.id+')">×</button><div class="note-title">'+esc(n.title)+'</div><div class="note-preview">'+esc(n.preview||'')+'</div><div class="note-time">'+(new Date(n.updatedAt).toLocaleString())+'</div></div>'}).join('')}function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}function createNote(){fetch('/api/notes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'New Note',content:''})}).then(function(r){return r.json()}).then(function(j){if(j.success){load();editNote(j.data.id)}})}function editNote(id){fetch('/api/notes/'+id).then(function(r){return r.json()}).then(function(j){if(j.success){currentId=j.data.id;document.getElementById('eTitle').value=j.data.title;document.getElementById('eContent').value=j.data.content;document.getElementById('editor').style.display='block';switchTab('edit')}})}function saveNote(){if(!currentId)return;fetch('/api/notes/'+currentId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.getElementById('eTitle').value,content:document.getElementById('eContent').value})}).then(function(){closeEditor();load()})}function closeEditor(){document.getElementById('editor').style.display='none';currentId=null}function delNote(id){fetch('/api/notes/'+id,{method:'DELETE'}).then(function(){if(currentId===id)closeEditor();load()})}function searchNotes(){load()}function switchTab(t){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});if(t==='edit'){document.querySelector('.tab:first-child').classList.add('active');document.getElementById('eContent').style.display='block';document.getElementById('previewArea').style.display='none'}else{document.querySelector('.tab:last-child').classList.add('active');document.getElementById('eContent').style.display='none';document.getElementById('previewArea').style.display='block';updatePreview()}}function updatePreview(){var c=document.getElementById('eContent').value;document.getElementById('previewArea').innerHTML=simpleMd(c)}function simpleMd(s){return s.replace(/^### (.*$)/gm,'<h3>$1</h3>').replace(/^## (.*$)/gm,'<h2>$1</h2>').replace(/^# (.*$)/gm,'<h1>$1</h1>').replace(/\\*\\*(.*?)\\*\\*/g,'<strong>$1</strong>').replace(/\\*(.*?)\\*/g,'<em>$1</em>').replace(/\`(.*?)\`/g,'<code>$1</code>').replace(/^- (.*$)/gm,'<li>$1</li>').replace(/^> (.*$)/gm,'<blockquote>$1</blockquote>').replace(/\\n/g,'<br>')}load();</script></body></html>`;
}

View File

@ -0,0 +1,12 @@
{
"name": "notes-app",
"version": "1.0.0",
"description": "Markdown 笔记应用 - 零依赖,使用 Node.js 内置 http 模块",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {},
"keywords": ["notes", "markdown", "nodejs", "zero-dependency"],
"author": "WebToApp"
}

Some files were not shown because too many files have changed in this diff Show More