diff --git a/.commitlintrc.ts b/.commitlintrc.ts
index 281aaf6ce..c916b3617 100644
--- a/.commitlintrc.ts
+++ b/.commitlintrc.ts
@@ -1,30 +1,22 @@
-import {
- RuleConfigCondition,
- RuleConfigSeverity,
- TargetCaseType
-} from "@commitlint/types"
+import { RuleConfigSeverity, type UserConfig } from "@commitlint/types"
-export default {
+const config: UserConfig = {
rules: {
- "body-leading-blank": [RuleConfigSeverity.Error, "always"] as const,
- "body-max-line-length": [RuleConfigSeverity.Error, "always", 100] as const,
- "footer-leading-blank": [RuleConfigSeverity.Warning, "never"] as const,
- "footer-max-line-length": [
- RuleConfigSeverity.Error,
- "always",
- 100,
- ] as const,
- "header-max-length": [RuleConfigSeverity.Error, "always", 100] as const,
- "header-trim": [RuleConfigSeverity.Error, "always"] as const,
+ "body-leading-blank": [RuleConfigSeverity.Error, "always"],
+ "body-max-line-length": [RuleConfigSeverity.Error, "always", 100],
+ "footer-leading-blank": [RuleConfigSeverity.Warning, "never"],
+ "footer-max-line-length": [RuleConfigSeverity.Error, "always", 100],
+ "header-max-length": [RuleConfigSeverity.Error, "always", 100],
+ "header-trim": [RuleConfigSeverity.Error, "always"],
"subject-case": [
RuleConfigSeverity.Error,
"never",
["sentence-case", "start-case", "pascal-case", "upper-case"],
- ] as [RuleConfigSeverity, RuleConfigCondition, TargetCaseType[]],
- "subject-empty": [RuleConfigSeverity.Error, "never"] as const,
- "subject-full-stop": [RuleConfigSeverity.Error, "never", "."] as const,
- "type-case": [RuleConfigSeverity.Error, "always", "lower-case"] as const,
- "type-empty": [RuleConfigSeverity.Error, "never"] as const,
+ ],
+ "subject-empty": [RuleConfigSeverity.Error, "never"],
+ "subject-full-stop": [RuleConfigSeverity.Error, "never", "."],
+ "type-case": [RuleConfigSeverity.Error, "always", "lower-case"],
+ "type-empty": [RuleConfigSeverity.Error, "never"],
"type-enum": [
RuleConfigSeverity.Error,
"always",
@@ -42,7 +34,9 @@ export default {
"test",
"i18n",
],
- ] satisfies [RuleConfigSeverity, RuleConfigCondition, string[]],
+ ]
},
prompt: {},
-}
\ No newline at end of file
+}
+
+export default config
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml
index f4e861146..610e65a1d 100644
--- a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml
+++ b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml
@@ -40,6 +40,7 @@ body:
- Chrome
- Firefox
- Edge
+ - Brave
- Other
default: 0
- type: input
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml
index 889ce99bd..ef5ec5286 100644
--- a/.github/ISSUE_TEMPLATE/feature-request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature-request.yaml
@@ -19,6 +19,7 @@ body:
- Chrome
- Firefox
- Edge
+ - Brave
- Other
default: 0
- type: dropdown
diff --git a/.github/workflows/crowdin-export.yml b/.github/workflows/crowdin-export.yml
index afd0cded5..0ae69d015 100644
--- a/.github/workflows/crowdin-export.yml
+++ b/.github/workflows/crowdin-export.yml
@@ -10,24 +10,38 @@ jobs:
TIMER_CROWDIN_AUTH: ${{ secrets.TIMER_CROWDIN_AUTH }}
steps:
- name: Prepare branch
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 1
token: ${{secrets.GITHUB_TOKEN}}
+
- name: Test using Node.js
- uses: actions/setup-node@v1
+ uses: actions/setup-node@v6
with:
- node-version: "v22"
- - name: Install ts-node
- run: npm i -g ts-node
+ node-version: "lts/*"
+
- name: Install dependencies
run: npm install
+
- name: Export translations
- run: ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts
- - name: Test typescript
- uses: icrawl/action-tsc@v1
+ run: |
+ npx ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts 2>&1 | tee /tmp/export-output.log
+ exit ${PIPESTATUS[0]}
+ continue-on-error: true
+
+ - name: Check exporting logs
+ if: always()
+ run: |
+ if grep -q -F "[CROWDIN-ERROR]" /tmp/export-output.log; then
+ echo "::error:: Found errors in export logs"
+ exit 1
+ else
+ echo "No errors found in export logs"
+ fi
+
- name: Create Pull Request
- uses: peter-evans/create-pull-request@v7
+ if: success()
+ uses: peter-evans/create-pull-request@v8
with:
commit-message: "i18n(download): download translations by bot"
branch: crowdin-export/patch
diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml
index 8015ec452..bd5e3ea2a 100644
--- a/.github/workflows/end-to-end-tests.yml
+++ b/.github/workflows/end-to-end-tests.yml
@@ -4,20 +4,19 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v6
- name: Test using Node.js
- uses: actions/setup-node@v1
+ uses: actions/setup-node@v6
with:
- node-version: "v22"
+ node-version: "v24"
- name: Install dependencies
run: npm install
- name: Setup e2e environment
run: bash script/setup-e2e.sh --all
- - name: Start test servers
- run: bash script/setup-e2e.sh --start-servers
- name: Run tests
env:
+ DEBUG: "rstest"
USE_HEADLESS_PUPPETEER: true
run: npm run test-e2e
- name: Tests ✅
@@ -37,4 +36,4 @@ jobs:
"state": "failure",
"description": "Tests failed",
"target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- }
+ }'
diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml
new file mode 100644
index 000000000..a65a39fac
--- /dev/null
+++ b/.github/workflows/knip.yml
@@ -0,0 +1,19 @@
+name: Knip
+on:
+ pull_request:
+concurrency:
+ group: knip-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+jobs:
+ knip:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Use Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: "24"
+ - name: Install dependencies
+ run: npm install
+ - name: Run Knip
+ run: npx knip --treat-config-hints-as-errors
diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml
index 4fd25d1c8..ae4c03f4f 100644
--- a/.github/workflows/publish-all.yml
+++ b/.github/workflows/publish-all.yml
@@ -4,20 +4,38 @@ on: [workflow_dispatch]
env:
MAX_PACKAGE_SIZE: 2621440 # 2.5MB in bytes
+ FF_MIN_VER: "140.0"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
+ - name: Verify branch
+ run: |
+ if [[ "$GITHUB_REF" == refs/heads/main ]]; then
+ echo "Running on main branch ✅"
+ elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
+ git fetch origin main
+ if git merge-base --is-ancestor HEAD origin/main; then
+ echo "Tag is on main branch ✅"
+ else
+ echo "❌ Tag is not on main branch"
+ exit 1
+ fi
+ else
+ echo "❌ Not on main branch or a tag on main"
+ exit 1
+ fi
+
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
- node-version: "v22"
+ node-version: "v24"
- name: Install dependencies
run: npm install
@@ -28,6 +46,9 @@ jobs:
- name: Build for Firefox
run: npm run build:firefox
+ - name: Source package
+ run: bash script/source-archive.sh
+
- name: Check file sizes
run: |
# Check Chrome/Edge package size
@@ -53,19 +74,19 @@ jobs:
fi
- name: Upload Chrome/Edge Artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: chrome-edge-package
path: market_packages/target.zip
- name: Upload Firefox XPI Artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: firefox-xpi-package
path: market_packages/target.firefox.zip
- name: Upload Firefox Source Artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: firefox-source-package
path: market_packages/target.src.zip
@@ -75,7 +96,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download Chrome/Edge Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: chrome-edge-package
@@ -94,7 +115,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download Chrome/Edge Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: chrome-edge-package
@@ -112,21 +133,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download Firefox XPI Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: firefox-xpi-package
- name: Download Firefox Source Artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: firefox-source-package
- name: Upload to Firefox Add-on Store
- uses: wdzeng/firefox-addon@v1.2.0-alpha.0
+ uses: wdzeng/firefox-addon@v1.2.0
with:
addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}"
xpi-path: target.firefox.zip
source-file-path: target.src.zip
- compatibility: firefox, android
+ compatibility:
+ '{"firefox": {"min": "${{ env.FF_MIN_VER }}"}, "android":
+ {"min": "${{ env.FF_MIN_VER}}"}}'
jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }}
jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }}
diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml
index 1fde28733..39892ed1a 100644
--- a/.github/workflows/publish-edge.yml
+++ b/.github/workflows/publish-edge.yml
@@ -3,6 +3,8 @@ on: [workflow_dispatch]
jobs:
publish:
runs-on: ubuntu-latest
+ env:
+ ACTIONS_RUNNER_DEBUG: true
steps:
- uses: actions/checkout@v4
with:
@@ -10,7 +12,7 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
- node-version: "v22"
+ node-version: "v24"
- name: Install dependencies
run: npm install
- name: Build
diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml
index b1dc24e1e..b41cff429 100644
--- a/.github/workflows/publish-firefox.yml
+++ b/.github/workflows/publish-firefox.yml
@@ -1,26 +1,31 @@
name: Publish to Firefox Addon Store
-on: [workflow_dispatch]
+on: [ workflow_dispatch ]
jobs:
publish:
runs-on: ubuntu-latest
+ env:
+ FF_MIN_VER: "140.0"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup NodeJS
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v6
with:
- node-version: "v22"
+ node-version: "v24"
- name: Install dependencies
run: npm install
- name: Build
run: npm run build:firefox
+ - name: Source package
+ run: bash script/source-archive.sh
- name: Upload to Firefox addon store
uses: wdzeng/firefox-addon@v1.2.0-alpha.0
with:
addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}"
xpi-path: market_packages/target.firefox.zip
source-file-path: market_packages/target.src.zip
- compatibility: firefox, android
+ compatibility: "{\"firefox\": {\"min\": \"${{ env.FF_MIN_VER }}\"}, \"android\":
+ {\"min\": \"${{ env.FF_MIN_VER}}\"}}"
jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }}
jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }}
diff --git a/.gitignore b/.gitignore
index ee659ba6c..86537e1bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+tsconfig.tsbuildinfo
+
.DS_Store
node_modules
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 02e9869d3..50906233d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -8,64 +8,53 @@
},
"editor.foldingImportsByDefault": true,
"editor.trimAutoWhitespace": true,
- "javascript.format.insertSpaceAfterCommaDelimiter": true,
- "javascript.preferences.quoteStyle": "single",
- "javascript.format.semicolons": "remove",
- "javascript.format.insertSpaceBeforeFunctionParenthesis": false,
- "typescript.format.insertSpaceAfterCommaDelimiter": true,
- "typescript.preferences.quoteStyle": "single",
- "typescript.format.semicolons": "remove",
- "typescript.format.insertSpaceBeforeFunctionParenthesis": false,
+ "js/ts.format.insertSpaceAfterCommaDelimiter": true,
+ "js/ts.preferences.quoteStyle": "single",
+ "js/ts.format.semicolons": "remove",
+ "js/ts.format.insertSpaceBeforeFunctionParenthesis": false,
"files.eol": "\n",
"cSpell.words": [
"Arrayable",
"Auths",
"Cascader",
- "clbbddpinhgdejpoepalbfnkogbobfdb",
"COPYFILE",
- "countup",
"daterange",
- "delayable",
"domcontentloaded",
"dont",
"echarts",
"emsp",
"ensp",
- "filemanager",
"Hengyang",
+ "indexeddb",
"Kanban",
+ "knip",
"MKCOL",
"newtab",
- "nohup",
- "okey",
- "Openeds",
+ "otpauth",
"Popconfirm",
"PROPFIND",
"Qihu",
+ "qrcode",
"Rsdoctor",
"rspack",
+ "rstest",
"rtlcss",
"selectchanged",
"sheepzh",
"subpages",
- "Treemap",
- "Vnode",
+ "Totp",
"vueuse",
- "webcomponents",
+ "webext",
"webstore",
"webtime",
"wfhg",
- "zcvf",
- "Zhang",
- "zrender"
+ "Zhang"
],
"cSpell.ignorePaths": [
"package-lock.json",
"node_modules",
- "vscode-extension",
".git/objects",
".vscode",
- ".vscode-insiders",
// Ignore i18n resources
"src/i18n/message/**/*.json",
],
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66205e14f..e825c2e98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,86 @@ All notable changes to Time Tracker will be documented in this file.
It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge.
+## [4.3.2] - 2026-05-16
+
+- Urgently fixed some bugs
+
+## [4.3.1] - 2026-05-15
+
+- Added share button on the popup page
+- Enabled tab group tracking by default
+- Optimized data migration from other extensions
+- Fixed some bugs
+
+## [4.3.0] - 2026-05-11
+
+- Added two-factor authentication (2FA) for time limit verification
+- Optimized the behavior of time limit verification
+
+## [4.2.3] - 2026-05-08
+
+- Fixed some bugs
+- Added some translations
+
+## [4.2.2] - 2026-05-01 [For FF Mobile]
+
+- Fixed behavior on FF Mobile
+
+## [4.2.1] - 2026-04-30
+
+- Fixed some bugs for time limit
+- Optimized the style of popup page
+
+## [4.2.0] - 2026-04-28
+
+- refactored basic architecture
+- supported custom delay duration
+- added time limit on the popup page
+
+## [4.1.7] - 2026-04-23
+
+- Dropped support for Firefox versions below 140
+- Fixed some issues
+
+## [4.1.6] - 2026-04-09
+
+- Optimized some UI
+
+## [4.1.5] - 2026-03-31
+
+- Fixed some issues
+
+## [4.1.4] - 2026-03-30
+
+- Fixed some issue for Gist
+
+
+## [4.1.3] - 2026-03-24
+
+- Add data collection permission for Firefox
+
+## [4.1.2] - 2026-03-20
+
+- Supported Italian
+
+## [4.1.1] - 2026-03-13
+
+- Fixed the issue of timeline
+
+## [4.1.0] - 2026-03-07
+
+- Added notification
+- Supported time limits for mobile browsers
+
+## [4.0.1] - 2026-02-27
+
+- Fixed an IndexedDB upgrade bug on Edge
+
+## [4.0.0] - 2026-02-26
+
+- Supported IndexedDB to store the tracking data
+- Refactor the header of popup page
+
## [3.7.15] - 2026-01-21
- Fixed virtual sites' data
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7f810cac8..d4a9b6945 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,7 +14,7 @@ And [Chrome Extension Development Documentation](https://developer.chrome.com/do
Some free open source tools are also integrated:
-- Testing tool [jest](https://jestjs.io/docs/getting-started)
+- Testing tool [Rstest](https://rstest.rs)
- End-to-end integration testing [puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/puppeteer)
- I18N tool [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox)
diff --git a/README-zh.md b/README-zh.md
index 40d009a7c..551b6cc6e 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -4,6 +4,12 @@
[](https://github.com/996icu/996.ICU)
[](https://github.com/sheepzh/time-tracker-4-browser/releases)
+
+
+
+
+
+
\[ 简体中文 | [English](./README.md) \]
网费很贵是一款用于上网时间统计的浏览器插件,使用 rspack,TypeScript 和 Element-plus 进行开发。你可以在 Firefox,Chrome 和 Edge 中安装并使用它。
diff --git a/README.md b/README.md
index 9da6906bf..cfdafd23f 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,12 @@
[](https://github.com/996icu/996.ICU)
[](https://crowdin.com/project/timer-chrome-edge-firefox)
+
+
+
+
+
+
\[ English | [简体中文](./README-zh.md) \]
Time Tracker is a browser extension to track the time you spent on all websites. It's built by rspack, TypeScript and Element-plus. And you can install it for Firefox, Chrome and Edge.
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 000000000..df92792ee
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,26 @@
+# Examples Package
+
+The `examples` directory is an independent npm package for local demo and e2e helper servers.
+
+## Setup
+
+```bash
+cd examples
+npm install
+```
+
+## Available scripts
+
+- `npm run start:gist` - start Gist mock server
+- `npm run start:notification` - start notification demo server ([notification/README.md](notification/README.md))
+
+## Environment variables
+
+### Gist mock server
+
+- `PORT` (default: `12347`)
+- `GIST_TOKEN` (optional auth token; supports `token` or `Bearer` authorization header)
+
+### Notification demo server
+
+- `AUTH` (optional signature secret)
diff --git a/examples/gist/README.md b/examples/gist/README.md
new file mode 100644
index 000000000..412014f3e
--- /dev/null
+++ b/examples/gist/README.md
@@ -0,0 +1,45 @@
+# Gist Mock Server
+
+This mock server provides an in-memory Gist API for end-to-end testing.
+It follows the GitHub Gist REST contract for the endpoints needed by this project.
+
+## Supported APIs
+
+- `GET /gists?per_page=100&page=1`
+- `GET /gists/:id`
+- `POST /gists` (create)
+- `PATCH /gists/:id` (official update API)
+- `POST /gists/:id` (compat mode for current project call path)
+- `DELETE /gists/:id`
+- `GET /raw/:id/:filename`
+
+Response shape is primarily aligned with GitHub Gist REST API, while keeping compatibility with current project call paths (for example `POST /gists/:id`).
+
+## Run
+
+```bash
+cd examples
+npm install
+npm run start:gist
+```
+
+Optional environment variables:
+
+- `PORT` (default: `12347`)
+- `GIST_TOKEN` (if set, requests must contain `Authorization: token ` or `Authorization: Bearer `)
+
+## Test with curl
+
+```bash
+curl -X POST "http://localhost:12347/gists" \
+ -H "Authorization: token demo" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "public": false,
+ "description": "Used for timer to save meta info. Don'\''t change this description :)",
+ "files": {
+ "README.md": {"filename": "README.md", "content": "hello"},
+ "clients.json": {"filename": "clients.json", "content": "[]"}
+ }
+ }'
+```
diff --git a/examples/gist/mock-server.ts b/examples/gist/mock-server.ts
new file mode 100644
index 000000000..1e0888633
--- /dev/null
+++ b/examples/gist/mock-server.ts
@@ -0,0 +1,297 @@
+import { randomUUID } from "crypto"
+import { createServer, type IncomingMessage, type ServerResponse } from "http"
+
+type GistFormFile = {
+ filename: string
+ content: string
+}
+
+type GistFile = {
+ filename: string
+ type: string
+ language: string
+ raw_url: string
+ size: number
+ truncated: boolean
+ content?: string
+}
+
+type GistForm = Partial<{
+ public: boolean
+ description: string
+ files: { [filename: string]: GistFormFile | null }
+}>
+
+type Gist = {
+ url: string
+ forks_url: string
+ commits_url: string
+ id: string
+ node_id: string
+ git_pull_url: string
+ git_push_url: string
+ html_url: string
+ public: boolean
+ description: string
+ files: { [filename: string]: GistFile | null }
+ comments: number
+ comments_url: string
+ owner: null
+ user: null
+ truncated: boolean
+ history: unknown[]
+ forks: unknown[]
+ created_at: string
+ updated_at: string
+}
+
+const PORT = Number(process.env.PORT ?? 12347)
+// The same as e2e tests
+const REQUIRED_TOKEN = 'github_gist_mock_token'
+
+const gists = new Map()
+const gistOrder: string[] = []
+
+function nowIso(): string {
+ return new Date().toISOString()
+}
+
+function inferFileType(filename: string): string {
+ if (filename.endsWith(".json")) return "application/json"
+ if (filename.endsWith(".md")) return "text/markdown"
+ return "text/plain"
+}
+
+function inferLanguage(filename: string): string {
+ if (filename.endsWith(".json")) return "JSON"
+ if (filename.endsWith(".md")) return "Markdown"
+ return "Text"
+}
+
+function buildGistFile(origin: string, gistId: string, filename: string, content: string): GistFile {
+ const encodedFilename = encodeURIComponent(filename)
+ return {
+ filename,
+ type: inferFileType(filename),
+ language: inferLanguage(filename),
+ raw_url: `${origin}/raw/${gistId}/${encodedFilename}`,
+ size: Buffer.byteLength(content, "utf-8"),
+ truncated: false,
+ content,
+ }
+}
+
+function buildCreatedGist(origin: string, form: GistForm): Gist {
+ const id = randomUUID().replaceAll("-", "")
+ const ts = nowIso()
+ const files: Record = {}
+ for (const [filename, file] of Object.entries(form.files || {})) {
+ if (!file) continue
+ files[filename] = buildGistFile(origin, id, filename, file.content)
+ }
+ const gistUrl = `${origin}/gists/${id}`
+ return {
+ url: gistUrl,
+ forks_url: `${gistUrl}/forks`,
+ commits_url: `${gistUrl}/commits`,
+ id,
+ node_id: `MOCK_${id}`,
+ git_pull_url: `${origin}/${id}.git`,
+ git_push_url: `${origin}/${id}.git`,
+ html_url: `${origin}/mock/gist/${id}`,
+ public: !!form.public,
+ description: form.description ?? "",
+ files,
+ comments: 0,
+ comments_url: `${gistUrl}/comments`,
+ owner: null,
+ user: null,
+ truncated: false,
+ history: [],
+ forks: [],
+ created_at: ts,
+ updated_at: ts,
+ }
+}
+
+function updateExistingGist(origin: string, gist: Gist, form: GistForm): Gist {
+ const next: Gist = {
+ ...gist,
+ description: form.description ?? gist.description,
+ public: typeof form.public === "boolean" ? form.public : gist.public,
+ files: { ...gist.files },
+ updated_at: nowIso(),
+ }
+ for (const [filename, file] of Object.entries(form.files || {})) {
+ if (file === null) {
+ delete next.files[filename]
+ continue
+ }
+ const oldFilename = filename
+ const newFilename = file.filename || oldFilename
+ if (newFilename !== oldFilename) {
+ delete next.files[oldFilename]
+ }
+ next.files[newFilename] = buildGistFile(origin, gist.id, newFilename, file.content)
+ }
+ return next
+}
+
+
+class Handler {
+ private readonly req: IncomingMessage
+ private readonly res: ServerResponse
+ private readonly origin: string
+ private readonly url: URL
+
+ constructor(req: IncomingMessage, res: ServerResponse) {
+ this.req = req
+ this.res = res
+ this.origin = `http://${req.headers.host ?? `localhost:${PORT}`}`
+ this.url = new URL(req.url ?? "/", this.origin)
+ }
+
+ handle() {
+ try {
+ const { method, url, headers } = this.req
+ console.log(`${method} ${url} ${headers.authorization}`)
+
+ if (!this.isAuthValid()) return this.unauthorized()
+
+ if (method === "HEAD") return this.sendNoContent()
+ const { pathname } = this.url
+ if (method === "GET" && pathname === "/gists") return this.listGists()
+ if (method === "POST" && pathname === "/gists") return this.createGist()
+
+ const gistMatch = pathname.match(/^\/gists\/([^/]+)$/)
+ if (gistMatch?.[1]) {
+ const gistId = gistMatch[1]
+ if (method === "GET") return this.getGist(gistId)
+ if (method === "POST") return this.updateGist(gistId)
+ if (method === "PATCH") return this.updateGist(gistId)
+ if (method === "DELETE") return this.deleteGist(gistId)
+ }
+
+ const rawMatch = pathname.match(/^\/raw\/([^/]+)\/(.+)$/)
+ if (method === "GET" && rawMatch?.[1] && rawMatch?.[2]) {
+ const gistId = rawMatch[1]
+ const filename = decodeURIComponent(rawMatch[2])
+ return this.getRawFile(gistId, filename)
+ }
+
+ return this.notFound()
+ } catch (e) {
+ console.error("Mock gist server error:", e)
+ this.sendJson(400, { message: "Bad Request" })
+ }
+ }
+
+ private isAuthValid(): boolean {
+ const { method, headers: { authorization } } = this.req
+ const needAuth = method === "GET" || method === "POST" || method === "PATCH" || method === "DELETE"
+ if (!needAuth) return true
+ return !REQUIRED_TOKEN || `token ${REQUIRED_TOKEN}` === authorization
+ }
+
+ private async readBody(): Promise {
+ const body = await new Promise((resolve, reject) => {
+ let data = ""
+ this.req.on("data", (chunk: Buffer) => { data += chunk.toString() })
+ this.req.on("end", () => resolve(data))
+ this.req.on("error", reject)
+ })
+ return JSON.parse(body) as T
+ }
+
+ private listGists() {
+ const perPageRaw = Number(this.url.searchParams.get("per_page") || "30")
+ const pageRaw = Number(this.url.searchParams.get("page") || "1")
+ const perPage = Math.min(100, Math.max(1, Number.isNaN(perPageRaw) ? 30 : perPageRaw))
+ const page = Math.max(1, Number.isNaN(pageRaw) ? 1 : pageRaw)
+ const since = this.url.searchParams.get("since")
+ const all = gistOrder.map(id => gists.get(id)).filter(Boolean) as Gist[]
+ const filtered = since
+ ? all.filter(gist => gist.updated_at > since)
+ : all
+ const start = (page - 1) * perPage
+ const end = start + Math.max(0, perPage)
+ this.sendJson(200, filtered.slice(start, end))
+ }
+
+ private async createGist() {
+ const form = await this.readBody()
+ if (!form.files || Object.keys(form.files).length === 0) {
+ return this.validationFailed("Validation failed: files is required")
+ }
+ const gist = buildCreatedGist(this.origin, form)
+ gists.set(gist.id, gist)
+ gistOrder.unshift(gist.id)
+ this.sendJson(201, gist)
+ }
+
+ private getGist(gistId: string) {
+ const gist = gists.get(gistId)
+ if (!gist) return this.notFound()
+ this.sendJson(200, gist)
+ }
+
+ private async updateGist(gistId: string) {
+ const gist = gists.get(gistId)
+ if (!gist) return this.notFound()
+ const form = await this.readBody()
+ if (!form.description && !form.files) {
+ return this.validationFailed("Validation failed: description or files is required")
+ }
+ const updated = updateExistingGist(this.origin, gist, form)
+ gists.set(gistId, updated)
+ this.sendJson(200, updated)
+ }
+
+ private async deleteGist(gistId: string) {
+ if (!gists.has(gistId)) return this.notFound()
+ gists.delete(gistId)
+ const idx = gistOrder.findIndex(id => id === gistId)
+ if (idx >= 0) gistOrder.splice(idx, 1)
+ this.sendNoContent()
+ }
+
+ private getRawFile(gistId: string, filename: string) {
+ const gist = gists.get(gistId)
+ const file = gist?.files[filename]
+ if (!file) return this.notFound()
+ this.res.writeHead(200, { "Content-Type": file.type || "text/plain" })
+ this.res.end(file.content || "")
+ }
+
+ private sendJson(statusCode: number, payload: unknown) {
+ this.res.writeHead(statusCode, { "Content-Type": "application/json" })
+ this.res.end(JSON.stringify(payload))
+ }
+
+ private unauthorized() {
+ this.sendJson(401, { message: "Bad credentials" })
+ }
+
+ private sendNoContent() {
+ this.res.writeHead(204).end()
+ }
+
+ private validationFailed(message: string) {
+ this.sendJson(422, { message })
+ }
+
+ private notFound() {
+ this.sendJson(404, { message: "Not Found" })
+ }
+}
+
+function main() {
+ const server = createServer((req, res) => new Handler(req, res).handle())
+
+ server.listen(PORT, () => {
+ console.log(`Gist mock server listening on http://localhost:${PORT}`)
+ console.log("Set GIST_TOKEN to enable token validation")
+ })
+}
+
+main()
diff --git a/examples/host/index.html b/examples/host/index.html
new file mode 100644
index 000000000..5812731a0
--- /dev/null
+++ b/examples/host/index.html
@@ -0,0 +1,8 @@
+
+
+
+
+ Time Tracker test page
+
+
+
\ No newline at end of file
diff --git a/examples/notification/README.md b/examples/notification/README.md
new file mode 100644
index 000000000..3c13280ed
--- /dev/null
+++ b/examples/notification/README.md
@@ -0,0 +1,157 @@
+# HTTP Notification Callback
+
+The extension can push usage data to your server via an HTTP callback. This page describes:
+
+1. [Protocol contract](#protocol-contract) — what your HTTP endpoint must satisfy.
+2. [Request body (JSON)](#request-body) — every field the extension sends.
+3. [Signature verification](#signature-verification) — how to verify `Tt4b-Sign`.
+4. [Full example](#full-example) — a complete request you can use for testing.
+5. [Local demo server](#local-demo-server) — run a sample receiver locally.
+
+---
+
+## Protocol contract
+
+Your endpoint **must** satisfy every requirement below. The extension will only ever call the URL the user saved in settings.
+
+| # | Requirement |
+|---|-------------|
+| 1 | Accept **`POST`** requests. The extension never uses any other HTTP method for a real callback. |
+| 2 | The URL scheme must be **`http://`** or **`https://`**. The extension will not use other URL schemes. |
+| 3 | The request carries **`Content-Type: application/json`** with a **UTF-8** JSON body whose structure is defined in [Request body](#request-body). |
+| 4 | If the user configured an **auth token**, the request includes a **`Tt4b-Sign`** header (see [Signature verification](#signature-verification)). If no token is configured the header is **omitted** — your server must not require it in that case. |
+| 5 | Return a **2xx** status (e.g. `200`) to signal success. The extension only treats the delivery as successful for **2xx** responses. The **response body is not used**. |
+| 6 | Return a meaningful **4xx / 5xx** status to signal failure. The user may see the **HTTP status text** of your response, so use a descriptive reason phrase (e.g. `401 Unauthorized`, `400 Bad Request`). |
+
+**Out of scope:** what you do with the data (store, forward, log, etc.) is entirely up to you — as long as you return **2xx** when you have successfully accepted the payload.
+
+---
+
+## Request body
+
+The top-level JSON object contains four keys:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `meta` | `object` | Client metadata — see [`meta`](#meta). |
+| `cycle` | `string` | `"daily"` or `"weekly"` — the reporting period the user chose. |
+| `summary` | `object` | Aggregated totals for the period — see [`summary`](#summary). |
+| `row` | `array` | Per-site, per-day breakdown — see [`row`](#row-items). |
+
+### `meta`
+
+| Field | Type | Example | Description |
+|-------|------|---------|-------------|
+| `locale` | `string` | `"en"` | The app's UI locale at the time the payload was built. |
+| `version` | `string` | `"1.0.0"` | Extension version string. |
+| `ts` | `number` | `1710000000000` | When the payload was built: milliseconds since the Unix epoch. |
+
+### `summary`
+
+| Field | Type | Example | Description |
+|-------|------|---------|-------------|
+| `focus` | `number` | `3600` | Total **focus time in milliseconds** across all sites in the period. Focus time is the accumulated duration a tab was in the foreground and the user was actively interacting. |
+| `visit` | `number` | `42` | Total page-visit count across all sites in the period. |
+| `siteCount` | `number` | `3` | Number of distinct hosts present in `row`. |
+| `dateStart` | `string` | `"2025-01-15"` | First date of the period (`YYYY-MM-DD`). |
+| `dateEnd` | `string` | `"2025-01-15"` | Last date of the period (`YYYY-MM-DD`). For a daily cycle `dateStart === dateEnd`. |
+
+### `row` items
+
+Each element in the `row` array represents **one host on one day**:
+
+| Field | Type | Required | Example | Description |
+|-------|------|----------|---------|-------------|
+| `host` | `string` | yes | `"example.com"` | The site's hostname (no scheme, no path). |
+| `date` | `string` | yes | `"2025-01-15"` | The calendar date (`YYYY-MM-DD`). |
+| `focus` | `number` | yes | `1200` | Focus time **in milliseconds** for this host on this date. |
+| `time` | `number` | yes | `10` | **Visit count** for this host on that calendar day (how many page visits were recorded; **not** a length of time). |
+| `run` | `number` | no | `5000` | **Run time** in milliseconds for this host on this date (only when run-time tracking is enabled for the site; not the same as focus time). |
+
+> **Consistency check:** `summary.focus` equals the sum of all `row[].focus` values, and `summary.visit` equals the sum of all `row[].time` values.
+
+---
+
+## Signature verification
+
+| Auth token configured? | `Tt4b-Sign` header |
+|-------------------------|--------------------|
+| No (empty) | **Not sent.** Do not require or check it. |
+| Yes | `Tt4b-Sign: <64 lowercase hex characters>` |
+
+**Algorithm:** HMAC-SHA256.
+
+- **Key** — the auth-token string (UTF-8).
+- **Data** — an **empty string** (`""`).
+
+> ⚠️ The HMAC is computed over an **empty** input, **not** over the request body. This is by design — the signature proves the caller knows the shared token, not that the body is untampered. If you need body-integrity verification, hash the body separately after validating the token.
+
+```js
+import { createHmac } from 'node:crypto';
+
+function verifySign(header, token) {
+ const expected = createHmac('sha256', token).update('').digest('hex');
+ return expected === header;
+}
+```
+
+---
+
+## Full example
+
+**Request:**
+
+```
+POST /callback HTTP/1.1
+Content-Type: application/json
+Tt4b-Sign: <64 hex chars — only if token is set>
+```
+
+```json
+{
+ "meta": {
+ "locale": "en",
+ "version": "1.0.0",
+ "ts": 1710000000000
+ },
+ "cycle": "daily",
+ "summary": {
+ "focus": 3600,
+ "visit": 42,
+ "siteCount": 3,
+ "dateStart": "2025-01-15",
+ "dateEnd": "2025-01-15"
+ },
+ "row": [
+ { "host": "example.com", "date": "2025-01-15", "focus": 1200, "time": 10 },
+ { "host": "github.com", "date": "2025-01-15", "focus": 1800, "time": 25 },
+ { "host": "docs.test.io", "date": "2025-01-15", "focus": 600, "time": 7 }
+ ]
+}
+```
+
+**Expected response:** `200 OK` (body is ignored).
+
+> The three rows sum to `focus=3600` and `time=42`, matching `summary.focus` and `summary.visit`.
+
+---
+
+## Local demo server
+
+From the `examples/` package ([setup](../README.md)):
+
+```bash
+# Optional: set AUTH to the same value as the extension's auth token
+export AUTH="my-secret-token"
+npm run start:notification
+```
+
+This starts `demo-server.ts` on **port 3000**.
+
+| Method | Demo behavior |
+|--------|---------------|
+| `POST` | Parses JSON, optionally verifies `Tt4b-Sign`, responds `200` / `401` / `400`. |
+| `HEAD` | `200` (this demo only; the extension’s callback does not use `HEAD`.) |
+| Any other | `405 Method Not Allowed`. **The real callback always uses `POST`.** |
+
+> The demo only checks `meta` for the signature; it does **not** validate the full body shape. A production service should follow the [protocol contract](#protocol-contract) and field definitions above.
\ No newline at end of file
diff --git a/examples/notification/demo-server.ts b/examples/notification/demo-server.ts
new file mode 100644
index 000000000..b1af9e66c
--- /dev/null
+++ b/examples/notification/demo-server.ts
@@ -0,0 +1,56 @@
+import hash from "hash.js"
+import { createServer } from "http"
+
+const AUTH: string | undefined = process.env.AUTH
+
+function genSign(meta: any, auth: string): string {
+ return hash.hmac(hash.sha256 as any, auth).update(meta).digest('hex')
+}
+
+function verifySign(meta: any, receivedSign: string | string[] | undefined): boolean {
+ if (!AUTH) return true
+ if (!receivedSign) return false
+ return genSign(meta, AUTH) === receivedSign
+}
+
+function main() {
+ const server = createServer(async (req, res) => {
+ const method = req.method
+ if (method === 'HEAD') {
+ res.writeHead(200).end()
+ return
+ }
+
+ if (method === 'POST') {
+ try {
+ const body = await new Promise((resolve, reject) => {
+ let data = ''
+ req.on('data', (chunk: Buffer) => { data += chunk.toString() })
+ req.on('end', () => resolve(data))
+ req.on('error', reject)
+ })
+
+ const { meta } = JSON.parse(body)
+ const sign = req.headers['tt4b-sign']
+ if (!verifySign(meta, sign)) {
+ res.writeHead(401).end('Unauthorized')
+ return
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end("Thanks!!")
+ } catch (e) {
+ console.error("Failed to parse request", e)
+ res.writeHead(400).end('Bad Request')
+ }
+ return
+ }
+
+ res.writeHead(405).end('Method Not Allowed')
+ })
+
+ const port = 3000
+ server.listen(port, () => console.log(`Notification server listening on port ${port}`))
+}
+
+main()
diff --git a/examples/package.json b/examples/package.json
new file mode 100644
index 000000000..fdd09e3c0
--- /dev/null
+++ b/examples/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@tt4b/examples",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Standalone examples package for local/e2e servers",
+ "scripts": {
+ "start:gist": "ts-node gist/mock-server.ts",
+ "start:notification": "ts-node notification/demo-server.ts"
+ },
+ "dependencies": {
+ "hash.js": "^1.1.7"
+ },
+ "devDependencies": {
+ "@types/node": "^25.5.0",
+ "ts-node": "^10.9.2",
+ "typescript": "6.0.2"
+ }
+}
\ No newline at end of file
diff --git a/jest.config.ts b/jest.config.ts
deleted file mode 100644
index 60007387e..000000000
--- a/jest.config.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { readFileSync } from 'fs'
-import type { Config } from "jest"
-import { join } from 'path'
-
-const tsconfig = JSON.parse(readFileSync(join(process.cwd(), 'tsconfig.json'), 'utf-8'))
-const { compilerOptions } = tsconfig as { compilerOptions: { paths: { [key: string]: string[] } } }
-
-const { paths } = compilerOptions
-
-const aliasPattern = /^(@.*)\/\*$/
-const sourcePattern = /^(.*)\/\*$/
-
-const moduleNameMapper: { [key: string]: string } = {}
-
-Object.entries(paths).forEach(([alias, sourceArr]) => {
- const aliasMatch = alias.match(aliasPattern)
- if (!aliasMatch) {
- return
- }
- if (!Array.isArray(sourceArr) || sourceArr.length !== 1) {
- return
- }
- const sourceMath = sourceArr[0]?.match(sourcePattern)
- if (!sourceMath) {
- return
- }
- const prefix = aliasMatch[1]
- const pattern = `^${prefix}/(.*)$`
- const source = sourceMath[1]
- const sourcePath = `/${source}/$1`
- moduleNameMapper[pattern] = sourcePath
-})
-
-console.log("The moduleNameMapper parsed from tsconfig.json: ")
-console.log(moduleNameMapper)
-
-const config: Config = {
- moduleNameMapper,
- roots: [
- "/test",
- "/test-e2e",
- ],
- testRegex: '(.+)\\.test\\.(jsx?|tsx?)$',
- transform: {
- "^.+\\.tsx?$": "@swc/jest"
- },
- moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
-}
-
-export default config
\ No newline at end of file
diff --git a/knip.ts b/knip.ts
new file mode 100644
index 000000000..fc9fdf434
--- /dev/null
+++ b/knip.ts
@@ -0,0 +1,34 @@
+import type { KnipConfig } from "knip"
+const config: KnipConfig = {
+ entry: [
+ "src/background/index.ts",
+ "src/pages/app/index.ts",
+ "src/pages/popup/{index,skeleton}.ts",
+ "src/pages/side/index.ts",
+ "src/content-script/index.ts",
+ "src/content-script/limit/modal/index.ts",
+ "script/user-chart/{add,render}.ts",
+ "examples/gist/mock-server.ts",
+ "examples/notification/demo-server.ts",
+ ],
+ ignoreDependencies: [
+ "@rstest/coverage-istanbul",
+ "tsconfig-paths",
+ ],
+ rspack: {
+ config: ["rspack/rspack.{dev,prod,e2e,analyze}*.ts"],
+ },
+ rstest: {
+ config: [
+ "test/rstest.config.mts",
+ "test-e2e/rstest.config.mts",
+ ]
+ },
+ commitlint: {
+ config: [
+ ".commitlintrc.ts",
+ ]
+ }
+}
+
+export default config
\ No newline at end of file
diff --git a/package.json b/package.json
index e60ca434a..60b73098b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
- "name": "timer",
- "version": "3.7.15",
+ "name": "tt4b",
+ "version": "4.3.2",
"description": "Time tracker for browser",
"homepage": "https://www.wfhg.cc",
"scripts": {
@@ -13,9 +13,9 @@
"build": "rspack --config=rspack/rspack.prod.ts",
"build:firefox": "rspack --config=rspack/rspack.prod.firefox.ts",
"build:safari": "rspack --config=rspack/rspack.prod.safari.ts",
- "test": "jest --env=jsdom test/",
- "test-c": "jest --coverage --reporters=jest-junit --env=jsdom test/",
- "test-e2e": "jest test-e2e/ --runInBand",
+ "test": "rstest --config test/rstest.config.mts",
+ "test-c": "rstest --config test/rstest.config.mts --coverage --reporter=junit",
+ "test-e2e": "rstest --config test-e2e/rstest.config.mts",
"prepare": "husky"
},
"author": {
@@ -28,47 +28,50 @@
},
"license": "MIT",
"devDependencies": {
- "@crowdin/crowdin-api-client": "^1.52.0",
+ "@commitlint/types": "^21.0.1",
+ "@crowdin/crowdin-api-client": "^1.55.1",
"@emotion/babel-plugin": "^11.13.5",
- "@emotion/css": "^11.13.5",
- "@rsdoctor/rspack-plugin": "^1.5.0",
- "@rspack/cli": "^1.7.3",
- "@rspack/core": "^1.7.3",
- "@swc/core": "^1.15.10",
- "@swc/jest": "^0.2.39",
- "@types/chrome": "0.1.35",
+ "@rsdoctor/rspack-plugin": "^1.5.11",
+ "@rspack/cli": "^2.0.3",
+ "@rspack/core": "^2.0.3",
+ "@rstest/core": "^0.10.0",
+ "@rstest/coverage-istanbul": "^0.10.0",
+ "@types/chrome": "0.1.42",
"@types/decompress": "^4.2.7",
- "@types/jest": "^30.0.0",
- "@types/node": "^25.0.9",
- "@types/punycode": "^2.1.4",
+ "@types/firefox-webext-browser": "^143.0.0",
+ "@types/node": "^25.8.0",
"@vue/babel-plugin-jsx": "^2.0.1",
- "babel-loader": "^10.0.0",
- "commitlint": "^20.3.1",
- "css-loader": "^7.1.2",
+ "babel-loader": "^10.1.1",
+ "commitlint": "^21.0.1",
+ "css-loader": "^7.1.4",
"decompress": "^4.2.1",
+ "fake-indexeddb": "^6.2.5",
+ "fork-ts-checker-webpack-plugin": "^9.1.0",
"husky": "^9.1.7",
- "jest": "^30.2.0",
- "jest-environment-jsdom": "^30.2.0",
- "jest-junit": "^16.0.0",
+ "jsdom": "^29.1.1",
"jszip": "^3.10.1",
- "postcss": "^8.5.6",
- "postcss-loader": "^8.2.0",
- "postcss-rtlcss": "^5.7.1",
- "puppeteer": "^24.35.0",
- "ts-loader": "^9.5.4",
+ "knip": "^6.14.0",
+ "postcss": "^8.5.14",
+ "postcss-loader": "^8.2.1",
+ "postcss-rtlcss": "^6.0.0",
+ "puppeteer": "^25.0.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
- "typescript": "5.9.3"
+ "typescript": "6.0.3",
+ "unplugin-element-plus": "^0.11.2"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
+ "@emotion/css": "^11.13.5",
"echarts": "^6.0.0",
- "element-plus": "2.13.1",
- "punycode": "^2.3.1",
- "vue": "^3.5.27",
- "vue-router": "^4.6.4"
+ "element-plus": "2.14.0",
+ "hash.js": "^1.1.7",
+ "qrcode-generator": "^2.0.4",
+ "typescript-guard": "0.2.4",
+ "vue": "^3.5.34",
+ "vue-router": "^5.0.7"
},
"engines": {
"node": ">=22"
}
-}
+}
\ No newline at end of file
diff --git a/rspack/plugins/file-manager.ts b/rspack/plugins/file-manager.ts
index 01f442f02..224d99228 100644
--- a/rspack/plugins/file-manager.ts
+++ b/rspack/plugins/file-manager.ts
@@ -1,4 +1,4 @@
-import type { Compiler } from '@rspack/core'
+import type { Compiler, RspackPluginInstance } from '@rspack/core'
import fs from 'fs'
import JSZip from 'jszip'
import path from 'path'
@@ -23,7 +23,8 @@ interface FileManagerOptions {
context?: string
}
-export class FileManagerPlugin {
+export class FileManagerPlugin implements RspackPluginInstance {
+ private static readonly NAME = 'FileManagerPlugin'
private options: FileManagerOptions
private outputPath: string
@@ -33,11 +34,11 @@ export class FileManagerPlugin {
}
apply(compiler: Compiler) {
- compiler.hooks.afterEnvironment.tap('FileManagerPlugin', () => {
+ compiler.hooks.afterEnvironment.tap(FileManagerPlugin.NAME, () => {
this.outputPath = compiler.options.output.path || 'dist'
})
- compiler.hooks.done.tapPromise('FileManagerPlugin', async () => {
+ compiler.hooks.done.tapPromise(FileManagerPlugin.NAME, async () => {
if (this.options.events.onEnd) {
for (const op of this.options.events.onEnd) {
await this.processOperation(op)
diff --git a/rspack/plugins/generate-json.ts b/rspack/plugins/generate-json.ts
index 4682f3cac..d635096a9 100644
--- a/rspack/plugins/generate-json.ts
+++ b/rspack/plugins/generate-json.ts
@@ -1,38 +1,27 @@
-import { Compilation, Compiler, sources } from '@rspack/core'
+import { Compilation, type Compiler, type RspackPluginInstance, sources } from '@rspack/core'
-type GenerateJsonPluginOptions = {
- data: Record
- outputPath: string
-}
+export class GenerateJsonPlugin implements RspackPluginInstance {
+ private static readonly NAME = 'GenerateJsonPlugin'
-export class GenerateJsonPlugin {
- private options: GenerateJsonPluginOptions
-
- constructor(outputPath: string, data: Record) {
+ constructor(private outputPath: string, private data: unknown) {
if (!data || typeof data !== 'object') {
throw new Error('Invalid data option')
}
- if (!outputPath?.endsWith('.json')) {
+ if (!outputPath.endsWith('.json')) {
throw new Error('outputPath must be .json file')
}
-
- this.options = { outputPath, data }
}
apply(compiler: Compiler) {
- compiler.hooks.thisCompilation.tap('GenerateJsonPlugin', (compilation) => {
+ compiler.hooks.thisCompilation.tap(GenerateJsonPlugin.NAME, compilation => {
compilation.hooks.processAssets.tap({
- name: 'GenerateJsonPlugin',
+ name: GenerateJsonPlugin.NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
}, () => {
try {
- const json = JSON.stringify(this.options.data)
-
- compilation.emitAsset(this.options.outputPath, {
- source: () => json,
- size: () => json.length
- } as sources.Source)
-
+ const json = JSON.stringify(this.data)
+ const raw = new sources.RawSource(json)
+ compilation.emitAsset(this.outputPath, raw)
} catch (e) {
compilation.errors.push(new Error(`[SimpleWriteJson] ${(e as Error)?.message}`))
}
diff --git a/rspack/plugins/import-checker.ts b/rspack/plugins/import-checker.ts
new file mode 100644
index 000000000..bd094d3df
--- /dev/null
+++ b/rspack/plugins/import-checker.ts
@@ -0,0 +1,97 @@
+import type { Compiler, Module, ModuleGraph, RspackPluginInstance } from '@rspack/core'
+import { NormalModule } from '@rspack/core'
+
+/**
+ * Can't import content-script & pages for background
+ * Can't import background for content-script & pages
+ */
+class ImportCheckerPlugin implements RspackPluginInstance {
+ static readonly NAME = 'ImportCheckerPlugin'
+
+ apply(compiler: Compiler) {
+ compiler.hooks.compilation.tap(ImportCheckerPlugin.NAME, compilation => {
+ const moduleGraph = compilation.moduleGraph
+ compilation.hooks.finishModules.tap(
+ ImportCheckerPlugin.NAME,
+ modules => {
+ for (const mod of modules) {
+ processModule(mod, moduleGraph)
+ }
+ },
+ )
+ })
+ }
+}
+
+function processModule(mod: Module, moduleGraph: ModuleGraph) {
+ const resource = moduleFilesystemPath(mod)
+ if (!resource) return
+
+ const incoming = moduleGraph.getIncomingConnections(mod)
+ if (!incoming?.length) return
+
+ for (const conn of incoming) {
+ const originMod = conn.originModule
+ if (!originMod) continue
+ const issuer = moduleFilesystemPath(originMod)
+ if (!issuer) continue
+ const err = verify(issuer, resource)
+ if (err) throw err
+ }
+}
+
+function verify(issuer: string, resource: string): Error | undefined {
+ const issuerPath = normalizePath(issuer)
+ const resourcePath = normalizePath(resource)
+
+ const issuerInBg = isBgPath(issuerPath)
+ const issuerInOthers = isCsOrPagePath(issuerPath)
+
+ const resourceInBg = isBgPath(resourcePath)
+ const resourceInOthers = isCsOrPagePath(resourcePath)
+
+ if (issuerInBg && resourceInOthers) {
+ return new Error(
+ `[${ImportCheckerPlugin.NAME}] background must not import content-script or pages.\n`
+ + ` From: ${issuer}\n`
+ + ` To: ${resource}`,
+ )
+ }
+
+ if (issuerInOthers && resourceInBg) {
+ return new Error(
+ `[${ImportCheckerPlugin.NAME}] content-script and pages must not import background.\n`
+ + ` From: ${issuer}\n`
+ + ` To: ${resource}`,
+ )
+ }
+}
+
+function moduleFilesystemPath(mod: Module): string | undefined {
+ if (mod instanceof NormalModule) {
+ const { resource } = mod
+ if (resource) return resource
+ }
+ return mod.nameForCondition()
+}
+
+function normalizePath(p: string): string {
+ return p.replace(/\\/g, '/')
+}
+
+export function isBgPath(path: string): boolean {
+ return isUnder(normalizePath(path), 'background')
+}
+
+function isCsOrPagePath(path: string): boolean {
+ return isUnder(path, 'content-script') || isUnder(path, 'pages')
+}
+
+function isUnder(path: string, segment: string): boolean {
+ const marker = `/src/${segment}/`
+ if (path.includes(marker)) return true
+ const suffix = `/src/${segment}`
+ return path.endsWith(suffix)
+}
+
+export default ImportCheckerPlugin
diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts
index 8fbcda9c7..6f2bde349 100644
--- a/rspack/rspack.common.ts
+++ b/rspack/rspack.common.ts
@@ -1,21 +1,22 @@
import {
CopyRspackPlugin, CssExtractRspackPlugin, DefinePlugin, HtmlRspackPlugin,
- type Chunk, type Configuration,
- type RspackPluginInstance,
- type RuleSetRule
+ type Chunk, type Configuration, type Module, type RspackPluginInstance, type RuleSetRule
} from "@rspack/core"
+import { default as VueBabelPluginJsx } from "@vue/babel-plugin-jsx"
import path, { join } from "path"
import postcssRTLCSS from 'postcss-rtlcss'
+import ElementPlus from 'unplugin-element-plus/rspack'
import i18nChrome from "../src/i18n/chrome"
+import { compilerOptions } from "../tsconfig.json"
import { GenerateJsonPlugin } from "./plugins/generate-json"
+import ImportCheckerPlugin, { isBgPath } from "./plugins/import-checker"
-export const MANIFEST_JSON_NAME = "manifest.json"
+const MANIFEST_JSON_NAME = "manifest.json"
const generateJsonPlugins: RspackPluginInstance[] = []
const localeJsonFiles = Object.entries(i18nChrome)
.map(([locale, message]) => new GenerateJsonPlugin(`_locales/${locale}/messages.json`, message))
- .map(plugin => plugin as unknown as RspackPluginInstance)
generateJsonPlugins.push(...localeJsonFiles)
type EntryConfig = {
@@ -25,7 +26,7 @@ type EntryConfig = {
const BACKGROUND = 'background'
const CONTENT_SCRIPT = 'content_scripts'
-const CONTENT_SCRIPT_SKELETON = 'content_scripts_skeleton'
+const CONTENT_SCRIPT_LIMIT = 'content_scripts_limit'
const POPUP = 'popup'
const entryConfigs: EntryConfig[] = [{
@@ -35,8 +36,8 @@ const entryConfigs: EntryConfig[] = [{
name: CONTENT_SCRIPT,
path: './src/content-script',
}, {
- name: CONTENT_SCRIPT_SKELETON,
- path: './src/content-script/skeleton',
+ name: CONTENT_SCRIPT_LIMIT,
+ path: './src/content-script/limit/modal',
}, {
name: POPUP,
path: './src/pages/popup',
@@ -61,9 +62,11 @@ const POSTCSS_LOADER_CONF: RuleSetRule['use'] = {
}
const chunkFilter = ({ name }: Chunk) => {
- return !name || ![BACKGROUND, CONTENT_SCRIPT, CONTENT_SCRIPT_SKELETON].includes(name)
+ return !name || ![BACKGROUND, CONTENT_SCRIPT].includes(name)
}
+const isBackgroundModule = (module: Module) => isBgPath(module.nameForCondition?.() ?? '')
+
const staticOptions: Configuration = {
entry() {
const entry: Record = {}
@@ -83,11 +86,27 @@ const staticOptions: Configuration = {
iterableIsArray: true,
},
plugins: [
- "@vue/babel-plugin-jsx",
+ VueBabelPluginJsx,
"@emotion/babel-plugin",
],
},
- }, 'ts-loader'],
+ }, {
+ loader: 'builtin:swc-loader',
+ options: {
+ jsc: {
+ parser: {
+ syntax: 'typescript',
+ tsx: true,
+ },
+ transform: {
+ react: {
+ runtime: 'preserve',
+ },
+ },
+ target: compilerOptions.target,
+ },
+ },
+ }],
}, {
test: /\.css$/,
use: [CssExtractRspackPlugin.loader, 'css-loader', POSTCSS_LOADER_CONF],
@@ -100,18 +119,79 @@ const staticOptions: Configuration = {
resolve: {
extensions: ['.ts', '.tsx', '.js', '.css'],
tsConfig: join(__dirname, '..', 'tsconfig.json'),
+ conditionNames: ['import', 'module', 'browser', 'default'],
+ alias: {
+ 'element-plus/es/components/loading-service/style/css': 'element-plus/es/components/loading/style/css',
+ 'element-plus/es/components/loading-directive/style/css': 'element-plus/es/components/loading/style/css',
+ 'element-plus/es/components/auto-resizer/style/css': 'element-plus/es/components/table-v2/style/css',
+ },
},
optimization: {
splitChunks: {
chunks: chunkFilter,
+ maxInitialRequests: 30,
+ maxAsyncRequests: 30,
cacheGroups: {
+ echarts: {
+ test: /[\\/]node_modules[\\/]echarts[\\/]/,
+ name: 'vendor/echarts',
+ filename: 'vendor/echarts.js',
+ priority: 40,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
elementPlus: {
- name: 'element-plus',
test: /[\\/]node_modules[\\/]element-plus[\\/]/,
+ name: 'vendor/element-plus',
+ filename: 'vendor/element-plus.js',
+ priority: 39,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
+ elementIcons: {
+ test: /[\\/]node_modules[\\/]@element-plus[\\/]icons-vue[\\/]/,
+ name: 'vendor/el-icons',
+ filename: 'vendor/el-icons.js',
+ priority: 38,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
+ vue: {
+ test: /[\\/]node_modules[\\/](vue|@vue|vue-router|@vueuse)[\\/]/,
+ name: 'vendor/vue',
+ filename: 'vendor/vue.js',
+ priority: 37,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
+ dayjs: {
+ test: /[\\/node_modules][\\/]dayjs[\\/]/,
+ priority: 37,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
+ memoizeOne: {
+ test: /[\\/node_modules][\\/]memoize\\-one[\\/]/,
+ priority: 37,
+ reuseExistingChunk: true,
+ enforce: true,
+ },
+ /**
+ * Exclude src/background from the default shared chunk group so those files are
+ * never pulled into vendor/* (merging into entry name: 'background' panics in Rspack).
+ */
+ default: {
+ minChunks: 2,
+ priority: -20,
+ reuseExistingChunk: true,
+ test: module => !isBackgroundModule(module),
},
defaultVendors: {
- filename: 'vendor/[name].js'
- }
+ test: /[\\/]node_modules[\\/]/,
+ filename: 'vendor/[name].js',
+ priority: -10,
+ reuseExistingChunk: true,
+ },
}
},
},
@@ -119,14 +199,16 @@ const staticOptions: Configuration = {
type Option = {
outputPath: string
- manifest: chrome.runtime.ManifestV3 | chrome.runtime.ManifestFirefox
+ manifest: chrome.runtime.ManifestV3 | browser._manifest.WebExtensionManifest
mode: Configuration["mode"]
}
const generateOption = ({ outputPath, manifest, mode }: Option) => {
const plugins = [
...generateJsonPlugins,
+ ElementPlus({}),
new GenerateJsonPlugin(MANIFEST_JSON_NAME, manifest),
+ new ImportCheckerPlugin(),
// copy static resources
new CopyRspackPlugin({
patterns: [
@@ -136,7 +218,7 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => {
}
]
}),
- new CssExtractRspackPlugin(),
+ new CssExtractRspackPlugin({ ignoreOrder: true }),
new HtmlRspackPlugin({
filename: path.join('static', 'app.html'),
title: 'Loading...',
@@ -148,6 +230,17 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => {
},
chunks: ['app'],
}),
+ new HtmlRspackPlugin({
+ filename: path.join('static', 'limit.html'),
+ title: 'Loading...',
+ chunks: [CONTENT_SCRIPT_LIMIT],
+ meta: {
+ viewport: {
+ name: "viewport",
+ content: 'width=device-width',
+ },
+ }
+ }),
new HtmlRspackPlugin({
filename: path.join('static', 'popup.html'),
chunks: ['popup'],
diff --git a/rspack/rspack.dev.firefox.ts b/rspack/rspack.dev.firefox.ts
index ff149a7a4..adb49d4e2 100644
--- a/rspack/rspack.dev.firefox.ts
+++ b/rspack/rspack.dev.firefox.ts
@@ -3,14 +3,12 @@ import manifest from "../src/manifest-firefox"
import generateOption from "./rspack.common"
manifest.name = "IS DEV"
-// Fix the crx id for development mode
-manifest.key = "clbbddpinhgdejpoepalbfnkogbobfdb"
// The manifest.json is different from Chrome's with add-on ID
manifest.browser_specific_settings = {
...manifest.browser_specific_settings,
gecko: {
...manifest.browser_specific_settings?.gecko,
- id: 'timer@zhy',
+ id: 'tt4b@zhy',
}
}
diff --git a/rspack/rspack.dev.ts b/rspack/rspack.dev.ts
index 18ce606d4..5cf59f91e 100644
--- a/rspack/rspack.dev.ts
+++ b/rspack/rspack.dev.ts
@@ -1,3 +1,4 @@
+import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"
import path from "path"
import manifest from "../src/manifest"
import generateOption from "./rspack.common"
@@ -10,4 +11,27 @@ const options = generateOption({
mode: "development",
})
+const tsCheckerPlugin = new ForkTsCheckerWebpackPlugin({
+ typescript: {
+ configOverwrite: {
+ compilerOptions: {
+ skipLibCheck: false,
+ },
+ },
+ diagnosticOptions: {
+ syntactic: true,
+ semantic: true,
+ declaration: true,
+ global: true,
+ },
+ },
+ issue: {
+ exclude: [
+ { file: '**/node_modules/**' },
+ ],
+ },
+})
+
+options.plugins?.push(tsCheckerPlugin)
+
export default options
diff --git a/rspack/rspack.prod.firefox.ts b/rspack/rspack.prod.firefox.ts
index b36fa0cb2..b76b4c28f 100644
--- a/rspack/rspack.prod.firefox.ts
+++ b/rspack/rspack.prod.firefox.ts
@@ -2,6 +2,7 @@ import path from "path"
import manifestFirefox from "../src/manifest-firefox"
import { FileManagerPlugin } from "./plugins/file-manager"
import optionGenerator from "./rspack.common"
+import { enhancePluginWith } from './util'
const { name, version } = require(path.join(__dirname, '..', 'package.json'))
@@ -10,29 +11,10 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages')
const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.firefox.zip`)
const targetZipFilePath = path.resolve(marketPkgPath, 'target.firefox.zip')
-const normalSourceCodePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.zip`)
-const targetSourceCodePath = path.resolve(__dirname, '..', 'market_packages', 'target.src.zip')
-const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-firefox.md')
-// Temporary directory for source code to archive on Firefox
-const sourceTempDir = path.resolve(__dirname, '..', 'source_temp')
-const srcDir = [
- 'public',
- 'src',
- 'test', 'types',
- 'package.json', 'package-lock.json',
- 'tsconfig.json',
- 'rspack',
- 'jest.config.ts',
- 'script',
- ".gitignore",
-]
-const copyMapper = srcDir.map(p => { return { source: path.resolve(__dirname, '..', p), destination: path.resolve(sourceTempDir, p) } })
-const filemanagerPlugin = new FileManagerPlugin({
+const fileManagerPlugin = new FileManagerPlugin({
events: {
- // Archive at the end
onEnd: [
- // Define plugin to archive zip for different markets
{
delete: [normalZipFilePath],
archive: [{
@@ -43,26 +25,12 @@ const filemanagerPlugin = new FileManagerPlugin({
destination: targetZipFilePath,
}]
},
- // Archive source code for FireFox
- {
- copy: [
- { source: readmeForFirefox, destination: path.join(sourceTempDir, 'README.md') },
- ...copyMapper
- ],
- archive: [
- { source: sourceTempDir, destination: normalSourceCodePath },
- { source: sourceTempDir, destination: targetSourceCodePath },
- ],
- delete: [sourceTempDir],
- },
]
}
})
const option = optionGenerator({ outputPath, manifest: manifestFirefox, mode: "production" })
-const { plugins = [] } = option
-plugins.push(filemanagerPlugin)
-option.plugins = plugins
+enhancePluginWith(option, fileManagerPlugin)
option.devtool = false
-export default option
\ No newline at end of file
+export default option
diff --git a/rspack/rspack.prod.safari.ts b/rspack/rspack.prod.safari.ts
index 5a9dd8a36..e4f53201e 100644
--- a/rspack/rspack.prod.safari.ts
+++ b/rspack/rspack.prod.safari.ts
@@ -10,7 +10,7 @@ const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${na
const options = generateOption({ outputPath, manifest, mode: "production" })
-const filemanagerPlugin = new FileManagerPlugin({
+const fileManagerPlugin = new FileManagerPlugin({
events: {
// Archive at the end
onEnd: [
@@ -25,7 +25,7 @@ const filemanagerPlugin = new FileManagerPlugin({
})
const { plugins = [] } = options
-plugins.push(filemanagerPlugin)
+plugins.push(fileManagerPlugin)
options.plugins = plugins
export default options
\ No newline at end of file
diff --git a/rspack/rspack.prod.ts b/rspack/rspack.prod.ts
index 47df7f112..6ef6db0c1 100644
--- a/rspack/rspack.prod.ts
+++ b/rspack/rspack.prod.ts
@@ -12,7 +12,7 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages')
const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.mv3.zip`)
const targetZipFilePath = path.resolve(marketPkgPath, `target.zip`)
-const filemanagerPlugin = new FileManagerPlugin({
+const fileManagerPlugin = new FileManagerPlugin({
events: {
// Archive at the end
onEnd: [
@@ -34,7 +34,7 @@ const filemanagerPlugin = new FileManagerPlugin({
const option = optionGenerator({ outputPath, manifest, mode: "production" })
-enhancePluginWith(option, filemanagerPlugin)
+enhancePluginWith(option, fileManagerPlugin)
option.devtool = false
export default option
\ No newline at end of file
diff --git a/rspack/util.ts b/rspack/util.ts
index 65114cee9..6df6c9490 100644
--- a/rspack/util.ts
+++ b/rspack/util.ts
@@ -1,4 +1,4 @@
-import { type RspackOptions, type RspackPluginInstance } from '@rspack/core'
+import type { RspackOptions, RspackPluginInstance } from '@rspack/core'
export function enhancePluginWith(option: RspackOptions, ...toPush: RspackPluginInstance[]) {
const { plugins = [] } = option
diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts
index 2b9361f56..b446a48ac 100644
--- a/script/crowdin/client.ts
+++ b/script/crowdin/client.ts
@@ -79,8 +79,8 @@ class PaginationIterator {
private async processBuf() {
const pagination: Pagination = { offset: this.offset, limit: this.limit }
const list = await this.query(pagination)
- const data = list?.data
- if (!data?.length) {
+ const data = list.data
+ if (!data.length) {
this.isEnd = true
} else {
this.buf = data.map(obj => obj.data)
@@ -98,7 +98,7 @@ export type NameKey = {
branchId: number
}
-export type TranslationKey = {
+type TranslationKey = {
stringId: number
lang: CrowdinLanguage
}
@@ -229,15 +229,14 @@ export class CrowdinClient {
console.log("Content length: " + Object.keys(content).length)
for (const [path, value] of Object.entries(content)) {
const string = existStringsKeyMap[path]
+ if (!string) continue
const patch: PatchRequest[] = []
- string?.text !== value && patch.push({
+ string.text !== value && patch.push({
op: 'replace',
path: '/text',
value: value
})
- if (!patch.length) {
- continue
- }
+ if (!patch.length) continue
console.log('Try to edit string: ' + string.identifier)
await this.crowdin.sourceStringsApi.editString(PROJECT_ID, string.id, patch)
}
@@ -280,12 +279,11 @@ export class CrowdinClient {
targetLanguageIds: [...ALL_CROWDIN_LANGUAGES],
skipUntranslatedStrings: true,
})
- const buildId = buildRes?.data?.id
+ const buildId = buildRes.data.id
while (true) {
// Wait finished
const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId)
- const url = res?.data?.url
- if (url) return url
+ return res.data.url
}
}
}
@@ -296,7 +294,7 @@ export class CrowdinClient {
* @returns client
*/
export function getClientFromEnv(): CrowdinClient {
- const envVar = process.env?.TIMER_CROWDIN_AUTH
+ const envVar = process.env.TIMER_CROWDIN_AUTH
if (!envVar) {
console.error("Failed to get the variable named [TIMER_CROWDIN_AUTH]")
process.exit(1)
diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts
index 8971278c7..5e21c7e8c 100644
--- a/script/crowdin/common.ts
+++ b/script/crowdin/common.ts
@@ -22,6 +22,7 @@ export const ALL_CROWDIN_LANGUAGES = [
'zh-CN', 'zh-TW', 'ja',
'pt-PT', 'uk', 'es-ES', 'de', 'fr', 'ru', 'pl',
'ar', 'tr',
+ 'it',
] as const
/**
@@ -31,9 +32,9 @@ export const ALL_CROWDIN_LANGUAGES = [
*/
export type CrowdinLanguage = typeof ALL_CROWDIN_LANGUAGES[number]
-export const SOURCE_LOCALE: timer.RequiredLocale = 'en'
+export const SOURCE_LOCALE: tt4b.RequiredLocale = 'en'
-const OPTIONAL_PLACEHOLDER: Record = {
+const OPTIONAL_PLACEHOLDER: Record = {
ja: 0,
uk: 0,
de: 0,
@@ -45,27 +46,13 @@ const OPTIONAL_PLACEHOLDER: Record = {
zh_CN: 0,
zh_TW: 0,
pt_PT: 0,
- es: 0
+ es: 0,
+ it: 0,
}
-export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as timer.OptionalLocale[]
+export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as tt4b.OptionalLocale[]
-const CROWDIN_I18N_MAP: Record = {
- "zh-CN": 'zh_CN',
- ja: 'ja',
- 'zh-TW': 'zh_TW',
- 'pt-PT': 'pt_PT',
- uk: 'uk',
- 'es-ES': 'es',
- de: 'de',
- fr: 'fr',
- ru: 'ru',
- ar: 'ar',
- tr: 'tr',
- pl: 'pl',
-}
-
-const I18N_CROWDIN_MAP: Record = {
+const I18N_CROWDIN_MAP: Record = {
zh_CN: 'zh-CN',
ja: 'ja',
zh_TW: 'zh-TW',
@@ -78,11 +65,10 @@ const I18N_CROWDIN_MAP: Record = {
ar: 'ar',
tr: 'tr',
pl: 'pl',
+ it: 'it',
}
-export const crowdinLangOf = (locale: timer.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale]
-
-export const localeOf = (crowdinLang: CrowdinLanguage) => CROWDIN_I18N_MAP[crowdinLang]
+export const crowdinLangOf = (locale: tt4b.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale]
const IGNORED_FILE: Partial<{ [dir in Dir]: string[] }> = {
common: [
@@ -97,7 +83,7 @@ export function isIgnored(dir: Dir, fileName: string) {
return !!IGNORED_FILE[dir]?.includes(fileName)
}
-export const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message')
+const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message')
export const RSC_FILE_SUFFIX = "-resource.json"
/**
@@ -133,13 +119,13 @@ export async function readAllMessages(dir: Dir): Promise>
+ messages: Partial>
): Promise {
const dirPath = path.join(MSG_BASE, dir)
const filePath = path.join(dirPath, filename)
const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages
if (!existMessages) {
- console.error(`Failed to find local code: dir=${dir}, filename=${filename}`)
+ logError(`Failed to find local code: dir=${dir}, filename=${filename}`)
return
}
const sourceItemSet = transMsg(existMessages[SOURCE_LOCALE])
@@ -155,19 +141,23 @@ export async function mergeMessage(
// Deleted key
if (!sourceText) return
if (!checkPlaceholder(text, sourceText)) {
- console.error(`Invalid placeholder: dir=${dir}, filename=${filename}, path=${path}, source=${sourceText}, translated=${text}`)
+ logError(`Invalid placeholder: locale=${locale}, dir=${dir}, filename=${filename}, path=${path}, source=${sourceText}, translated=${text}`)
return
}
const pathSeg = path.split('.')
fillItem(pathSeg, 0, newMessage, text)
})
- Object.entries(newMessage).length && (existMessages[locale as timer.Locale] = newMessage)
+ Object.entries(newMessage).length && (existMessages[locale as tt4b.Locale] = newMessage)
})
const newFileContent = JSON.stringify(existMessages, null, 4)
fs.writeFileSync(filePath, newFileContent, { encoding: 'utf-8' })
}
+function logError(msg: string) {
+ console.error(`[CROWDIN-ERROR] ${msg}`)
+}
+
function checkPlaceholder(translated: string, source: string) {
const allSourcePlaceholders =
Array.from(source.matchAll(/\{(.*?)\}/g))
@@ -190,6 +180,7 @@ function checkPlaceholder(translated: string, source: string) {
function fillItem(fields: string[], index: number, obj: Record, text: string) {
const field = fields[index]
+ if (!field) return
if (index === fields.length - 1) {
obj[field] = text
return
@@ -227,8 +218,5 @@ export function transMsg(message: any, prefix?: string): ItemSet {
export async function checkMainBranch(client: CrowdinClient): Promise {
const branch = await client.getMainBranch()
- if (!branch) {
- exitWith("Main branch is null")
- }
- return branch!
+ return branch ?? exitWith("Main branch is null")
}
diff --git a/script/crowdin/export-translation.ts b/script/crowdin/export-translation.ts
index eca038ee3..115f437f7 100644
--- a/script/crowdin/export-translation.ts
+++ b/script/crowdin/export-translation.ts
@@ -17,7 +17,7 @@ const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip")
const TEMP_DIR = join(process.cwd(), ".crowdin-temp")
async function processDir(dir: Dir): Promise {
- const fileSets: Record>> = {}
+ const fileSets: Record>> = {}
for (const locale of ALL_TRANS_LOCALES) {
const crowdinLang = crowdinLangOf(locale)
const dirPath = join(TEMP_DIR, crowdinLang, dir)
diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts
index 184cd3026..56294533e 100644
--- a/script/crowdin/sync-translation.ts
+++ b/script/crowdin/sync-translation.ts
@@ -28,12 +28,12 @@ async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.F
}
const existList = await client.listTranslationByStringAndLang({ stringId: string.id, lang })
// Deleted old translations different from current
- const oldByOwner = existList?.filter(t => t?.user?.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text)
+ const oldByOwner = existList.filter(t => t.user.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text)
for (const toDelete of oldByOwner || []) {
await client.deleteTranslation(toDelete.id)
console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`)
}
- if (!existList?.find(t => t.text === text)) {
+ if (!existList.some(t => t.text === text)) {
// Create new translation
await client.createTranslation({ stringId: string.id, lang }, text)
console.log(`Created trans: stringId=${string.id}, lang=${lang}, text=${text}`)
diff --git a/script/psl.ts b/script/psl.ts
index aea4ced6d..fc33d526f 100644
--- a/script/psl.ts
+++ b/script/psl.ts
@@ -1,14 +1,13 @@
/**
* Build psl tree
*/
+import { type PslTree } from '@/background/psl'
import { fetchGet } from '@api/http'
-import { type PslTree } from '@util/psl'
import { writeFileSync } from 'fs'
import path from 'path'
-import punycode from "punycode"
const LIST_URL = "https://publicsuffix.org/list/effective_tld_names.dat"
-const JSON_PATH = path.join(__dirname, "..", "src", "util", "psl", "rules.json")
+const JSON_PATH = path.join(__dirname, "..", "src", "background", "psl", "rules.json")
const downloadList = async (): Promise => {
const response = await fetchGet(LIST_URL)
@@ -18,7 +17,7 @@ const downloadList = async (): Promise => {
const parse = (tree: PslTree, parts: string[], index: number) => {
if (index < 0) return
const part = parts[index]
- const ascii = punycode.toASCII(part)
+ const ascii = new URL(`http://${part}`).hostname
let node = tree[ascii]
if (index === 0) {
if (!node) tree[ascii] = 1
@@ -58,6 +57,4 @@ async function main() {
writeFileSync(JSON_PATH, JSON.stringify(tree, null, 4), { encoding: "utf-8" })
}
-
-
main()
\ No newline at end of file
diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh
index af80d5a6f..09b1da86e 100755
--- a/script/setup-e2e.sh
+++ b/script/setup-e2e.sh
@@ -139,6 +139,20 @@ install_e2e_dependencies() {
log_success "E2E dependencies installed successfully"
}
+install_mock_server_dependencies() {
+ if [ -f "$PROJECT_ROOT/examples/package.json" ]; then
+ log_step "Installing dependencies for mock server..."
+ if npm install --prefix "$PROJECT_ROOT/examples" &> /dev/null; then
+ log_success "Mock server dependencies installed successfully"
+ else
+ log_error "Failed to install mock server dependencies"
+ exit 1
+ fi
+ else
+ log_warning "examples/package.json not found, skipping mock server dependencies installation"
+ fi
+}
+
# Upgrade e2e dependencies
upgrade_e2e_dependencies() {
log_step "Upgrading e2e test dependencies..."
@@ -190,9 +204,31 @@ build_e2e_output() {
build_npm_script "dev:e2e" "dist_e2e" true
}
-# Build production output (optional)
-build_production_output() {
- build_npm_script "build" "dist_prod" false
+is_port_open() {
+ local port=$1
+ lsof -nP -iTCP:"$port" -sTCP:LISTEN > /dev/null 2>&1
+}
+
+wait_port_open() {
+ local port=$1
+ local timeout_seconds=${2:-5}
+ local start_seconds=$SECONDS
+ while true; do
+ if is_port_open "$port"; then
+ return 0
+ fi
+ if [ $((SECONDS - start_seconds)) -ge "$timeout_seconds" ]; then
+ return 1
+ fi
+ done
+}
+
+remove_pm2_app_if_exists() {
+ local app_name=$1
+ if pm2 describe "$app_name" > /dev/null 2>&1; then
+ log_info "Removing existing pm2 app: $app_name"
+ pm2 delete "$app_name" > /dev/null 2>&1 || true
+ fi
}
# Start test servers
@@ -206,24 +242,56 @@ start_test_servers() {
cd "$PROJECT_ROOT" || exit 1
- # Check if servers are already running
- if pm2 list 2>/dev/null | grep -q "http-server.*12345" || pm2 list 2>/dev/null | grep -q "http-server.*12346"; then
- log_warning "Test servers might already be running"
- log_info "Stopping existing servers..."
- pm2 stop all 2>/dev/null || true
- pm2 delete all 2>/dev/null || true
+ log_info "Checking and starting e2e-server-1..."
+ remove_pm2_app_if_exists "e2e-server-1"
+ if is_port_open 12345; then
+ log_warning "Port 12345 is already in use before starting e2e-server-1"
+ fi
+ pm2 start http-server --name "e2e-server-1" -- ./examples/host -p 12345
+ if ! wait_port_open 12345 5; then
+ log_error "e2e-server-1 failed to start on port 12345"
+ log_info "Check logs with: pm2 logs e2e-server-1 --lines 50"
+ exit 1
+ fi
+
+ if [ ! -f "$PROJECT_ROOT/examples/package.json" ]; then
+ log_error "examples package is missing. Please check examples/package.json"
+ exit 1
+ fi
+ if [ ! -d "$PROJECT_ROOT/examples/node_modules" ]; then
+ log_error "examples dependencies are not installed. Please run: cd examples && npm install"
+ exit 1
fi
- log_info "Starting test server on port 12345..."
- pm2 start "http-server ./test-e2e/example -p 12345" --name "e2e-server-1"
+ log_info "Checking and starting e2e-server-2..."
+ remove_pm2_app_if_exists "e2e-server-2"
+ if is_port_open 12346; then
+ log_warning "Port 12346 is already in use before starting e2e-server-2"
+ fi
+ pm2 start http-server --name "e2e-server-2" -- ./examples/host -p 12346
+ if ! wait_port_open 12346 5; then
+ log_error "e2e-server-2 failed to start on port 12346"
+ log_info "Check logs with: pm2 logs e2e-server-2 --lines 50"
+ exit 1
+ fi
- log_info "Starting test server on port 12346..."
- pm2 start "http-server ./test-e2e/example -p 12346" --name "e2e-server-2"
+ log_info "Checking and starting gist-mock-server..."
+ remove_pm2_app_if_exists "gist-mock-server"
+ if is_port_open 12347; then
+ log_warning "Port 12347 is already in use before starting gist-mock-server"
+ fi
+ PORT=12347 pm2 start npm --name "gist-mock-server" --cwd "$PROJECT_ROOT/examples" -- run start:gist
+ if ! wait_port_open 12347 8; then
+ log_error "gist-mock-server failed to start on port 12347"
+ log_info "Check logs with: pm2 logs gist-mock-server --lines 50"
+ exit 1
+ fi
log_success "Test servers started"
log_info "Server 1: http://127.0.0.1:12345"
log_info "Server 2: http://127.0.0.1:12346"
- log_info "To stop servers, run: pm2 stop all && pm2 delete all"
+ log_info "Gist mock: http://127.0.0.1:12347"
+ log_info "To stop servers, run: pm2 delete e2e-server-1 e2e-server-2 gist-mock-server"
log_info "To view server logs, run: pm2 logs"
}
@@ -240,8 +308,7 @@ show_usage() {
echo " --init, -i Initialize e2e environment (install dependencies if not installed)"
echo " --upgrade, -u Upgrade e2e dependencies (http-server, pm2)"
echo " --build, -b Build e2e output (runs 'npm run dev:e2e')"
- echo " --build-prod Also build production output (runs 'npm run build')"
- echo " --start-servers, -s Start test servers (http-server on ports 12345 and 12346)"
+ echo " --start-servers, -s Start test servers (12345/12346) and gist mock server (12347)"
echo " --all, -a Run all steps (init + build + build-prod)"
echo " --help, -h Show this help message"
echo ""
@@ -259,7 +326,6 @@ main() {
local do_init=false
local do_upgrade=false
local do_build=false
- local do_build_prod=false
local do_start_servers=false
# Parse arguments
@@ -277,10 +343,6 @@ main() {
do_build=true
shift
;;
- --build-prod)
- do_build_prod=true
- shift
- ;;
--start-servers|-s)
do_start_servers=true
shift
@@ -288,7 +350,7 @@ main() {
--all|-a)
do_init=true
do_build=true
- do_build_prod=true
+ do_start_servers=true
shift
;;
--help|-h)
@@ -303,7 +365,7 @@ main() {
esac
done
- if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_build_prod" = false ] && [ "$do_start_servers" = false ]; then
+ if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_start_servers" = false ]; then
show_usage
exit 0
fi
@@ -332,6 +394,7 @@ main() {
# Run requested operations
if [ "$do_init" = true ]; then
install_e2e_dependencies
+ install_mock_server_dependencies
fi
if [ "$do_upgrade" = true ]; then
@@ -342,10 +405,6 @@ main() {
build_e2e_output
fi
- if [ "$do_build_prod" = true ]; then
- build_production_output
- fi
-
if [ "$do_start_servers" = true ]; then
start_test_servers
fi
diff --git a/script/source-archive.sh b/script/source-archive.sh
new file mode 100755
index 000000000..fd7bd06d9
--- /dev/null
+++ b/script/source-archive.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+mkdir -p market_packages
+git archive --format=zip -o market_packages/target.src.zip HEAD
+zip -u -q market_packages/target.src.zip package-lock.json
+zip -d -q market_packages/target.src.zip README.md 2>/dev/null || true
+TMP=$(mktemp -d)
+cp doc/for-firefox.md "$TMP/README.md"
+( cd "$TMP" && zip -u -j -q "$ROOT/market_packages/target.src.zip" README.md )
+rm -rf "$TMP"
diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts
index 76357df59..1dc3a5d99 100644
--- a/script/user-chart/add.ts
+++ b/script/user-chart/add.ts
@@ -5,8 +5,8 @@ import {
type Gist,
type GistForm,
updateGist as updateGistApi
-} from "@src/api/gist"
-import { CHROME_ID } from "@src/util/constant/meta"
+} from "@api/gist"
+import { CHROME_ID } from "@util/constant/meta"
import fs from "fs"
import { exitWith } from "../util/process"
import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common"
@@ -34,23 +34,16 @@ function parseArgv(): AddArgv {
const argv = process.argv.slice(2)
const [a0, a1] = argv
- if (!a0) {
- exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]")
- }
+ if (!a0) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]")
if (a0 === 'auto') {
- if (!a1) exitWith("add.ts auto [dir_path]")
- return { mode: 'auto', dirPath: a1 }
+ return a1 ? { mode: 'auto', dirPath: a1 } : exitWith("add.ts auto [dir_path]")
}
- if (!a1) {
- exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]")
- }
+ if (!a1) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]")
- const browser: Browser = BROWSER_MAP[a0]
- if (!browser) {
- exitWith("add.ts [c/e/f] [file_name]")
- }
+ const browser = BROWSER_MAP[a0]
+ if (!browser) return exitWith("add.ts [c/e/f] [file_name]")
return { mode: 'manual', browser, fileName: a1 }
}
@@ -64,7 +57,9 @@ function detectBrowser(fileName: string): Browser | null {
function sortDataByKey(data: UserCount): UserCount {
const sorted: UserCount = {}
- Object.keys(data).sort().forEach(key => sorted[key] = data[key])
+ Object.entries(data)
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .forEach(([key, val]) => sorted[key] = val)
return sorted
}
@@ -91,11 +86,16 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist
const filename = filenameOf(browser)
// 1. merge
const file = gist.files[filename]
- const existData = (await getJsonFileContent(file!)) || {}
+ if (!file) {
+ exitWith(`Missing file in gist: ${filename}`)
+ }
+ const existData = (await getJsonFileContent(file)) || {}
Object.entries(data).forEach(([key, val]) => existData[key] = val)
// 2. sort by key
const sorted: UserCount = {}
- Object.keys(existData).sort().forEach(key => sorted[key] = existData[key])
+ Object.entries(existData)
+ .sort(([key1], [key2]) => key1.localeCompare(key2))
+ .forEach(([key, value]) => sorted[key] = value)
const files: Record = {}
files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) }
const gistForm: GistForm = { public: true, description, files }
@@ -105,7 +105,7 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist
function parseChrome(content: string): UserCount {
const lines = content.split('\n')
const result: Record = {}
- if (!(lines?.length > 2)) {
+ if (!(lines.length > 2)) {
return result
}
lines.slice(2).forEach(line => {
@@ -124,7 +124,7 @@ function parseChrome(content: string): UserCount {
function parseEdge(content: string): UserCount {
const lines = content.split('\n')
const result: Record = {}
- if (!(lines?.length > 1)) {
+ if (!(lines.length > 1)) {
return result
}
lines.slice(1).forEach(line => {
@@ -145,7 +145,7 @@ function parseEdge(content: string): UserCount {
function parseFirefox(content: string): UserCount {
const lines = content.split('\n')
const result: Record = {}
- if (!(lines?.length > 4)) {
+ if (!(lines.length > 4)) {
return result
}
lines.slice(4).forEach(line => {
diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts
index 505fd3439..a3a30cfc5 100644
--- a/script/user-chart/common.ts
+++ b/script/user-chart/common.ts
@@ -1,5 +1,5 @@
import { findTarget, type Gist } from "@api/gist"
-import { exitWith } from "../util/process"
+import { exitWith } from '../util/process'
export type Browser =
| 'chrome'
diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts
index 57f45b2ce..ded23a9be 100644
--- a/script/user-chart/render.ts
+++ b/script/user-chart/render.ts
@@ -13,9 +13,7 @@ import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type User
type EcOption = ComposeOption<
| LineSeriesOption
| TitleComponentOption
- | GridComponentOption
->
-
+ | GridComponentOption>
const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge']
const POINT_COUNT = 500
@@ -115,7 +113,10 @@ function zoom(data: T[], reduction: number): T[] {
let i = 0
const newData: T[] = []
while (i < data.length) {
- newData.push(data[i])
+ const item = data[i]
+ if (item !== undefined) {
+ newData.push(item)
+ }
i += reduction
}
return newData
diff --git a/script/util/process.ts b/script/util/process.ts
index ce80dc4ce..d017db756 100644
--- a/script/util/process.ts
+++ b/script/util/process.ts
@@ -2,7 +2,7 @@
/**
* @throws Will invoke ```process.exit()```
*/
-export function exitWith(msg: string) {
+export function exitWith(msg: string): never {
console.error(msg)
process.exit()
}
\ No newline at end of file
diff --git a/script/zip.sh b/script/zip.sh
index 2491e975c..b36efda56 100755
--- a/script/zip.sh
+++ b/script/zip.sh
@@ -6,12 +6,20 @@ FOLDER=$(
)
TARGET_PATH="${FOLDER}/aaa"
-COPYFILE_DISABLE=1 tar -zcvf ${TARGET_PATH} \
- --exclude=dist*/ \
- --exclude=.git/ \
- --exclude=package-lock.json \
- --exclude=node_modules \
- --exclude=firefox_dev*/ \
- --exclude=market_packages \
- --exclude=aaa \
- ./
+EXCLUDE_ARGS=""
+
+if [ -f "${FOLDER}/.gitignore" ]; then
+ while IFS= read -r line || [ -n "$line" ]; do
+ [[ -z "$line" || "$line" =~ ^# ]] && continue
+ line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
+ [ -z "$line" ] && continue
+
+ pattern="${line#/}"
+ EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=${pattern}"
+ done < "${FOLDER}/.gitignore"
+fi
+
+EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=.git"
+
+cd "${FOLDER}"
+COPYFILE_DISABLE=1 tar -zcf ${TARGET_PATH} ${EXCLUDE_ARGS} ./
diff --git a/src/api/chrome/action.ts b/src/api/chrome/action.ts
index 17aa26a54..bb52a3c7c 100644
--- a/src/api/chrome/action.ts
+++ b/src/api/chrome/action.ts
@@ -3,8 +3,10 @@ import { handleError } from "./common"
const action = IS_MV3 ? chrome.action : chrome.browserAction
-export function setBadgeText(text: string, tabId: number | undefined): Promise {
- return new Promise(resolve => action?.setBadgeText({ text, tabId }, () => {
+export function setBadgeText(text: string, tabId?: number): Promise {
+ const details: { text: string; tabId?: number } =
+ tabId === undefined ? { text } : { text, tabId }
+ return new Promise(resolve => action?.setBadgeText(details, () => {
handleError('setBadgeText')
resolve()
}))
diff --git a/src/api/chrome/alarm.ts b/src/api/chrome/alarm.ts
index 0b4c2c807..bb136462c 100644
--- a/src/api/chrome/alarm.ts
+++ b/src/api/chrome/alarm.ts
@@ -1,3 +1,4 @@
+import { IS_MV3 } from '@util/constant/environment'
import { handleError } from "./common"
type AlarmHandler = (alarm: ChromeAlarm) => PromiseLike | void
@@ -6,13 +7,33 @@ export function onAlarm(handler: AlarmHandler) {
chrome.alarms.onAlarm.addListener(handler)
}
-export function clearAlarm(name: string): Promise {
- return new Promise(resolve => chrome.alarms.clear(name, () => {
- handleError('clearAlarm')
+export async function clearAlarm(name: string): Promise {
+ if (IS_MV3) {
+ return chrome.alarms.clear(name)
+ } else {
+ return new Promise(resolve => chrome.alarms.clear(name, removed => {
+ handleError('clearAlarm')
+ resolve(removed)
+ }))
+ }
+}
+
+export function createAlarm(name: string, when: number): Promise {
+ if (IS_MV3) {
+ return chrome.alarms.create(name, { when })
+ }
+ return new Promise(resolve => chrome.alarms.create(name, { when }, () => {
+ handleError('createAlarm')
resolve()
}))
}
-export function createAlarm(name: string, when: number): void {
- chrome.alarms.create(name, { when })
+export async function getAlarm(name: string): Promise {
+ if (IS_MV3) {
+ return chrome.alarms.get(name)
+ }
+ return new Promise(resolve => chrome.alarms.get(name, alarm => {
+ handleError('getAlarm')
+ resolve(alarm)
+ }))
}
\ No newline at end of file
diff --git a/src/api/chrome/notifications.ts b/src/api/chrome/notifications.ts
new file mode 100644
index 000000000..598d04697
--- /dev/null
+++ b/src/api/chrome/notifications.ts
@@ -0,0 +1,25 @@
+import { IS_MV3 } from "@util/constant/environment"
+import { handleError } from "./common"
+
+type NotificationTopic = 'time'
+
+export async function createNotification(
+ topic: NotificationTopic,
+ options: MakeRequired
+): Promise {
+ if (IS_MV3) {
+ return await chrome.notifications.create(topic, options)
+ } else {
+ return new Promise((resolve, reject) => {
+ chrome.notifications.create(topic, options, (id: string) => {
+ const error = handleError('createNotification')
+ if (error) {
+ reject(new Error(error))
+ } else {
+ resolve(id)
+ }
+ })
+ })
+ }
+}
+
diff --git a/src/api/chrome/permission.ts b/src/api/chrome/permission.ts
index c98423958..1817d1413 100644
--- a/src/api/chrome/permission.ts
+++ b/src/api/chrome/permission.ts
@@ -1,7 +1,7 @@
import { IS_MV3 } from "@util/constant/environment"
import { handleError } from "./common"
-export async function hasPerm(perm: chrome.runtime.ManifestPermissions): Promise {
+export async function hasPerm(perm: chrome.runtime.ManifestPermission): Promise {
if (IS_MV3) {
try {
return await chrome.permissions.contains({ permissions: [perm] })
@@ -18,7 +18,7 @@ export async function hasPerm(perm: chrome.runtime.ManifestPermissions): Promise
}
}
-export async function requestPerm(perm: chrome.runtime.ManifestPermissions): Promise {
+export async function requestPerm(perm: chrome.runtime.ManifestPermission): Promise {
if (IS_MV3) {
try {
return await chrome.permissions.request({ permissions: [perm] })
diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts
index 3f40223cb..8f70371f6 100644
--- a/src/api/chrome/runtime.ts
+++ b/src/api/chrome/runtime.ts
@@ -1,4 +1,3 @@
-import { handleError } from "./common"
export function getRuntimeId(): string {
return chrome.runtime.id
@@ -8,61 +7,23 @@ export function getRuntimeName(): string {
return chrome.runtime.getManifest().name
}
-/**
- * Fix proxy data failed to serialized in Firefox
- */
-function cloneData(data: T | undefined): T | undefined {
- if (data === undefined) return undefined
- try {
- return JSON.parse(JSON.stringify(data))
- } catch (cloneError) {
- console.warn("Failed clone data", cloneError)
- return data
- }
-}
-
-export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: T): Promise {
- const request: timer.mq.Request = { code, data: cloneData(data) }
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- // timeout: no response from runtime
- resolve(undefined)
- }, 10_000)
- try {
- chrome.runtime.sendMessage(request, (response: timer.mq.Response) => {
- clearTimeout(timeout)
- handleError('sendMsg2Runtime')
- const resCode = response?.code
- resCode === 'fail' && reject(new Error(response?.msg || 'Unknown error'))
- resCode === 'success' && resolve(response.data)
- })
- } catch (e) {
- clearTimeout(timeout)
- reject('Failed to send message: ' + (e as Error)?.message || 'Unknown error')
- }
- })
+export function getIconUrl(): string {
+ return getUrl('static/images/icon.png')
}
-/**
- * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid
- * And sending messages to the runtime will be failed
- */
-export async function trySendMsg2Runtime(code: timer.mq.ReqCode, data?: Req): Promise {
- try {
- return await sendMsg2Runtime(code, data)
- } catch {
- // ignored
- }
-}
-
-export function onRuntimeMessage(handler: ChromeMessageHandler): void {
+export function onRuntimeMessage(handler: ChromeMessageHandler): void {
// Be careful!!!
// Can't use await/async in callback parameter
- chrome.runtime.onMessage.addListener((message: timer.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: timer.mq.Callback) => {
- handler(message, sender).then((response: timer.mq.Response) => {
- if (response.code === 'ignore') return
- sendResponse(response)
- })
+ chrome.runtime.onMessage.addListener((message: tt4b.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: tt4b.mq.Callback) => {
+ void handler(message, sender)
+ .then((response: tt4b.mq.Response) => {
+ sendResponse(response)
+ })
+ .catch((err: unknown) => {
+ const msg = err instanceof Error ? err.message : String(err)
+ console.error('onRuntimeMessage handler error', err)
+ sendResponse({ code: 'fail', msg })
+ })
// 'return true' will force chrome to wait for the response processed in the above promise.
// @see https://github.com/mozilla/webextension-polyfill/issues/130
return true
diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts
index a0d36d3c8..79c9e6b27 100644
--- a/src/api/chrome/tab.ts
+++ b/src/api/chrome/tab.ts
@@ -18,26 +18,7 @@ export function getTab(id: number): Promise {
}))
}
-export function resetTabUrl(tabId: number, url: string): Promise {
- return new Promise(resolve => chrome.tabs.update(tabId, {
- url: url,
- highlighted: true,
- }, () => resolve()))
-}
-
-export async function getRightOf(target: ChromeTab): Promise {
- if (!target) return
- const { windowId, index } = target
- return new Promise(resolve => chrome.tabs.query({ windowId }, tabs => {
- const rightTab = tabs
- ?.sort?.((a, b) => (a?.index ?? -1) - (b?.index ?? -1))
- ?.filter?.(t => t.index > index)
- ?.[0]
- resolve(rightTab)
- }))
-}
-
-export function getCurrentTab(): Promise {
+function getCurrentTab(): Promise {
return new Promise(resolve => chrome.tabs.getCurrent(tab => {
handleError("getCurrentTab")
resolve(tab)
@@ -82,27 +63,33 @@ export function listTabs(query?: chrome.tabs.QueryInfo): Promise {
}))
}
-export function sendMsg2Tab(tabId: number, code: timer.mq.ReqCode, data?: T): Promise {
- const request: timer.mq.Request = { code, data }
+export function sendMsg2Tab(tabId: number, code: C, data?: tt4b.tab.ReqData): Promise | undefined> {
+ const request: tt4b.tab.Request = { code, data: data as tt4b.tab.ReqData }
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject('sendMsg2Tab timeout'), 2000)
- chrome.tabs.sendMessage, timer.mq.Response>(tabId, request, response => {
+ chrome.tabs.sendMessage, tt4b.tab.Response>(tabId, request, response => {
const sendError = handleError('sendMsg2Tab')
clearTimeout(timeout)
- const resCode = response?.code
- resCode === 'success' && resolve(response.data)
- reject(new Error(response?.msg ?? sendError ?? 'Unknown error'))
+ if (response?.code === 'success') {
+ resolve(response.data as tt4b.tab.ResData | undefined)
+ return
+ }
+ if (response?.code === 'fail') {
+ reject(new Error(response.msg ?? sendError ?? 'Unknown error'))
+ return
+ }
+ reject(new Error(sendError ?? 'Unknown error'))
})
})
}
-export async function trySendMsg2Tab(
+export async function trySendMsg2Tab(
tabId: number,
- code: timer.mq.ReqCode,
- data?: T
-): Promise {
+ code: C,
+ data?: tt4b.tab.ReqData
+): Promise | undefined> {
try {
- return await sendMsg2Tab(tabId, code, data)
+ return await sendMsg2Tab(tabId, code, data)
} catch (e) {
console.warn(`Errored to send message to tab: tabId=${tabId}, code=${code}, data=${JSON.stringify(data)}`, e)
return Promise.resolve(undefined)
diff --git a/src/api/chrome/tabGroups.ts b/src/api/chrome/tabGroups.ts
index 42f490c87..2a748e6ad 100644
--- a/src/api/chrome/tabGroups.ts
+++ b/src/api/chrome/tabGroups.ts
@@ -67,18 +67,3 @@ export function isValidGroup(groupId?: number): groupId is number {
return false
}
}
-
-export const cvtGroupColor = (color?: `${chrome.tabGroups.Color}`): string => {
- switch (color) {
- case 'grey': return '#5F6369'
- case 'blue': return '#1974E8'
- case 'yellow': return '#F9AB03'
- case 'red': return '#DA3025'
- case 'green': return '#198139'
- case 'pink': return '#D01984'
- case 'purple': return '#A143F5'
- case 'cyan': return '#027B84'
- case 'orange': return '#FA913E'
- default: return '#000'
- }
-}
diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts
index 8c29b079d..3b669d6b9 100644
--- a/src/api/chrome/window.ts
+++ b/src/api/chrome/window.ts
@@ -1,93 +1,37 @@
-import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment"
+import { IS_ANDROID, IS_MV3 } from "@util/constant/environment"
import { handleError } from "./common"
-export function listAllWindows(): Promise {
- if (IS_ANDROID) {
- // windows API not supported on Firefox for Android
- return Promise.resolve([])
+export async function getLastFocusedId(): Promise {
+ if (IS_ANDROID) return Promise.resolve(undefined)
+ if (IS_MV3) {
+ const window = await chrome.windows.getLastFocused({ windowTypes: ['normal'] })
+ return window.id
}
- return new Promise(resolve => chrome.windows.getAll(windows => {
- handleError("listAllWindows")
- resolve(windows || [])
- }))
-}
-
-export function isNoneWindowId(windowId: number) {
- if (IS_ANDROID) {
- return false
- }
- return !windowId || windowId === chrome.windows.WINDOW_ID_NONE
+ return new Promise(resolve => chrome.windows.getLastFocused(
+ { windowTypes: ['normal'] },
+ ({ id }) => {
+ handleError('getLastFocusedId')
+ resolve(id)
+ },
+ ))
}
-/**
- * Reduce invoking to improve memory leak of Firefox
- *
- * @see https://github.com/sheepzh/time-tracker-4-browser/issues/599
- */
-class FocusedWindowCtx {
- last?: number | undefined
- listened: boolean = false
- windowsTypes: `${chrome.windows.WindowType}`[]
-
- constructor(windowTypes: `${chrome.windows.WindowType}`[]) {
- this.windowsTypes = windowTypes
- }
-
- async apply(): Promise {
- if (IS_ANDROID) {
- return undefined
- }
- if (this.last) {
- return isNoneWindowId(this.last) ? undefined : this.last
- }
- // init
- this.last = await this.getInner()
- if (!this.listened) {
- // filter argument is not supported for Firefox
- // @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows/onFocusChanged#addlistener_syntax
- IS_FIREFOX
- ? chrome.windows.onFocusChanged.addListener(wid => this.last = wid)
- : chrome.windows.onFocusChanged.addListener(wid => this.last = wid, { windowTypes: this.windowsTypes })
- this.listened = true
- }
- return this.last
- }
-
- private getInner(): Promise {
- return new Promise(resolve => chrome.windows.getLastFocused(
- // Only find normal window
- { windowTypes: ['normal'] },
- window => {
- handleError('getFocusedNormalWindow')
- const { focused, id } = window
- if (!focused || !id || isNoneWindowId(id)) {
- resolve(undefined)
- } else {
- resolve(id)
- }
- }
- ))
- }
+export function getWindow(id: number): Promise {
+ if (IS_ANDROID) return Promise.resolve(undefined)
+ return new Promise(resolve => chrome.windows.get(id, window => {
+ handleError('getWindow')
+ resolve(window)
+ }))
}
-const context = new FocusedWindowCtx(['normal'])
-
-export const getFocusedNormalWindowId = () => context.apply()
-export async function getWindow(id: number): Promise {
- if (IS_ANDROID) {
- return
- }
- return new Promise(resolve => chrome.windows.get(id, win => resolve(win)))
+export function isNoneWindowId(windowId: number | undefined) {
+ return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE
}
-type _Handler = (windowId: number) => void
-
-export function onNormalWindowFocusChanged(handler: _Handler) {
- if (IS_ANDROID) {
- return
- }
+export function onWindowFocusChanged(handler: ArgCallback) {
+ if (IS_ANDROID) return
chrome.windows.onFocusChanged.addListener(windowId => {
handleError('onWindowFocusChanged')
handler(windowId)
})
-}
\ No newline at end of file
+}
diff --git a/src/api/crowdin.ts b/src/api/crowdin.ts
index c25f34261..f70f76586 100644
--- a/src/api/crowdin.ts
+++ b/src/api/crowdin.ts
@@ -5,8 +5,16 @@
* https://opensource.org/licenses/MIT
*/
-import { CROWDIN_PROJECT_ID } from "@util/constant/url"
-import { fetchGet } from './http'
+import { createArrayGuard, createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard'
+import { CROWDIN_PROJECT_ID } from "../util/constant/url"
+
+type ListResponse = {
+ data: { data: T }[]
+}
+
+const createListRespGuard = (itemGuard: TypeGuard) => createObjectGuard>({
+ data: createArrayGuard(createObjectGuard({ data: itemGuard }))
+})
/**
* Used to obtain translation status
@@ -21,37 +29,55 @@ export type TranslationStatusInfo = {
translationProgress: number
}
-export type MemberInfo = {
+const isStatusResp = createListRespGuard(
+ createObjectGuard({
+ languageId: isString,
+ translationProgress: isInt,
+ })
+)
+
+type MemberInfo = {
username: string
joinedAt: string
avatarUrl: string
}
+const isMembersResp = createListRespGuard(
+ createObjectGuard({
+ username: isString,
+ joinedAt: isString,
+ avatarUrl: isString,
+ })
+)
+
export async function getTranslationStatus(): Promise {
const limit = 500
- const auth = `Bearer ${PUBLIC_TOKEN}`
const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/languages/progress?limit=${limit}`
- const response = await fetchGet(url, { headers: { "Authorization": auth } })
- const data: { data: { data: TranslationStatusInfo }[] } = await response.json()
- return data.data.map(i => i.data)
+ return await getList(url, isStatusResp)
}
export async function getMembers(): Promise {
const result: MemberInfo[] = []
- const auth = `Bearer ${PUBLIC_TOKEN}`
-
const limit = 10
let offset = 0
while (true) {
const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/members?limit=${limit}&offset=${offset}`
- const response = await fetchGet(url, { headers: { "Authorization": auth } })
- const data: { data: { data: MemberInfo }[] } = await response.json()
- const newItems = data?.data?.map(i => i.data) ?? []
+ const newItems = await getList(url, isMembersResp)
result.push(...newItems)
-
if (newItems.length < limit) break
offset += limit
}
return result
-}
\ No newline at end of file
+}
+
+async function getList(url: string, respGuard: TypeGuard>): Promise {
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: { "Authorization": `Bearer ${PUBLIC_TOKEN}` },
+ })
+ const json = await resp.json()
+ if (respGuard(json)) return json.data.map(i => i.data)
+ console.warn('Unexpected response from Crowdin API', json)
+ return []
+}
diff --git a/src/api/gist.ts b/src/api/gist.ts
index 86edd8bea..463773d28 100644
--- a/src/api/gist.ts
+++ b/src/api/gist.ts
@@ -5,7 +5,7 @@
* https://opensource.org/licenses/MIT
*/
-import FIFOCache from "@util/fifo-cache"
+import FIFOCache from "../util/fifo-cache"
import { fetchGet, fetchGetWithTry, fetchPost } from "./http"
type BaseFile = {
@@ -16,7 +16,7 @@ export type FileForm = BaseFile & {
content: string
}
-export type File = BaseFile & {
+type File = BaseFile & {
type: string
language: string
raw_url: string
@@ -161,5 +161,8 @@ export async function testToken(token: string): Promise {
}
})
const { status, statusText } = response || {}
- return status === 200 ? undefined : statusText || ("ERROR " + status)
+ if (status === 200) return undefined
+ if (status === 401) return '[401] Invalid token or no permission to access gist'
+ if (status === 403) return '[403] Access forbidden, possibly due to rate limit exceeded'
+ return statusText || `ERROR ${status}`
}
diff --git a/src/api/http.ts b/src/api/http.ts
index 6675ef081..0e3f902df 100644
--- a/src/api/http.ts
+++ b/src/api/http.ts
@@ -1,10 +1,5 @@
type Option = Omit
-export type FetchResult = {
- data?: T
- statusCode: number
-}
-
export async function fetchGetWithTry(url: string, maxTry: number, option?: Option): Promise {
let count = 0
do {
diff --git a/src/api/obsidian.ts b/src/api/obsidian.ts
index 51207be58..d9a9752b1 100644
--- a/src/api/obsidian.ts
+++ b/src/api/obsidian.ts
@@ -12,7 +12,7 @@ export const DEFAULT_VAULT = "vault"
export const INVALID_AUTH_CODE = 40101
export const NOT_FOUND_CODE = 40400
-export type ObsidianResult = {
+type ObsidianResult = {
message?: string
errorCode?: number
} & T
diff --git a/src/api/sw/backup.ts b/src/api/sw/backup.ts
new file mode 100644
index 000000000..1a9bdd177
--- /dev/null
+++ b/src/api/sw/backup.ts
@@ -0,0 +1,15 @@
+import { sendMsg2Runtime } from "./common"
+
+export const syncData = () => sendMsg2Runtime('backup.sync', undefined, 120_000)
+
+export const checkAuth = () => sendMsg2Runtime('backup.checkAuth')
+
+export const clearBackup = (cid: string) => sendMsg2Runtime('backup.clear', cid, 60_000)
+
+export const queryBackup = (param: tt4b.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param, 120_000)
+
+export const previewBackup = (param: tt4b.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param, 120_000)
+
+export const getLastBackUp = (type: tt4b.backup.Type) => sendMsg2Runtime('backup.lastTs', type)
+
+export const allBackupClients = () => sendMsg2Runtime('backup.clients')
diff --git a/src/api/sw/cate.ts b/src/api/sw/cate.ts
new file mode 100644
index 000000000..e3f2b5ca2
--- /dev/null
+++ b/src/api/sw/cate.ts
@@ -0,0 +1,3 @@
+import { sendMsg2Runtime } from "./common"
+
+export const listAllCategories = () => sendMsg2Runtime('cate.all')
diff --git a/src/api/sw/common.ts b/src/api/sw/common.ts
new file mode 100644
index 000000000..d674e93ea
--- /dev/null
+++ b/src/api/sw/common.ts
@@ -0,0 +1,63 @@
+import { handleError } from '../chrome/common'
+
+/**
+ * Fix proxy data failed to serialized in Firefox
+ */
+function cloneData(data: T | undefined): T | undefined {
+ if (data === undefined) return undefined
+ try {
+ return JSON.parse(JSON.stringify(data))
+ } catch (cloneError) {
+ console.warn("Failed clone data", cloneError)
+ return data
+ }
+}
+
+type RuntimeMsgArgs = [tt4b.mq.ReqData] extends [undefined]
+ ? [data?: tt4b.mq.ReqData, timeout_ms?: number]
+ : [data: tt4b.mq.ReqData, timeout_ms?: number]
+
+export function sendMsg2Runtime(
+ code: C,
+ ...args: RuntimeMsgArgs
+): Promise> {
+ const [data, timeout_ms] = args
+ const request: tt4b.mq.Request = { code, data: cloneData(data) as tt4b.mq.ReqData }
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ // timeout: no response from runtime
+ reject('sendMsg2Runtime timeout')
+ }, timeout_ms ?? 10_000)
+ try {
+ chrome.runtime.sendMessage(request, (response: tt4b.mq.Response) => {
+ clearTimeout(timeout)
+ handleError('sendMsg2Runtime')
+ const resCode = response?.code
+ if (resCode === 'fail') {
+ console.warn("Error occurred when querying service-worker", code, data, response?.msg)
+ return reject(new Error(response?.msg || 'Unknown error'))
+ }
+ resCode === 'success' && resolve(response.data as tt4b.mq.ResData)
+ })
+ } catch (e) {
+ clearTimeout(timeout)
+ const msg = e instanceof Error ? e.message : 'Unknown error'
+ reject(`Failed to send message: ${msg}`)
+ }
+ })
+}
+
+/**
+ * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid
+ * And sending messages to the runtime will be failed
+ */
+export async function trySendMsg2Runtime(
+ code: C,
+ ...args: RuntimeMsgArgs
+): Promise | undefined> {
+ try {
+ return await sendMsg2Runtime(code, ...args)
+ } catch {
+ return undefined
+ }
+}
diff --git a/src/api/sw/immigration.ts b/src/api/sw/immigration.ts
new file mode 100644
index 000000000..05c10e155
--- /dev/null
+++ b/src/api/sw/immigration.ts
@@ -0,0 +1,5 @@
+import { sendMsg2Runtime } from "./common"
+
+export function importOther(query: tt4b.imported.ProcessQuery) {
+ return sendMsg2Runtime('immigration.importOther', query, 60_000)
+}
diff --git a/src/api/sw/limit.ts b/src/api/sw/limit.ts
new file mode 100644
index 000000000..052e32d84
--- /dev/null
+++ b/src/api/sw/limit.ts
@@ -0,0 +1,11 @@
+import { sendMsg2Runtime } from "./common"
+
+export const listLimits = (query?: tt4b.limit.Query) => sendMsg2Runtime('limit.list', query)
+
+export const getLimitSummary = () => sendMsg2Runtime('limit.summary')
+
+export const deleteLimits = (ids: number[]) => sendMsg2Runtime('limit.delete', ids)
+
+export const updateLimits = (rules: tt4b.limit.Rule[]) => sendMsg2Runtime('limit.update', rules)
+
+export const addLimit = (rule: Omit) => sendMsg2Runtime('limit.add', rule)
diff --git a/src/api/sw/merge.ts b/src/api/sw/merge.ts
new file mode 100644
index 000000000..6e3f06516
--- /dev/null
+++ b/src/api/sw/merge.ts
@@ -0,0 +1,7 @@
+import { sendMsg2Runtime } from "./common"
+
+export const listAllMergeRules = () => sendMsg2Runtime('merge.all')
+
+export const deleteMergeRule = (origin: string) => sendMsg2Runtime('merge.delete', origin)
+
+export const addMergeRule = (rule: tt4b.merge.Rule) => sendMsg2Runtime('merge.add', rule)
\ No newline at end of file
diff --git a/src/api/sw/option.ts b/src/api/sw/option.ts
new file mode 100644
index 000000000..d8735a45c
--- /dev/null
+++ b/src/api/sw/option.ts
@@ -0,0 +1,14 @@
+import { sendMsg2Runtime } from "@api/sw/common"
+
+export const getOption = () => sendMsg2Runtime('option.get')
+
+export const setOption = (option: Partial) => sendMsg2Runtime('option.set', option)
+
+export const getWeekStartTime = async (now?: number | Date): Promise => {
+ let nowTs = typeof now === 'number' ? now : now?.getTime()
+ nowTs = nowTs ?? Date.now()
+ const startTs = await sendMsg2Runtime('option.weekStartTime', nowTs)
+ return new Date(startTs)
+}
+
+export const getWeekStartDay = () => sendMsg2Runtime('option.weekStartDay')
diff --git a/src/api/sw/period.ts b/src/api/sw/period.ts
new file mode 100644
index 000000000..18b55c9d0
--- /dev/null
+++ b/src/api/sw/period.ts
@@ -0,0 +1,3 @@
+import { sendMsg2Runtime } from './common'
+
+export const listPeriods = (param: tt4b.period.Query) => sendMsg2Runtime('period.list', param)
\ No newline at end of file
diff --git a/src/api/sw/site.ts b/src/api/sw/site.ts
new file mode 100644
index 000000000..0109e51ae
--- /dev/null
+++ b/src/api/sw/site.ts
@@ -0,0 +1,31 @@
+import { sendMsg2Runtime } from "./common"
+
+export const listSites = (param?: tt4b.site.Query) => sendMsg2Runtime('site.list', param)
+
+export function getSitePage(param?: tt4b.site.Query, page?: tt4b.common.PageQuery) {
+ return sendMsg2Runtime('site.page', { ...param, ...page })
+}
+
+export const deleteSites = (...keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.delete', keys)
+
+export function changeSitesCate(cateId: number | undefined, ...keys: tt4b.site.SiteKey[]) {
+ return sendMsg2Runtime('site.changeCate', { keys, cateId })
+}
+
+export const deleteSiteIcon = (key: tt4b.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key)
+
+export async function changeSiteAlias(key: tt4b.site.SiteKey, alias: string | undefined): Promise {
+ const trimmed = alias?.trim() || undefined
+ await sendMsg2Runtime('site.changeAlias', { key, alias: trimmed })
+ return trimmed
+}
+
+export const fillInitialAlias = (keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys)
+
+export const getInitialAlias = (host: string) => sendMsg2Runtime('site.initialAlias', host)
+
+export function changeSiteRun(key: tt4b.site.SiteKey, enabled: boolean) {
+ return sendMsg2Runtime('site.changeRun', { key, enabled })
+}
+
+export const searchSite = (query?: string) => sendMsg2Runtime('site.search', query)
diff --git a/src/api/sw/stat.ts b/src/api/sw/stat.ts
new file mode 100644
index 000000000..534b02f35
--- /dev/null
+++ b/src/api/sw/stat.ts
@@ -0,0 +1,31 @@
+import { sendMsg2Runtime } from "./common"
+
+export const listSiteStats = (param?: tt4b.stat.SiteQuery) => sendMsg2Runtime('stat.sites', param)
+
+export const getSiteStatPage = (param?: tt4b.stat.SitePageQuery) => sendMsg2Runtime('stat.sitePage', param)
+
+export function deleteSiteStatByHost(host: string, date?: [string?, string?] | string) {
+ return sendMsg2Runtime('stat.deleteSite', { host, date })
+}
+
+export function deleteSiteStatByGroup(groupId: number, date?: [string?, string?] | string) {
+ return sendMsg2Runtime('stat.deleteSite', { groupId, date })
+}
+
+export const listCateStats = (param?: tt4b.stat.CateQuery) => sendMsg2Runtime('stat.cates', param)
+
+export const getCateStatPage = (param?: tt4b.stat.CatePageQuery) => sendMsg2Runtime('stat.catePage', param)
+
+export const listGroupStats = (param?: tt4b.stat.GroupQuery) => sendMsg2Runtime('stat.groups', param)
+
+export const getGroupStatPage = (param?: tt4b.stat.GroupPageQuery) => sendMsg2Runtime('stat.groupPage', param)
+
+export const batchDeleteStats = (targets: tt4b.stat.Row[]) => sendMsg2Runtime('stat.batchDelete', targets)
+
+export function countGroupStatsByIds(groupIds: number[], date: string | [string?, string?]) {
+ return sendMsg2Runtime('stat.countGroup', { groupIds, date })
+}
+
+export function countSiteStatsByHosts(hosts: string[], date: string | [string?, string?]) {
+ return sendMsg2Runtime('stat.countSite', { host: hosts, date })
+}
diff --git a/src/api/sw/whitelist.ts b/src/api/sw/whitelist.ts
new file mode 100644
index 000000000..14e3744df
--- /dev/null
+++ b/src/api/sw/whitelist.ts
@@ -0,0 +1,7 @@
+import { sendMsg2Runtime } from "./common"
+
+export const listWhitelist = () => sendMsg2Runtime('whitelist.all')
+
+export const addWhitelist = (white: string) => sendMsg2Runtime('whitelist.add', white)
+
+export const deleteWhitelist = (white: string) => sendMsg2Runtime('whitelist.delete', white)
diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts
index 6e445d419..d8cb042d3 100644
--- a/src/api/web-dav.ts
+++ b/src/api/web-dav.ts
@@ -3,7 +3,7 @@
*
* Testing with server implemented by https://github.com/svtslv/webdav-cli
*/
-import { encode } from '@util/base64'
+import { encodeBase64 } from '../util/encode'
import { fetchDelete, fetchGet } from './http'
// Only support password for now
@@ -22,7 +22,7 @@ const authHeaders = (auth: WebDAVAuth): Headers => {
const type = auth?.type
const headers = new Headers()
if (type === 'password') {
- headers.set('Authorization', `Basic ${encode(`${auth?.username}:${auth?.password}`)}`)
+ headers.set('Authorization', `Basic ${encodeBase64(`${auth?.username}:${auth?.password}`)}`)
}
return headers
}
diff --git a/src/background/browser-action-manager.ts b/src/background/action.ts
similarity index 80%
rename from src/background/browser-action-manager.ts
rename to src/background/action.ts
index 12af14126..5ea65c59c 100644
--- a/src/background/browser-action-manager.ts
+++ b/src/background/action.ts
@@ -1,21 +1,21 @@
-/**
+/**
* Copyright (c) 2021-present Hengyang Zhang
*
* This software is released under the MIT License.
* https://opensource.org/licenses/MIT
*/
+import { APP_OPTION_ROUTE, APP_REPORT_ROUTE } from "@/shared/route"
import { onIconClick } from "@api/chrome/action"
import { createContextMenu } from "@api/chrome/context-menu"
import { getRuntimeId } from "@api/chrome/runtime"
import { createTab } from "@api/chrome/tab"
import { locale } from "@i18n"
import { t2Chrome } from "@i18n/chrome/t"
-import { IS_ANDROID, IS_FIREFOX, IS_MV3, IS_SAFARI } from "@util/constant/environment"
+import { IS_ANDROID, IS_MV3, IS_SAFARI } from "@util/constant/environment"
import {
CHANGE_LOG_PAGE, GITHUB_ISSUE_ADD, SOURCE_CODE_PAGE, TU_CAO_PAGE,
getAppPageUrl, getGuidePageUrl,
} from "@util/constant/url"
-import { OPTION_ROUTE, REPORT_ROUTE } from "../pages/app/router/constants"
const APP_PAGE_URL = getAppPageUrl()
@@ -36,13 +36,6 @@ function titleOf(prefixEmoji: string, title: string) {
}
}
-const sidebarProps: ChromeContextMenuCreateProps = {
- id: getRuntimeId() + '_timer_menu_item_sidebar',
- title: titleOf('🖱️', t2Chrome(msg => msg.base.sidebar)),
- onclick: () => browser.sidebarAction.open(),
- ...baseProps,
-}
-
const allFunctionProps: ChromeContextMenuCreateProps = {
id: getRuntimeId() + '_timer_menu_item_app_link',
title: titleOf('🏷️', t2Chrome(msg => msg.base.allFunction)),
@@ -53,7 +46,7 @@ const allFunctionProps: ChromeContextMenuCreateProps = {
const optionPageProps: ChromeContextMenuCreateProps = {
id: getRuntimeId() + '_timer_menu_item_option_link',
title: titleOf('🥰', t2Chrome(msg => msg.base.option)),
- onclick: () => createTab(APP_PAGE_URL + '#' + OPTION_ROUTE),
+ onclick: () => createTab(getAppPageUrl(APP_OPTION_ROUTE)),
...baseProps
}
@@ -85,9 +78,7 @@ const changeLogProps: ChromeContextMenuCreateProps = {
...baseProps
}
-function initBrowserAction() {
- // Create sidebar item for Firefox
- createContextMenu(IS_FIREFOX ? sidebarProps : allFunctionProps)
+export function initBrowserAction() {
createContextMenu(allFunctionProps)
createContextMenu(optionPageProps)
createContextMenu(repoPageProps)
@@ -97,8 +88,15 @@ function initBrowserAction() {
if (IS_ANDROID) {
// Forbidden popup page
- onIconClick(() => createTab({ url: getAppPageUrl(REPORT_ROUTE) }))
+ onIconClick(() => createTab({ url: getAppPageUrl(APP_REPORT_ROUTE) }))
}
}
-export default initBrowserAction
+export function initSidePanel() {
+ if (!IS_MV3) return
+ const sidePanel = chrome.sidePanel
+ // sidePanel not supported for Firefox
+ // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint
+ if (!sidePanel?.setOptions) return
+ sidePanel.setOptions({ path: "/static/side.html" })
+}
diff --git a/src/background/alarm-manager.ts b/src/background/alarm-manager.ts
index e3728a8a2..7dd45cf8c 100644
--- a/src/background/alarm-manager.ts
+++ b/src/background/alarm-manager.ts
@@ -1,10 +1,10 @@
-import { clearAlarm, createAlarm, onAlarm } from "@api/chrome/alarm"
+import { clearAlarm, createAlarm, getAlarm, onAlarm } from "@api/chrome/alarm"
import { getRuntimeId } from "@api/chrome/runtime"
type _AlarmConfig = {
handler: _Handler,
interval?: number,
- when?: () => number,
+ when?: () => number | null,
}
type _Handler = (alarm: ChromeAlarm) => void
@@ -15,7 +15,7 @@ const ALARM_PREFIX_LENGTH = ALARM_PREFIX.length
const getInnerName = (outerName: string) => ALARM_PREFIX + outerName
const getOuterName = (innerName: string) => innerName.substring(ALARM_PREFIX_LENGTH)
-const calcNextTs = (config: _AlarmConfig): number => {
+const calcNextTs = (config: _AlarmConfig): number | null => {
const { interval, when } = config
if (interval) return Date.now() + interval
if (when) return when()
@@ -42,21 +42,18 @@ class AlarmManager {
return
}
const innerName = getOuterName(name)
- const config: _AlarmConfig = this.alarms[innerName]
- if (!config) {
- // Not registered, or removed
- return
- }
+ const config = this.alarms[innerName]
+ if (!config) return
// Handle alarm event
try {
- config.handler?.(alarm)
+ config.handler(alarm)
} catch (e) {
console.info("Failed to handle alarm event", e)
} finally {
- const nextTs = calcNextTs(config)
// Clear this one
await clearAlarm(name)
- createAlarm(name, nextTs)
+ const nextTs = calcNextTs(config)
+ nextTs && await createAlarm(name, nextTs)
}
})
}
@@ -66,7 +63,7 @@ class AlarmManager {
*
* @param interval mills
*/
- setInterval(outerName: string, interval: number, handler: _Handler): void {
+ async setInterval(outerName: string, interval: number, handler: _Handler): Promise {
if (!interval || !handler) {
return
}
@@ -79,13 +76,13 @@ class AlarmManager {
// Initialize config
this.alarms[outerName] = config
// Create new one alarm
- createAlarm(getInnerName(outerName), Date.now() + interval)
+ await createAlarm(getInnerName(outerName), Date.now() + interval)
}
/**
* Set a alarm to do sth if the time arrives
*/
- setWhen(outerName: string, when: () => number, handler: _Handler): void {
+ async setWhen(outerName: string, when: () => number | null, handler: _Handler): Promise {
if (!when || !handler) {
return
}
@@ -98,15 +95,28 @@ class AlarmManager {
// Initialize config
this.alarms[outerName] = config
// Create new one alarm
- createAlarm(getInnerName(outerName), when())
+ const next = calcNextTs(config)
+ next && await createAlarm(getInnerName(outerName), next)
}
/**
* Remove a interval
*/
- remove(outerName: string) {
+ async remove(outerName: string): Promise {
delete this.alarms[outerName]
- clearAlarm(getInnerName(outerName))
+ await clearAlarm(getInnerName(outerName))
+ }
+
+ /**
+ * Judge if exist
+ */
+ async getAlarm(outerName: string): Promise {
+ const innerName = getInnerName(outerName)
+ const existed = await getAlarm(innerName)
+ if (!existed && this.alarms[outerName]) {
+ delete this.alarms[outerName]
+ }
+ return existed
}
}
diff --git a/src/background/backup-scheduler.ts b/src/background/backup-scheduler.ts
deleted file mode 100644
index 7dc55a9ed..000000000
--- a/src/background/backup-scheduler.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Copyright (c) 2023 Hengyang Zhang
- *
- * This software is released under the MIT License.
- * https://opensource.org/licenses/MIT
- */
-
-import processor from "@service/backup/processor"
-import optionHolder from "@service/components/option-holder"
-import { MILL_PER_MINUTE } from "@util/time"
-import alarmManager from "./alarm-manager"
-
-const ALARM_NAME = 'auto-backup-data'
-
-class BackupScheduler {
- needBackup = false
- /**
- * Interval of milliseconds
- */
- interval: number = 0
-
- init() {
- optionHolder.get().then(opt => this.handleOption(opt))
- optionHolder.addChangeListener(opt => this.handleOption(opt))
- }
-
- private handleOption(option: timer.option.BackupOption) {
- const { autoBackUp, backupType, autoBackUpInterval = 0 } = option || {}
- this.needBackup = backupType !== "none" && !!backupType && !!autoBackUp
- this.interval = autoBackUpInterval * MILL_PER_MINUTE
- if (this.needSchedule()) {
- alarmManager.setInterval(ALARM_NAME, this.interval, () => this.doBackup())
- } else {
- alarmManager.remove(ALARM_NAME)
- }
- }
-
- private needSchedule(): boolean {
- return !!this.needBackup && !!this.interval
- }
-
- private async doBackup(): Promise {
- const result = await processor.syncData()
- if (!result.success) {
- console.warn(`Failed to backup ts=${Date.now()}, msg=${result.errorMsg}`)
- }
- }
-}
-
-export default BackupScheduler
\ No newline at end of file
diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts
index 8175027c1..84618f279 100644
--- a/src/background/badge-manager.ts
+++ b/src/background/badge-manager.ts
@@ -6,17 +6,17 @@
*/
import { setBadgeBgColor, setBadgeText } from "@api/chrome/action"
-import { listTabs } from "@api/chrome/tab"
-import { getFocusedNormalWindowId } from "@api/chrome/window"
-import statDatabase from "@db/stat-database"
-import optionHolder from "@service/components/option-holder"
-import whitelistHolder from "@service/whitelist/holder"
+import { listTabs, onTabUpdated } from "@api/chrome/tab"
+import { getLastFocusedId, isNoneWindowId, onWindowFocusChanged } from "@api/chrome/window"
import { IS_ANDROID } from "@util/constant/environment"
import { extractHostname, isBrowserUrl } from "@util/pattern"
import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time"
-import MessageDispatcher from "./message-dispatcher"
+import statDatabase from "./database/stat-database"
+import type MessageDispatcher from './message-dispatcher'
+import optionHolder from "./service/components/option-holder"
+import whitelistHolder from "./service/whitelist/holder"
-export type BadgeLocation = {
+type BadgeLocation = {
/**
* The tab id of badge text show display with
*/
@@ -25,7 +25,6 @@ export type BadgeLocation = {
* The url of tab
*/
url: string
- focus?: number
}
function mill2Str(milliseconds: number) {
@@ -36,23 +35,16 @@ function mill2Str(milliseconds: number) {
// no more than 1 hour
return `${Math.round(milliseconds / MILL_PER_MINUTE)}m`
} else {
- return `${(milliseconds / MILL_PER_HOUR).toFixed(1)}h`
+ const hours = milliseconds / MILL_PER_HOUR
+ return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h`
}
}
-function setBadgeTextOfMills(milliseconds: number | undefined, tabId: number | undefined) {
- const text = milliseconds === undefined ? '' : mill2Str(milliseconds)
- setBadgeText(text, tabId)
-}
-
-async function findActiveTab(): Promise {
- const windowId = await getFocusedNormalWindowId()
- if (!windowId) {
- return undefined
- }
- const tabs = await listTabs({ active: true, windowId })
- // Fix #131
- // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG
+async function findActiveTab(windowId?: number): Promise {
+ windowId ??= await getLastFocusedId()
+ if (isNoneWindowId(windowId)) return undefined
+ const tabs = await listTabs({ windowId, active: true })
+ // Fix #131 — Edge can return two active tabs (e.g. edge://newtab/).
for (const { id: tabId, url } of tabs) {
if (!tabId || !url || isBrowserUrl(url)) continue
return { tabId, url }
@@ -63,106 +55,89 @@ async function findActiveTab(): Promise {
async function clearAllBadge(): Promise {
const tabs = await listTabs()
if (!tabs?.length) return
- for (const tab of tabs) {
- await setBadgeText('', tab?.id)
- }
-}
-
-type BadgeState = 'HIDDEN' | 'NOT_SUPPORTED' | 'PAUSED' | 'TIME' | 'WHITELIST'
-
-interface BadgeManager {
- init(dispatcher: MessageDispatcher): void
- updateFocus(location?: BadgeLocation): void
+ for (const { id } of tabs) id != null && await setBadgeText('', id)
}
-class DefaultBadgeManager {
- pausedTabId: number | undefined
- current: BadgeLocation | undefined
- visible: boolean | undefined
- state: BadgeState | undefined
+class BadgeManager {
+ #pausedTabId: number | undefined
+ #current: BadgeLocation | undefined
+ #visible = false
+ #countLocalFiles = false
async init(messageDispatcher: MessageDispatcher) {
+ if (IS_ANDROID) return // do nothing on Android, since badge text is not supported
+
const option = await optionHolder.get()
- this.processOption(option)
+ await this.processOption(option)
optionHolder.addChangeListener(opt => this.processOption(opt))
whitelistHolder.addPostHandler(() => this.render())
- messageDispatcher
- .register('cs.idleChange', (isIdle, sender) => {
- const tabId = sender?.tab?.id
- isIdle ? this.pause(tabId) : this.resume(tabId)
- })
- this.updateFocus()
+ messageDispatcher.register('cs.idleChanged', (isIdle, sender) => {
+ const tabId = sender?.tab?.id
+ void (isIdle ? this.pause(tabId) : this.resume(tabId))
+ })
+ onWindowFocusChanged(async windowId => {
+ this.#current = await findActiveTab(windowId)
+ await this.render()
+ })
+ onTabUpdated(async (tabId, { url }, { active }) => {
+ if (!active || !url) return
+ this.#current = { tabId, url }
+ await this.render()
+ })
+ await this.updateFocus()
}
- /**
- * Hide the badge text
- */
private async pause(tabId?: number) {
- this.pausedTabId = tabId
- this.render()
+ if (typeof tabId !== 'number') return
+ this.#pausedTabId = tabId
+ await this.render()
}
- /**
- * Show the badge text
- */
- private resume(tabId?: number) {
- if (!this.pausedTabId || this.pausedTabId !== tabId) return
- this.pausedTabId = undefined
- this.render()
+ private async resume(tabId?: number) {
+ if (typeof this.#pausedTabId !== 'number') return
+ if (typeof tabId !== 'number' || this.#pausedTabId !== tabId) return
+ this.#pausedTabId = undefined
+ await this.render()
}
async updateFocus(target?: BadgeLocation) {
- this.current = target || await findActiveTab()
+ this.#current = target ?? await findActiveTab()
await this.render()
}
- private processOption(option: timer.option.AppearanceOption) {
- const { displayBadgeText, badgeBgColor } = option || {}
- const before = this.visible
- this.visible = !!displayBadgeText
- !this.visible && before && clearAllBadge()
- setBadgeBgColor(badgeBgColor)
- }
+ private async processOption(option: tt4b.option.DefaultOption) {
+ const { displayBadgeText, badgeBgColor, countLocalFiles } = option
- private async render(): Promise {
- this.state = await this.processState()
- }
+ const changed = this.#visible !== displayBadgeText || this.#countLocalFiles !== countLocalFiles
+ this.#countLocalFiles = countLocalFiles
+ this.#visible = displayBadgeText
- private async processState(): Promise {
- const { url, tabId, focus } = this.current || {}
- if (!this.visible || !url) {
- this.state !== 'HIDDEN' && setBadgeText('', tabId)
- return 'HIDDEN'
- }
- if (isBrowserUrl(url)) {
- this.state !== 'NOT_SUPPORTED' && setBadgeText('∅', tabId)
- return 'NOT_SUPPORTED'
+ if (!this.#visible) {
+ await clearAllBadge()
+ } else {
+ await setBadgeBgColor(badgeBgColor)
+ if (changed) await this.render()
}
- const host = extractHostname(url)?.host
- if (whitelistHolder.contains(host, url)) {
- this.state !== 'WHITELIST' && setBadgeText('W', tabId)
- return 'WHITELIST'
- }
- if (this.pausedTabId === tabId) {
- this.state !== 'PAUSED' && setBadgeText('P', tabId)
- return 'PAUSED'
- }
- const milliseconds = focus || (host ? (await statDatabase.get(host, new Date())).focus : undefined)
- setBadgeTextOfMills(milliseconds, tabId)
- return 'TIME'
}
-}
-class SilentBadgeManager implements BadgeManager {
- init(_dispatcher: MessageDispatcher): void {
- // do nothing
+ private async render(): Promise {
+ const badgeText = await this.resolveBadgeText()
+ await setBadgeText(badgeText, this.#current?.tabId)
}
- updateFocus(_location?: BadgeLocation): void {
- // do nothing
+
+ private async resolveBadgeText(): Promise {
+ if (!this.#current || !this.#visible) return ''
+ const { url, tabId } = this.#current
+ if (isBrowserUrl(url)) return '∅'
+ const { host, protocol } = extractHostname(url)
+ if (protocol === 'file' && !this.#countLocalFiles) return '∅'
+ if (whitelistHolder.contains(host, url)) return 'W'
+ if (this.#pausedTabId === tabId) return 'P'
+ const { focus } = await statDatabase.get(host, new Date())
+ return mill2Str(focus)
}
}
-// Don't display badge on Android
-const badgeManager: BadgeManager = IS_ANDROID ? new SilentBadgeManager() : new DefaultBadgeManager()
+const badgeTextManager = new BadgeManager()
-export default badgeManager
+export default badgeTextManager
diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts
index 43551fc68..3ad13fe4c 100644
--- a/src/background/content-script-handler.ts
+++ b/src/background/content-script-handler.ts
@@ -5,46 +5,60 @@
* https://opensource.org/licenses/MIT
*/
-import { executeScript } from "@api/chrome/script"
-import { createTab } from "@api/chrome/tab"
-import { ANALYSIS_ROUTE, LIMIT_ROUTE } from "@app/router/constants"
-import optionHolder from "@service/components/option-holder"
-import limitService from "@service/limit-service"
-import { getSite } from "@service/site-service"
-import timelineThrottler from '@service/throttler/timeline-throttler'
-import whitelistHolder from "@service/whitelist/holder"
-import { getAppPageUrl } from "@util/constant/url"
-import { extractFileHost, extractHostname } from "@util/pattern"
+import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment"
+import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern"
+import { extractSiteName } from "@util/site"
import badgeManager from "./badge-manager"
-import { collectIconAndAlias } from "./icon-and-alias-collector"
import MessageDispatcher from "./message-dispatcher"
+import { saveAlias, saveIconUrl } from "./service/site-service"
+import { incVisitCount } from './track-server/normal'
-const handleOpenAnalysisPage = (sender: ChromeMessageSender) => {
- const { tab, url } = sender || {}
- if (!url) return
- const host = extractFileHost(url) || extractHostname(url)?.host
- const newTabUrl = getAppPageUrl(ANALYSIS_ROUTE, { host })
+function isUrl(title: string) {
+ return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://')
+}
- const tabIndex = tab?.index
- const newTabIndex = tabIndex ? tabIndex + 1 : undefined
- createTab({ url: newTabUrl, index: newTabIndex })
+async function collectAlias(key: tt4b.site.SiteKey, tabTitle: string) {
+ if (!tabTitle) return
+ if (isUrl(tabTitle)) return
+ const siteName = extractSiteName(tabTitle, key.host)
+ siteName && await saveAlias(key, siteName, true)
}
-const handleOpenLimitPage = (sender: ChromeMessageSender) => {
- const { tab, url } = sender || {}
- if (!url) return
- const newTabUrl = getAppPageUrl(LIMIT_ROUTE, { url })
- const tabIndex = tab?.index
- const newTabIndex = tabIndex ? tabIndex + 1 : undefined
- createTab({ url: newTabUrl, index: newTabIndex })
+/**
+ * Process the tab
+ */
+async function processTabInfo(tab: ChromeTab): Promise {
+ let { favIconUrl, url, title } = tab
+ if (!url || !title) return
+ if (isBrowserUrl(url)) return
+ const hostInfo = extractHostname(url)
+ const host = hostInfo.host
+ if (!host) return
+ // localhost hosts with Chrome use cache, so keep the favIcon url undefined
+ IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined)
+ const siteKey: tt4b.site.SiteKey = { host, type: 'normal' }
+ favIconUrl && await saveIconUrl(siteKey, favIconUrl)
+ !IS_ANDROID
+ && !isBrowserUrl(url)
+ && isHomepage(url)
+ && await collectAlias(siteKey, title)
+}
+
+/**
+ * Collect the favicon of host
+ */
+const collectIconAndAlias = async (tab: ChromeTab) => {
+ if (IS_SAFARI || IS_ANDROID) return
+ processTabInfo(tab)
}
const handleInjected = async (sender: ChromeMessageSender) => {
- const tabId = sender?.tab?.id
- if (!tabId) return
- collectIconAndAlias(tabId)
- badgeManager.updateFocus()
- executeScript(tabId, ['content_scripts.js'])
+ const { tab, url } = sender
+ if (!tab) return
+ await incVisitCount(tab)
+ await collectIconAndAlias(tab)
+ const tabId = tab.id
+ await badgeManager.updateFocus(tabId && url ? { tabId, url } : undefined)
}
/**
@@ -54,26 +68,7 @@ const handleInjected = async (sender: ChromeMessageSender) => {
*/
export default function init(dispatcher: MessageDispatcher) {
dispatcher
- // Judge is in whitelist
- .register<{ host?: string, url?: string }, boolean>('cs.isInWhitelist', ({ host, url } = {}) => !!host && !!url && whitelistHolder.contains(host, url))
- // Need to print the information of today
- .register('cs.printTodayInfo', async () => {
- const option = await optionHolder.get()
- return !!option.printInConsole
- })
- .register('cs.getLimitedRules', url => limitService.getLimited(url))
- .register('cs.getRelatedRules', url => limitService.getRelated(url))
- .register('cs.openAnalysis', (_, sender) => handleOpenAnalysisPage(sender))
- .register('cs.openLimit', (_, sender) => handleOpenLimitPage(sender))
- .register('cs.onInjected', async (_, sender) => handleInjected(sender))
+ .register('cs.injected', (_, sender) => handleInjected(sender))
// Get sites which need to count run time
- .register('cs.getRunSites', async url => {
- const { host } = extractHostname(url) || {}
- if (!host) return null
- const site: timer.site.SiteKey = { host, type: 'normal' }
- const exist = await getSite(site)
- return exist?.run ? site : null
- })
- .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible)
- .register('cs.timelineEv', ev => timelineThrottler.saveEvent(ev))
+ .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible)
}
\ No newline at end of file
diff --git a/src/background/data-cleaner.ts b/src/background/data-cleaner.ts
index 32d6dbf01..18e4f49e7 100644
--- a/src/background/data-cleaner.ts
+++ b/src/background/data-cleaner.ts
@@ -1,22 +1,21 @@
-import periodService, { type PeriodQueryParam } from "@service/period-service"
import { keyOf } from "@util/period"
import { getBirthday, getStartOfDay, MILL_PER_DAY } from "@util/time"
import alarmManager from "./alarm-manager"
+import { batchDeletePeriods } from "./service/period-service"
const PERIOD_ALARM_NAME = 'period-cleaner-alarm'
const START_DAY = keyOf(getBirthday())
const KEEP_RANGE_DAYS = 366
const cleanPeriodData = async () => {
- const endDate = new Date().getTime() - MILL_PER_DAY * KEEP_RANGE_DAYS
- const param: PeriodQueryParam = { periodRange: [START_DAY, keyOf(endDate)] }
- await periodService.batchDeleteBetween(param)
+ const endDate = Date.now() - MILL_PER_DAY * KEEP_RANGE_DAYS
+ await batchDeletePeriods(START_DAY, keyOf(endDate))
}
export default function initDataCleaner() {
alarmManager.setWhen(
PERIOD_ALARM_NAME,
- () => getStartOfDay(new Date()).getTime() + MILL_PER_DAY,
+ () => getStartOfDay(new Date()) + MILL_PER_DAY,
cleanPeriodData,
)
}
\ No newline at end of file
diff --git a/src/background/database/backup-database.ts b/src/background/database/backup-database.ts
new file mode 100644
index 000000000..c0129cd5e
--- /dev/null
+++ b/src/background/database/backup-database.ts
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2022 Hengyang Zhang
+ *
+ * This software is released under the MIT License.
+ * https://opensource.org/licenses/MIT
+ */
+
+import BaseDatabase from "./common/base-database"
+import { REMAIN_WORD_PREFIX } from "./common/constant"
+
+const PREFIX = REMAIN_WORD_PREFIX + "backup"
+const CACHE_KEY = PREFIX + "_cache"
+
+function cacheKeyOf(type: tt4b.backup.Type) {
+ return CACHE_KEY + "_" + type
+}
+
+class BackupDatabase extends BaseDatabase {
+
+ async getCache(type: tt4b.backup.Type): Promise {
+ return (await this.storage.getOne(cacheKeyOf(type))) || {}
+ }
+
+ async updateCache(type: tt4b.backup.Type, newVal: unknown): Promise {
+ return this.storage.put(cacheKeyOf(type), newVal as Object)
+ }
+}
+
+const backupDatabase = new BackupDatabase()
+
+export default backupDatabase
\ No newline at end of file
diff --git a/src/database/site-cate-database.ts b/src/background/database/cate-database.ts
similarity index 63%
rename from src/database/site-cate-database.ts
rename to src/background/database/cate-database.ts
index feb063b00..9c0b69336 100644
--- a/src/database/site-cate-database.ts
+++ b/src/background/database/cate-database.ts
@@ -19,26 +19,13 @@ type Item = {
type Items = Record
-function migrate(exist: Items, toMigrate: any) {
- let idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1
- const existLabels = new Set(Object.values(exist).map(e => e.n))
-
- Object.values(toMigrate).forEach(value => {
- const { n } = (value as Item) || {}
- if (!n || existLabels.has(n)) return
-
- const id = idBase
- idBase++
- exist[id] = { n }
- })
-}
-
/**
- * Site tag
+ * Category
*
* @since 3.0.0
*/
-class SiteCateDatabase extends BaseDatabase {
+class CateDatabase extends BaseDatabase {
+
private async getItems(): Promise {
return await this.storage.getOne(KEY) || {}
}
@@ -47,17 +34,17 @@ class SiteCateDatabase extends BaseDatabase {
await this.storage.put(KEY, items || {})
}
- async listAll(): Promise {
+ async listAll(): Promise {
const items = await this.getItems()
return Object.entries(items).map(([id, { n = '' } = {}]) => {
return {
id: parseInt(id),
name: n,
- } satisfies timer.site.Cate
+ } satisfies tt4b.site.Cate
})
}
- async add(name: string): Promise {
+ async add(name: string): Promise {
const items = await this.getItems()
const existId = Object.entries(items).find(([_, v]) => v.n === name)?.[0]
if (existId) {
@@ -66,7 +53,7 @@ class SiteCateDatabase extends BaseDatabase {
}
const id = (Object.keys(items || {}).map(k => parseInt(k)).sort().reverse()?.[0] ?? 0) + 1
- items[id] = { n: name || items[id]?.n }
+ items[id] = { n: name ?? items[id]?.n }
await this.saveItems(items)
return { name, id }
@@ -86,15 +73,6 @@ class SiteCateDatabase extends BaseDatabase {
await this.saveItems(items)
}
- async importData(data: any): Promise {
- let toImport = data[KEY] as Items
- // Not import
- if (typeof toImport !== 'object') return
- const exists: Items = await this.getItems()
- migrate(exists, toImport)
- this.setByKey(KEY, exists)
- }
-
async delete(id: number): Promise {
const items = await this.getItems()
@@ -104,6 +82,6 @@ class SiteCateDatabase extends BaseDatabase {
}
}
-const siteCateDatabase = new SiteCateDatabase()
+const cateDatabase = new CateDatabase()
-export default siteCateDatabase
\ No newline at end of file
+export default cateDatabase
\ No newline at end of file
diff --git a/src/database/common/base-database.ts b/src/background/database/common/base-database.ts
similarity index 80%
rename from src/database/common/base-database.ts
rename to src/background/database/common/base-database.ts
index 0137adc43..2c00a4e19 100644
--- a/src/database/common/base-database.ts
+++ b/src/background/database/common/base-database.ts
@@ -24,12 +24,4 @@ export default abstract class BaseDatabase {
protected setByKey(key: string, val: any): Promise {
return this.storage.put(key, val)
}
-
- /**
- * Import data
- *
- * @since 0.2.5
- * @param data backup data
- */
- abstract importData(data: any): Promise
}
\ No newline at end of file
diff --git a/src/database/common/constant.ts b/src/background/database/common/constant.ts
similarity index 100%
rename from src/database/common/constant.ts
rename to src/background/database/common/constant.ts
diff --git a/src/background/database/common/indexed-storage.ts b/src/background/database/common/indexed-storage.ts
new file mode 100644
index 000000000..531e0ea76
--- /dev/null
+++ b/src/background/database/common/indexed-storage.ts
@@ -0,0 +1,327 @@
+const ALL_TABLES = ['stat', 'timeline'] as const
+
+export type Table = typeof ALL_TABLES[number]
+
+export type Key> = keyof T & string
+
+type IndexConfig> = {
+ key: Key | Key[]
+ unique?: boolean
+}
+
+export type Index> = Key | Key[] | IndexConfig
+
+function normalizeIndex>(index: Index): IndexConfig {
+ return typeof index === 'string' || Array.isArray(index) ? { key: index } : index
+}
+
+function formatIdxName>(key: IndexConfig['key']): string {
+ const keyStr = Array.isArray(key) ? [...key].sort().join('_') : key
+ return `idx_${keyStr}`
+}
+
+export function req2Promise(req: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result)
+ req.onerror = (ev) => {
+ console.error("Failed to request indexed-db", ev, req.error)
+ reject(req.error)
+ }
+ })
+}
+
+export async function iterateCursor(
+ req: IDBRequest
+): Promise
+export async function iterateCursor(
+ req: IDBRequest,
+ processor: (cursor: IDBCursorWithValue) => void | Promise
+): Promise
+
+export async function iterateCursor(
+ req: IDBRequest,
+ processor?: (cursor: IDBCursorWithValue) => void | Promise
+): Promise {
+ const collectResults = !processor
+ const results: T[] = []
+
+ return new Promise((resolve, reject) => {
+ req.onsuccess = async () => {
+ const cursor = req.result
+ if (!cursor) return resolve(collectResults ? results : undefined)
+
+ try {
+ collectResults && results.push(cursor.value as T)
+ await processor?.(cursor)
+ cursor.continue()
+ } catch (error) {
+ reject(error)
+ }
+ }
+
+ req.onerror = () => reject(req.error)
+ })
+}
+
+type TransactionError = 'Connection' | 'StoreNotFound' | 'DataError' | 'Unknown'
+
+const detectTransactionError = (err: unknown): TransactionError => {
+ if (!(err instanceof DOMException)) {
+ console.warn("Non-DOMException error during transaction", err)
+ return 'Unknown'
+ }
+ const { name } = err
+ switch (name) {
+ case 'InvalidStateError':
+ case 'AbortError': return 'Connection'
+ case 'NotFoundError': return 'StoreNotFound'
+ case 'DataError': return 'DataError'
+ default:
+ console.warn(`Unknown dom exception: name=${name}`)
+ return 'Unknown'
+ }
+}
+
+export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined {
+ if (lower !== undefined && upper !== undefined) {
+ if (lower > upper) {
+ [lower, upper] = [upper, lower]
+ }
+ return IDBKeyRange.bound(lower, upper, false, false)
+ } else if (lower !== undefined) {
+ return IDBKeyRange.lowerBound(lower, false)
+ } else if (upper !== undefined) {
+ return IDBKeyRange.upperBound(upper, false)
+ } else {
+ return undefined
+ }
+}
+
+export type IndexResult = {
+ cursorReq: IDBRequest
+ coverage?: FilterCoverage
+}
+
+export abstract class BaseIDBStorage> {
+ private DB_NAME = `tt4b_${chrome.runtime.id}` as const
+
+ private db: IDBDatabase | undefined
+ private static initPromises = new Map>()
+
+ abstract indexes: Index[]
+ abstract key: Key | Key[]
+ abstract table: Table
+
+ protected async initDb(): Promise {
+ if (this.db) return this.db
+
+ let initPromise = BaseIDBStorage.initPromises.get(this.table)
+ if (!initPromise) {
+ initPromise = this.doInitDb()
+ BaseIDBStorage.initPromises.set(this.table, initPromise)
+ }
+
+ try {
+ this.db = await initPromise
+ this.setupDbCloseHandler(this.db)
+ return this.db
+ } catch (error) {
+ BaseIDBStorage.initPromises.delete(this.table)
+ throw error
+ }
+ }
+
+ private setupDbCloseHandler(db: IDBDatabase): void {
+ db.onversionchange = () => db.close()
+
+ db.onclose = () => {
+ if (this.db !== db) return
+
+ this.db = undefined
+ BaseIDBStorage.initPromises.delete(this.table)
+ }
+ }
+
+ private async doInitDb(): Promise {
+ const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB
+
+ const checkDb = await new Promise((resolve, reject) => {
+ const checkRequest = factory.open(this.DB_NAME)
+ checkRequest.onsuccess = () => resolve(checkRequest.result)
+ checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database"))
+ })
+
+ return checkDb
+ }
+
+ // Only used for testing, be careful when using in production
+ public async clear(): Promise {
+ await this.withStore(store => store.clear(), 'readwrite')
+ }
+
+ async upgrade(): Promise {
+ const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB
+
+ const checkDb = await new Promise((resolve, reject) => {
+ const checkRequest = factory.open(this.DB_NAME)
+ checkRequest.onsuccess = () => resolve(checkRequest.result)
+ checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database"))
+ checkRequest.onblocked = () => {
+ console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`)
+ }
+ })
+
+ const storeExisted = checkDb.objectStoreNames.contains(this.table)
+ const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb)
+
+ if (!needUpgrade) {
+ checkDb.close()
+ return
+ }
+
+ const currentVersion = checkDb.version
+ checkDb.close()
+
+ return new Promise((resolve, reject) => {
+ const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1)
+
+ upgradeRequest.onupgradeneeded = () => {
+ try {
+ const upgradeDb = upgradeRequest.result
+ const transaction = upgradeRequest.transaction
+ if (!transaction) {
+ reject(new Error("Failed to get transaction of upgrading request"))
+ return
+ }
+
+ transaction.onerror = () => {
+ reject(transaction.error || new Error("Transaction failed"))
+ }
+
+ transaction.onabort = () => {
+ reject(new Error("Upgrade transaction was aborted"))
+ }
+
+ let store = upgradeDb.objectStoreNames.contains(this.table)
+ ? transaction.objectStore(this.table)
+ : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] })
+ this.createIndexes(store)
+ } catch (error) {
+ console.error("Failed to upgrade database in onupgradeneeded", error)
+ upgradeRequest.transaction?.abort()
+ reject(error instanceof Error ? error : new Error(String(error)))
+ }
+ }
+
+ upgradeRequest.onsuccess = () => {
+ console.log(`IndexedDB upgraded for table "${this.table}"`)
+ upgradeRequest.result.close()
+ resolve()
+ }
+
+ upgradeRequest.onerror = (event) => {
+ console.error("Failed to upgrade database", event, upgradeRequest.error)
+ reject(upgradeRequest.error || new Error("Failed to upgrade database"))
+ }
+
+ upgradeRequest.onblocked = () => {
+ const blockingTables = Array.from(BaseIDBStorage.initPromises.keys())
+ .filter(table => table !== this.table)
+ console.warn(
+ `Database upgrade blocked for table "${this.table}" (DB: ${this.DB_NAME}), ` +
+ `waiting for other connections to close. ` +
+ `Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}`
+ )
+ }
+ })
+ }
+
+ private needUpgradeIndexes(db: IDBDatabase): boolean {
+ try {
+ const transaction = db.transaction(this.table, 'readonly')
+ const store = transaction.objectStore(this.table)
+ const indexNames = store.indexNames
+
+ for (const index of this.indexes) {
+ const { key } = normalizeIndex(index)
+ const idxName = formatIdxName(key)
+ if (!indexNames.contains(idxName)) {
+ return true
+ }
+ }
+ return false
+ } catch (e) {
+ console.error("Failed to check indexes", e)
+ return true
+ }
+ }
+
+ private createIndexes(store: IDBObjectStore) {
+ const existingIndexes = store.indexNames
+
+ for (const index of this.indexes) {
+ const { key, unique } = normalizeIndex(index)
+ const idxName = formatIdxName(key)
+ if (!existingIndexes.contains(idxName)) {
+ store.createIndex(idxName, key, { unique })
+ }
+ }
+ }
+
+ protected async withStore(operation: (store: IDBObjectStore) => Awaitable, mode?: IDBTransactionMode): Promise {
+ let db = await this.initDb()
+
+ for (let retryCount = 0; retryCount < 2; retryCount++) {
+ let trans: IDBTransaction | undefined
+ try {
+ trans = db.transaction(this.table, mode ?? 'readwrite')
+ const store = trans.objectStore(this.table)
+ const result = await operation(store)
+ const transaction = trans
+ await new Promise((resolve, reject) => {
+ transaction.oncomplete = () => resolve()
+ transaction.onerror = () => reject(transaction.error)
+ transaction.onabort = () => reject(new Error('Transaction aborted'))
+ })
+ return result
+ } catch (e) {
+ const errorType = detectTransactionError(e)
+
+ if (errorType === 'Unknown') {
+ console.error("Failed to process with transaction", e)
+ if (trans && !trans.error && trans.mode !== 'readonly') {
+ try {
+ trans.abort()
+ } catch (ignored) { }
+ }
+ throw e
+ }
+
+ if (errorType === 'StoreNotFound') {
+ this.db?.close()
+ await this.upgrade()
+ }
+
+ this.db = undefined
+ BaseIDBStorage.initPromises.delete(this.table)
+ db = await this.initDb()
+ }
+ }
+ throw new Error("Max retries exceeded")
+ }
+
+ protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex {
+ const idxName = formatIdxName(key)
+ try {
+ return store.index(idxName)
+ } catch (err) {
+ console.error(`Failed to query index: table=${this.table}`, err)
+ throw err
+ }
+ }
+
+ protected assertIndexCursor(store: IDBObjectStore, key: Key | Key[], range: IDBKeyRange): IDBRequest {
+ const index = this.assertIndex(store, key)
+ return index.openCursor(range)
+ }
+}
diff --git a/src/background/database/common/migratable.ts b/src/background/database/common/migratable.ts
new file mode 100644
index 000000000..323b9be49
--- /dev/null
+++ b/src/background/database/common/migratable.ts
@@ -0,0 +1,29 @@
+import { isRecord } from '@util/guard'
+import { createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard'
+import type { BrowserMigratableNamespace } from '../types'
+
+export const isExportData = createObjectGuard>({
+ __meta__: createObjectGuard({
+ version: isString,
+ ts: isInt,
+ }),
+})
+
+export const isLegacyVersion = (data: unknown): data is tt4b.backup.ExportData => {
+ if (!isExportData(data)) return false
+
+ const version = data.__meta__.version
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)/)
+ const majorStr = match?.[1]
+ if (!majorStr) return true
+ const major = parseInt(majorStr)
+
+ return major < 4
+}
+
+export const extractNamespace = (data: unknown, namespace: BrowserMigratableNamespace, guard: TypeGuard): T | undefined => {
+ if (!isRecord(data)) return undefined
+ if (!(namespace in data)) return undefined
+ const nsData = data[namespace]
+ return guard(nsData) ? nsData : undefined
+}
\ No newline at end of file
diff --git a/src/background/database/common/storage-holder.ts b/src/background/database/common/storage-holder.ts
new file mode 100644
index 000000000..fdc2194c1
--- /dev/null
+++ b/src/background/database/common/storage-holder.ts
@@ -0,0 +1,22 @@
+import optionHolder from '@service/components/option-holder'
+
+export class StorageHolder {
+ current: Database
+ delegates: Record
+
+ constructor(delegates: Record) {
+ this.delegates = delegates
+ this.current = delegates.classic
+ optionHolder.get().then(val => this.handleOption(val))
+ optionHolder.addChangeListener(val => this.handleOption(val))
+ }
+
+ private handleOption(option: tt4b.option.TrackingOption) {
+ const delegate = this.delegates[option.storage]
+ delegate && (this.current = delegate)
+ }
+
+ get(type: tt4b.option.StorageType): Database | null {
+ return this.delegates[type] ?? null
+ }
+}
\ No newline at end of file
diff --git a/src/database/common/storage-promise.ts b/src/background/database/common/storage-promise.ts
similarity index 96%
rename from src/database/common/storage-promise.ts
rename to src/background/database/common/storage-promise.ts
index 91c8d9c5a..14c47dc97 100644
--- a/src/database/common/storage-promise.ts
+++ b/src/background/database/common/storage-promise.ts
@@ -44,7 +44,7 @@ export default class StoragePromise {
/**
* @since 0.5.0
*/
- put(key: string, val: Object): Promise {
+ put(key: string, val: unknown): Promise {
return this.set({ [key]: val })
}
diff --git a/src/database/limit-database.ts b/src/background/database/limit-database.ts
similarity index 58%
rename from src/database/limit-database.ts
rename to src/background/database/limit-database.ts
index f8327bd3e..b2a3911d8 100644
--- a/src/database/limit-database.ts
+++ b/src/background/database/limit-database.ts
@@ -5,9 +5,13 @@
* https://opensource.org/licenses/MIT
*/
+import { isOptionalInt, isRecord, isVector2 } from '@util/guard'
import { formatTimeYMD, MILL_PER_DAY } from "@util/time"
+import { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isBoolean, isInt, isString } from 'typescript-guard'
import BaseDatabase from "./common/base-database"
import { REMAIN_WORD_PREFIX } from "./common/constant"
+import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable'
+import type { BrowserMigratable } from './types'
const KEY = REMAIN_WORD_PREFIX + 'LIMIT'
@@ -19,10 +23,30 @@ type DateRecords = {
}
}
-type LimitRecord = timer.limit.Rule & {
+export type LimitRecord = tt4b.limit.Rule & {
records: DateRecords
}
+type PartialRule = MakeRequired, 'name' | 'cond'>
+
+const isValidRow = createObjectGuard({
+ id: isOptionalInt,
+ name: isString,
+ cond: createArrayGuard(isString),
+ time: isOptionalInt,
+ count: isOptionalInt,
+ weekly: isOptionalInt,
+ weeklyCount: isOptionalInt,
+ visitTime: isOptionalInt,
+ enabled: createOptionalGuard(isBoolean),
+ locked: createOptionalGuard(isBoolean),
+ weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))),
+ allowDelay: createOptionalGuard(isBoolean),
+ periods: createOptionalGuard(createArrayGuard(isVector2)),
+})
+
+const isValidImportRows = createArrayGuard(isValidRow)
+
type ItemValue = {
/**
* ID
@@ -121,7 +145,8 @@ const cvtItem2Rec = (item: ItemValue): LimitRecord => {
type Items = Record
-function migrate(exist: Items, toMigrate: any) {
+function migrate(exist: Items, toMigrate: unknown) {
+ if (!isRecord(toMigrate)) return
const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1
Object.values(toMigrate).forEach((value, idx) => {
const id = idBase + idx
@@ -134,12 +159,24 @@ function migrate(exist: Items, toMigrate: any) {
})
}
+function cvtRule2Item(rule: tt4b.limit.Rule): ItemValue {
+ const { id, name, cond, time, count, weekly, weeklyCount, visitTime, periods, enabled, locked, allowDelay, weekdays } = rule
+ return {
+ i: id, n: name, c: cond, t: time, ct: count, wt: weekly, wct: weeklyCount,
+ v: visitTime, p: periods,
+ e: !!enabled, l: !!locked, ad: !!allowDelay,
+ wd: weekdays,
+ }
+}
+
/**
* Time limit
*
* @since 0.2.2
*/
-class LimitDatabase extends BaseDatabase {
+class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__'> {
+ namespace: '__limit__' = '__limit__'
+
private async getItems(): Promise {
let items = await this.storage.getOne(KEY) || {}
return items
@@ -163,41 +200,35 @@ class LimitDatabase extends BaseDatabase {
return Object.values(items).map(cvtItem2Rec)
}
- async save(data: MakeOptional, rewrite?: boolean): Promise {
+ async batchUpdate(rules: tt4b.limit.Rule[]): Promise {
+ if (!rules.length) return
const items = await this.getItems()
- let {
- id, name, weekdays,
- enabled, locked, allowDelay,
- cond,
- time, count,
- weekly, weeklyCount,
- visitTime, periods,
- } = data
- if (!id) {
- const lastId = Object.values(items)
- .map(e => e.i)
- .filter(i => !!i)
- .sort((a, b) => b - a)?.[0] ?? 0
- id = lastId + 1
- }
- const existItem = items[id]
- if (existItem && !rewrite) return id
- items[id] = {
- // Can be overridden by existing
- ...(existItem || {}),
- i: id, n: name, c: cond, wd: weekdays,
- e: !!enabled, l: locked, ad: !!allowDelay,
- t: time, ct: count,
- wt: weekly, wct: weeklyCount,
- v: visitTime, p: periods,
- }
+ rules.forEach(rule => {
+ const id = rule.id
+ const exist = items[id]
+ if (!exist) return
+ items[id] = { ...exist, ...cvtRule2Item(rule) }
+ })
+ await this.update(items)
+ }
+
+ async add(data: Omit): Promise {
+ const items = await this.getItems()
+
+ const lastId = Object.values(items)
+ .map(e => e.i)
+ .filter(i => !!i)
+ .sort((a, b) => b - a)?.[0] ?? 0
+ const id = lastId + 1
+
+ items[id] = cvtRule2Item({ id, ...data })
await this.update(items)
return id
}
- async remove(id: number): Promise {
+ async batchRemove(ids: number[]): Promise {
const items = await this.getItems()
- delete items[id]
+ ids.forEach(id => delete items[id])
await this.update(items)
}
@@ -226,7 +257,7 @@ class LimitDatabase extends BaseDatabase {
await this.update(items)
}
- async updateDelayCount(date: string, toUpdate: timer.limit.Item[]): Promise {
+ async updateDelayCount(date: string, toUpdate: tt4b.limit.Item[]): Promise {
const items = await this.getItems()
toUpdate?.forEach(({ id, delayCount }) => {
const entry = items[id]
@@ -238,35 +269,46 @@ class LimitDatabase extends BaseDatabase {
await this.update(items)
}
- async updateDelay(id: number, allowDelay: boolean) {
- const items = await this.getItems()
- if (!items[id]) return
- items[id].ad = allowDelay
- await this.update(items)
- }
-
- async updateEnabled(id: number, enabled: boolean) {
- const items = await this.getItems()
- if (!items[id]) return
- items[id].e = !!enabled
- await this.update(items)
- }
+ async importData(data: unknown): Promise {
+ if (!isExportData(data)) return
+ if (isLegacyVersion(data)) {
+ return this.importLegacyData(data)
+ }
- async updateLocked(id: number, locked: boolean) {
- const items = await this.getItems()
- if (!items[id]) return
- items[id].l = !!locked
- await this.update(items)
+ const rows = extractNamespace(data, this.namespace, isValidImportRows) ?? []
+ for (const row of rows) {
+ const toImport: Omit = {
+ name: row.name,
+ cond: row.cond,
+ time: row.time,
+ count: row.count,
+ weekly: row.weekly,
+ weeklyCount: row.weeklyCount,
+ visitTime: row.visitTime,
+ periods: row.periods,
+ enabled: row.enabled ?? true,
+ locked: row.locked ?? false,
+ allowDelay: row.allowDelay ?? false,
+ weekdays: row.weekdays ?? [],
+ }
+ await this.add(toImport)
+ }
}
- async importData(data: any): Promise {
- let toImport = data[KEY] as Items
- // Not import
- if (typeof toImport !== 'object') return
- const exists: Items = await this.getItems()
+ /**
+ * @deprecated Only for legacy data, will be removed in future version
+ */
+ private async importLegacyData(data: unknown): Promise {
+ if (!isRecord(data)) return
+ let toImport = data[KEY]
+ const exists = await this.getItems()
migrate(exists, toImport)
this.setByKey(KEY, exists)
}
+
+ exportData(): Promise {
+ return this.all()
+ }
}
const limitDatabase = new LimitDatabase()
diff --git a/src/database/memory-detector.ts b/src/background/database/memory-detector.ts
similarity index 66%
rename from src/database/memory-detector.ts
rename to src/background/database/memory-detector.ts
index 5579bef5e..e4fc35799 100644
--- a/src/database/memory-detector.ts
+++ b/src/background/database/memory-detector.ts
@@ -7,20 +7,6 @@
import StoragePromise from "./common/storage-promise"
-/**
- * User memory of this extension
- */
-export type MemoryInfo = {
- /**
- * Used bytes
- */
- used: number
- /**
- * Total bytes
- */
- total: number
-}
-
/**
* 'QUOTA_BYTES' Not supported in Firefox
*/
@@ -31,7 +17,7 @@ const total: number = chrome.storage.local.QUOTA_BYTES || 0
*
* @since 0.0.9
*/
-export async function getUsedStorage(): Promise {
+export async function getUsedStorage(): Promise {
const used = await new StoragePromise(chrome.storage.local).getUsedMemory()
return { used, total }
}
\ No newline at end of file
diff --git a/src/background/database/merge-rule-database.ts b/src/background/database/merge-rule-database.ts
new file mode 100644
index 000000000..7fe275851
--- /dev/null
+++ b/src/background/database/merge-rule-database.ts
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2021 Hengyang Zhang
+ *
+ * This software is released under the MIT License.
+ * https://opensource.org/licenses/MIT
+ */
+
+import { isRecord } from '@util/guard'
+import { createArrayGuard, createObjectGuard, createRecordGuard, createUnionGuard, isInt, isString } from 'typescript-guard'
+import BaseDatabase from "./common/base-database"
+import { REMAIN_WORD_PREFIX } from "./common/constant"
+import { extractNamespace, isLegacyVersion } from './common/migratable'
+import type { BrowserMigratable } from './types'
+
+const DB_KEY = REMAIN_WORD_PREFIX + 'MERGE_RULES'
+
+type MergeRuleSet = { [key: string]: string | number }
+
+const isMergeValue = createUnionGuard(isString, isInt)
+
+const isMergeRuleSet = createRecordGuard(isMergeValue)
+
+const isMergeRule = createObjectGuard({
+ origin: isString,
+ merged: isMergeValue,
+})
+
+/**
+ * Rules to merge host
+ *
+ * @since 0.1.2
+ */
+class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__merge__'> {
+ namespace: '__merge__' = '__merge__'
+
+ async refresh(): Promise