Files

Module Market

This directory is the WebToApp Module Market. Every JS/CSS module the in-app market shows is fetched directly from this folder over raw.githubusercontent.com, with cdn.jsdelivr.net/gh/ as a CDN fallback. There is no other backend. A merged PR is published the moment it lands on main.

This file is the canonical contribution guide for the Module Market. The root README.md and .github/CONTRIBUTING.md only keep high-level summaries and link here for the actual submission rules.

English · 简体中文


At a glance

modules/
├── registry.json              ← index file the app downloads first
├── submissions.json           ← CI-generated PR / contributor metadata
├── README.md                  ← this file
└── <module-path>/             ← one folder per module
    ├── module.json            ← module manifest (required)
    ├── main.js                ← module source (required)
    ├── style.css              ← optional CSS, auto-injected on load
    └── icon.png               ← optional 256 KB-max icon (also .svg/.webp/.jpg)

When a user opens the market, the app fetches both registry.json and submissions.json, then renders each entry that appears in both — that's how we guarantee the catalog only shows modules whose PR has actually been merged. Tapping Install then downloads <module-path>/module.json and main.js (plus style.css when hasCss is true) and hands the result to the local extension manager. The registry is cached for one hour; the refresh button bypasses the cache.

Heads-up: registry.json and module.json both have an icon field that takes a Material Icons name (e.g. "auto_awesome", "dark_mode"). For a real branded picture, set the registry-level iconUrl to either a relative path ("icon.png") or an absolute https:// URL. The relative form must reference one of icon.png / icon.svg / icon.webp / icon.jpg / icon.jpeg next to main.js and stay under 256 KB — larger or differently-named files will fail CI validation. When neither field is set, the app shows a first-letter avatar instead.


Submitting a module

  1. Fork shiaho777/web-to-app.
  2. Create a unique kebab-case folder under modules/, e.g. modules/dark-reader-lite/. The folder name is your path in registry.json.
  3. Add at minimum:
    • module.json — manifest, see schema
    • main.js — code that runs in the WebView
  4. Add a matching entry to registry.json. Keep id, name, version, runAt, and permissions consistent between both files (the registry is the listing surface, the manifest is what gets installed).
  5. Open a pull request. A maintainer reviews using the reviewer checklist and merges. CI validates the catalog automatically — see Local validation below if you want to self-check first.
  6. Once merged, every client picks up the new module on its next refresh.

There is no separate developer account, no API key, no submission portal.


module.json schema

{
  "id": "globally-unique-id",
  "name": "Display Name",
  "description": "Paragraph shown on the install page.",
  "icon": "material-icon-name",
  "category": "OTHER",
  "tags": ["tag1", "tag2"],
  "version": {
    "code": 1,
    "name": "1.0.0",
    "changelog": "Initial release"
  },
  "author": {
    "name": "Your Name",
    "url": "https://github.com/your-handle",
    "email": "optional@example.com"
  },
  "runAt": "DOCUMENT_END",
  "urlMatches": [
    { "pattern": "*", "isRegex": false, "exclude": false }
  ],
  "permissions": ["DOM_ACCESS"],
  "configItems": [
    {
      "key": "greeting",
      "name": "Greeting text",
      "description": "Shown in the floating banner.",
      "type": "TEXT",
      "defaultValue": "Hello, WebToApp!",
      "required": false
    }
  ]
}

Note that version here is an object with code (monotonically increasing integer), name (semver string), and changelog. In registry.json the matching field is just the semver string — same number, different shape.

Allowed category values

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.

Unknown values fall back to OTHER — it does not break the install, just hides the module from the category filter chips.

Allowed runAt values

DOCUMENT_START, DOCUMENT_END, DOCUMENT_IDLE, CONTEXT_MENU, BEFORE_UNLOAD. Defaults to DOCUMENT_END if omitted.

Allowed permissions values

The list is informational on the install screen; the runtime does not sandbox by it. Reviewers use it to spot dangerous capabilities.

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.

The dangerous ones (COOKIE, INDEXED_DB, NETWORK, WEBSOCKET, FETCH_INTERCEPT, CLIPBOARD, LOCATION, CAMERA, MICROPHONE, SCREEN_CAPTURE, FILE_ACCESS, EVAL, IFRAME) get extra scrutiny on review.

Allowed configItems[].type values

TEXT, TEXTAREA, NUMBER, BOOLEAN, SELECT, MULTI_SELECT, RADIO, CHECKBOX, COLOR, URL, EMAIL, PASSWORD, REGEX, CSS_SELECTOR, JAVASCRIPT, JSON, RANGE, DATE, TIME, DATETIME, FILE, IMAGE.

urlMatches patterns

Two pattern flavours:

  • isRegex: false (recommended): a Chrome-extension-style glob.
    • * matches any number of characters
    • *://... expands to (https?|ftp|file)://
    • <all_urls> and * both mean "every URL"
  • isRegex: true: a Java-style regex evaluated with a 200ms timeout per URL match.

Set exclude: true to make a rule subtract from the match set. If only exclude rules are present, the module matches every other URL.


registry.json entry schema

The registry is the index the app fetches first to render the list. Each entry mirrors the module manifest plus a path (folder name) and a hasCss flag.

{
  "id": "globally-unique-id",
  "path": "folder-name-under-modules",
  "name": "Display Name",
  "description": "One-line summary",
  "icon": "material-icon-name",
  "category": "OTHER",
  "tags": ["tag1", "tag2"],
  "version": "1.0.0",
  "minAppVersion": 33,
  "author": {
    "name": "Your Name",
    "url": "https://github.com/your-handle"
  },
  "runAt": "DOCUMENT_END",
  "permissions": ["DOM_ACCESS", "CSS_INJECT"],
  "urlMatches": [
    { "pattern": "*", "isRegex": false, "exclude": false }
  ],
  "hasCss": false,
  "iconUrl": "icon.png"
}

minAppVersion lets you ship a module that needs APIs only present from a specific WebToApp versionCode onwards — older clients hide the entry. The current versionCode is 47 (v2.1.8); set this only if you genuinely depend on a newer build.

iconUrl is optional. Either a relative path ("icon.png", "icon.svg", "icon.webp", "icon.jpg", "icon.jpeg") referencing a file in your module folder, or an absolute https:// URL pointing at off-repo hosting. Relative icons must stay under 256 KB. When iconUrl is absent, the app shows a circular avatar with the module's first letter.


Who shows up in the catalog (submissions.json)

submissions.json is generated by the Module Market Publish workflow on every push to main. It contains one entry per module that has actually landed on main, with PR number, merge timestamp, the GitHub identity of whoever opened the PR, and the GitHub identities of everyone else who has committed to that module's folder (the contributors list). The in-app market renders the original submitter plus these contributors as stacked avatars, and aggregates them into a contributors leaderboard.

The in-app market only shows modules that appear in submissions.json. That's the entire mechanism behind "only merged PRs are visible" — there is no allow-listing inside the app, no client-side filter to bypass; if your module isn't in this file, users won't see it.

The publish workflow:

  1. On push to main that touches modules/, walks every folder.
  2. For each folder, finds the introducing commit via git log --diff-filter=A.
  3. Asks GET /repos/{owner}/{repo}/commits/{sha}/pulls whether that commit was part of a merged PR. If yes → records PR number, URL, merged_at, and the PR author's GitHub login + avatar.
  4. Otherwise treats it as a maintainer direct-push and records the commit author — but only when the GitHub login matches the MAINTAINERS allow-list in the workflow. Anything else is left out on purpose.
  5. For every module, walks its full commit history, resolves each distinct author's GitHub login via the commit API, and records everyone except the original submitter (and bots) under contributors.
  6. Commits the regenerated file back to main.

You don't have to do anything as a contributor — just merge a PR and wait a few seconds for the publish workflow to run.


How main.js runs

The runtime wraps your code in an IIFE before injection, with these globals available:

Global Value
__MODULE_INFO__ { id, name, icon, version, uiConfig, runMode }
__MODULE_CONFIG__ object of { key: value } from saved config
__MODULE_UI_CONFIG__ the UI panel config (mirrors __MODULE_INFO__.uiConfig)
__MODULE_RUN_MODE__ 'INTERACTIVE' or 'AUTO'
getConfig(key, defaultValue) convenience accessor for __MODULE_CONFIG__

Your code is also wrapped in a try/catch — uncaught errors are logged to console.error with the module name as a prefix and do not abort the page. So you do not need to wrap everything yourself, but you do need to think about silent failures.

If you ship style.css (and set hasCss: true in registry.json), it is injected as a <style> tag with id="ext-module-<module-id>" immediately before your code runs.

A minimal hello-world:

(function () {
  var greeting = getConfig('greeting', 'Hello!');
  var banner = document.createElement('div');
  banner.textContent = greeting;
  banner.style.cssText = 'position:fixed;top:24px;left:50%;' +
    'transform:translateX(-50%);padding:10px 16px;background:#111;' +
    'color:#fff;border-radius:12px;z-index:2147483647;';
  document.body.appendChild(banner);
  setTimeout(function () { banner.remove(); }, 3000);
})();

See hello-world/main.js and reading-mode/main.js for fuller examples.

Optional: register a panel button

If your module ships an interactive UI, register it with the floating panel so the user can summon it:

__WTA_MODULE_UI__.register({
  id: __MODULE_INFO__.id,
  name: __MODULE_INFO__.name,
  icon: __MODULE_INFO__.icon,
  uiConfig: __MODULE_UI_CONFIG__,
  runMode: __MODULE_RUN_MODE__,
  onClick: function () {
    // open your UI
  }
});

If you skip this and your module has any configItems, the runtime will auto-register a default entry so the user can still open module settings.


Versioning

Bump version.code and version.name in module.json and version in registry.json together when you publish an update. The client compares semver against the locally installed copy and offers a one-tap upgrade.

User config is preserved across updates: keys still declared in the new manifest's configItems keep their values, keys you removed get cleaned up. So renaming a config key resets it to its default — bump the version and document the change in version.changelog.


Local validation

The repo ships a Python validator that mirrors the install-time checks the app performs, plus a few correctness rules CI uses to gate PRs:

python3 .github/scripts/ci/validate_modules.py

It uses only the standard library, so no pip install step is needed. The same script runs in GitHub Actions on every PR that touches modules/. A green CI run is required before a maintainer can merge.

The validator catches:

  • Invalid JSON in registry.json or any module.json
  • Missing required fields (id, name, version)
  • Unknown values for category, runAt, permissions, configItems[].type
  • Cross-file mismatches between registry.json and module.json (id, name, version, runAt, missing permissions)
  • Folder names that aren't kebab-case
  • Modules folder ↔ registry entry mismatches (orphan folders / ghost registry entries)
  • Duplicate ids or paths
  • Missing required files (module.json, main.js)
  • Stray files (warns about extra dirs, and icon files that exist on disk but are not referenced by iconUrl)
  • hasCss flag disagreeing with whether style.css is present on disk
  • iconUrl referencing a missing or oversized file in the module folder
  • Top-level return in main.js (would break inside the IIFE wrapper)
  • getConfig(...) calls without matching configItems

Reviewer checklist

For maintainers — this is what gets checked before a merge.

  • Folder name and id are unique and kebab-case
  • module.json and registry.json agree on id, name, version, runAt, permissions, and urlMatches
  • main.js is readable; no obfuscation without a source link in the PR
  • No unconditional network calls to third-party endpoints
  • No reading of document.cookie or auth tokens unless declared in permissions
  • urlMatches is appropriately scoped (avoid * for invasive modules)
  • main.js runs cleanly inside the IIFE wrapper (no top-level return)
  • runAt matches what the code expects — DOCUMENT_START modules must not assume document.body exists
  • version.code was bumped if version.name changed
  • hasCss is true if and only if style.css is present in the folder
  • If iconUrl is set, the file exists, is under 256 KB, and uses one of the allowed extensions (png, svg, webp, jpg, jpeg)
  • No unreferenced icon files or other extra files that the runtime cannot reach from iconUrl

中文

这个目录就是 WebToApp 的模块市场。所有用户在 App 里看到的市场模块都是直 接从这个文件夹通过 raw.githubusercontent.com 拉取的,CDN 兜底是 cdn.jsdelivr.net/gh/没有其他后端。PR 一旦合并到 main,下一刻就上 线。

本文件是模块市场投稿规则的唯一主文档。仓库根目录 README.md.github/CONTRIBUTING.md 只保留高层说明,真正的字段规则和提交流程都以这里 为准。

目录结构

modules/
├── registry.json              ← App 首次下载的索引
├── submissions.json           ← CI 生成:PR / 贡献者元数据
├── README.md                  ← 本文件
└── <模块目录>/
    ├── module.json            ← 模块清单(必需)
    ├── main.js                ← 模块代码(必需)
    ├── style.css              ← 可选 CSS,加载时自动注入
    └── icon.png               ← 可选图标,最大 256 KB(也支持 .svg/.webp/.jpg

App 打开市场时同时拉 registry.jsonsubmissions.json只渲染两边都 出现的条目——这是"只展示已合并 PR" 这条承诺的实现机制。点击安装时才拉 对应模块的 module.jsonmain.js(外加 style.css,如果 hasCsstrue),然后交给本地的扩展管理器。registry 缓存 1 小时,刷新按钮可绕开 缓存。

图标: registry.json 里的 iconUrl 字段可以指向模块目录里的图片 "icon.png""icon.svg""icon.webp""icon.jpg""icon.jpeg" 之一,最大 256 KB),也可以是绝对 https:// URL。如果 不填,App 会用模块名首字母自动生成圆形头像。icon/module.json::iconMaterial Icons 的字符串名, 当前主要用于本地内置模块。

submissions.json:决定谁出现在市场

submissions.jsonModule Market Publish 工作 流在每次 main 分支推送时自动生成,每条对应一个真正落地到 main 的模块, 带 PR 编号、合并时间、PR 作者的 GitHub 身份,以及该模块目录下所有其他提交者 的 GitHub 身份(contributors 列表)。App 端市场会把原始提交者和这些贡献者 以叠加头像的形式展示出来,并聚合出一个贡献者榜单。

App 端的市场只会展示出现在这个文件里的模块——这是"只展示已合并 PR"的 全部机制:客户端没有别的过滤,模块如果不在这里,用户根本看不到。

工作流逻辑:

  1. 推送到 main 且改了 modules/,遍历所有模块目录。
  2. git log --diff-filter=A 找出每个目录的引入提交。
  3. 调用 GET /repos/{owner}/{repo}/commits/{sha}/pulls 看这个提交是不是 某个已合并 PR 的一部分。是的话记录 PR 编号、URL、merged_at、PR 作者 的 GitHub login + 头像。
  4. 否则当作维护者直推处理,记录提交作者——但只有当 GitHub login 在 workflow 的 MAINTAINERS 白名单里才会被记录。其他直推故意不进。
  5. 针对每个模块,遍历其完整提交历史,用 commit API 解析出每位作者的 GitHub login,把除原始提交者(以及 bot)之外的人都记到 contributors 里。
  6. 把生成的文件 commit 回 main

作为贡献者你不用做任何事——PR 合并几秒钟之后这个工作流自动跑完,下次有 人打开市场就能看到你的模块。

提交流程

  1. Fork shiaho777/web-to-app
  2. modules/ 下新建一个唯一的 kebab-case 目录,比如 modules/dark-reader-lite/。文件夹名就是 registry.json 里的 path
  3. 至少包含:
    • module.json — 清单(schema 见英文段)
    • main.js — 在 WebView 中执行的代码
  4. registry.json 中加一条对应记录,保持 idnameversionrunAtpermissions 在两个文件之间一致
  5. 提 PR;维护者按 审核 Checklist 审查并合并。CI 会自动 校验 modules/ 目录——见下方 本地校验,可以提交前先自查
  6. 合并后所有客户端在下次刷新(默认 1 小时缓存)即可看到

字段差异

module.json 里的 version 是对象(code 整数、name 字符串、 changelog),registry.json 里的 version 直接是 name 那个字符串。 版本号要保持一致。

categoryrunAtpermissionsconfigItems[].type 的允许值与英文段 完全相同。

urlMatches 模式

  • isRegex: false(推荐):Chrome 扩展风格的 glob
    • * 匹配任意字符
    • *://... 展开为 (https?|ftp|file)://
    • <all_urls>* 都表示"匹配所有 URL"
  • isRegex: true:Java 正则,每次匹配有 200ms 超时

exclude: true 让一条规则从结果集中扣除。如果只有 exclude 规则,模块匹 配除此之外的所有 URL。

main.js 注入合约

代码会被包在 IIFE 里执行,注入的全局变量:

全局
__MODULE_INFO__ { id, name, icon, version, uiConfig, runMode }
__MODULE_CONFIG__ 用户保存的 { key: value } 配置
__MODULE_UI_CONFIG__ 面板 UI 配置
__MODULE_RUN_MODE__ 'INTERACTIVE''AUTO'
getConfig(key, defaultValue) __MODULE_CONFIG__ 的便捷访问器

代码外层还有一层 try/catch —— 抛出的异常会以模块名为前缀写到 console.error,不会中断页面其他注入。所以你不必自己再包一层 try/catch 但要意识到错误是静默吞掉的。

如果带 style.css(同时把 registry.json 里的 hasCss 设为 true), 它会以 id="ext-module-<模块id>"<style> 标签在你代码运行之前注入。

如果模块带了 configItems、但你没调用 __WTA_MODULE_UI__.register({...}) 注册一个面板按钮,运行时会自动给用户加一个入口,至少能让用户打开模块设置。

版本升级

发新版时同时module.jsonversion.code/version.nameregistry.jsonversion。客户端会用 semver 比较版本提示更新。

更新会保留用户配置:新清单 configItems 里仍然存在的 key,值会被保留;移 除的 key 会被自动清掉。所以重命名 config key 等于重置它——把更新写进 version.changelog

本地校验

仓库里有一份 Python 校验脚本,模拟 App 安装时做的检查 + CI 用来卡 PR 的几条 规则:

python3 .github/scripts/ci/validate_modules.py

只用了标准库,无需 pip install。同样的脚本会在 GitHub Actions 里跑——任何动到 modules/ 的 PR 都必须 CI 通过才能合并。

校验内容:

  • registry.json 或任何 module.json 的 JSON 解析错误
  • 必填字段缺失(idnameversion
  • category / runAt / permissions / configItems[].type 的非法枚举值
  • registry.jsonmodule.json 之间的字段不一致(idnameversionrunAt、漏报权限)
  • 文件夹名不是 kebab-case
  • modules 目录与 registry 条目不对齐(孤立目录 / 鬼条目)
  • 重复的 idpath
  • 缺少必需文件(module.jsonmain.js
  • 多余文件(例如未被 iconUrl 引用的图标文件、额外子目录等会给 warning)
  • hasCss 标志和 style.css 文件存在与否不匹配
  • iconUrl 引用了不存在或超过 256 KB 的图片
  • main.js 顶层 return(在 IIFE 包裹里会变成语法错误)
  • getConfig(...) 调用但 configItems 没声明对应字段

审核 Checklist

  • 目录名和 id 唯一且为 kebab-case
  • module.jsonregistry.jsonid/name/version/runAt/ permissions/urlMatches 一致
  • main.js 可读;没有特殊说明的话不接受混淆/压缩代码
  • 没有无条件调用第三方网络
  • 没有未在 permissions 中声明就读取 document.cookie 或鉴权 token
  • urlMatches 范围合理(侵入性强的模块不要无脑写 *
  • 代码能在 IIFE 包裹下正常执行(不要写顶层 return
  • runAt 与代码预期一致(DOCUMENT_START 不能假设 document.body 已存在)
  • version.name 变了的话 version.code 也要随之 +1
  • hasCss 当且仅当目录里有 style.css 时才为 true
  • 如果设置了 iconUrl,对应文件存在、不超过 256 KB,扩展名是 png/svg/webp/jpg/jpeg 之一
  • 不要放未被 iconUrl 引用的图标文件或其他运行时根本用不到的冗余文件