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-------.md b/.github/ISSUE_TEMPLATE/feature-request-------.md
deleted file mode 100644
index 5db3c2de7..000000000
--- a/.github/ISSUE_TEMPLATE/feature-request-------.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-name: Feature request / 需求提交
-about: Suggest an idea for this project / 说出你宝贵的需求!
-title: "[FEATURE]"
-labels: ''
-assignees: ''
-
----
-
-
diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml
new file mode 100644
index 000000000..ef5ec5286
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-request.yaml
@@ -0,0 +1,46 @@
+name: Feature Request
+title: "[FEATURE] "
+labels: feature
+description: Suggest an idea for this project
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Tell us your wants~
+ - type: dropdown
+ id: browser
+ validations:
+ required: true
+ attributes:
+ label: Browser
+ multiple: true
+ description: Which browsers are you using? (Chrome/Firefox/Edge/Other)
+ options:
+ - Chrome
+ - Firefox
+ - Edge
+ - Brave
+ - Other
+ default: 0
+ - type: dropdown
+ id: os
+ validations:
+ required: false
+ attributes:
+ label: OS
+ description: Which operating system are you using? (Mac/Linux/Win, Optional)
+ options:
+ - Win
+ - Mac
+ - Linux
+ - Android
+ - Other
+ default: 0
+ - type: textarea
+ id: description
+ validations:
+ required: false
+ attributes:
+ label: Feature Description
+ description: Please describe the feature in detail.
+ placeholder: "1. What you want.\n2. Why you want it.\n3. Any additional context."
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/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml
index 805105ffb..ff42e5b46 100644
--- a/.github/workflows/crowdin-sync.yml
+++ b/.github/workflows/crowdin-sync.yml
@@ -10,7 +10,7 @@ jobs:
- name: Test using Node.js
uses: actions/setup-node@v1
with:
- node-version: "v20.11.0"
+ node-version: "v22"
- name: Install ts-node
run: npm i -g ts-node
- name: Install dependencies
diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml
index 965d682a5..bd5e3ea2a 100644
--- a/.github/workflows/end-to-end-tests.yml
+++ b/.github/workflows/end-to-end-tests.yml
@@ -1,30 +1,22 @@
name: End-to-end tests CI
-on: [pull_request]
+on: [pull_request, workflow_dispatch]
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: "v20.11.0"
+ node-version: "v24"
- name: Install dependencies
run: npm install
- - name: Install http-server
- run: npm install -g http-server pm2
- - name: Build e2e outputs
- run: npm run dev:e2e
- - name: Build production outputs
- run: npm run build
-
- - name: Start up mock server
- run: |
- pm2 start 'http-server ./test-e2e/example -p 12345'
- pm2 start 'http-server ./test-e2e/example -p 12346'
+ - name: Setup e2e environment
+ run: bash script/setup-e2e.sh --all
- name: Run tests
env:
+ DEBUG: "rstest"
USE_HEADLESS_PUPPETEER: true
run: npm run test-e2e
- name: Tests ✅
@@ -44,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 7f08ba52c..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: "v20.11.0"
+ 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-chrome.yml b/.github/workflows/publish-chrome.yml
index a3b8b12ab..68e3e7bb6 100644
--- a/.github/workflows/publish-chrome.yml
+++ b/.github/workflows/publish-chrome.yml
@@ -10,7 +10,7 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
- node-version: "v20.11.0"
+ node-version: "v22"
- name: Install dependencies
run: npm install
- name: Build
diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml
index 240e52d8e..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: "v20.11.0"
+ 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 3bc021f53..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: "v20.11.0"
+ 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/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1cf191d92..4fb4dfe1c 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -8,7 +8,7 @@ jobs:
- name: Test using Node.js
uses: actions/setup-node@v1
with:
- node-version: "v20.11.0"
+ node-version: "v22"
- run: npm install
- run: npm run test-c
- name: Tests ✅
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 07af2530e..e825c2e98 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,145 @@ 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
+
+## [3.7.14] - 2026-01-16
+
+- Supported more languages for the guide page
+
+## [3.7.13] - 2026-01-13
+
+- Added the donation link. Thank you all guys ^^
+
+## [3.7.12] - 2026-01-09
+
+- The first version of new year!
+- Fixed some bugs
+
+## [3.7.11] - 2025-12-30
+
+- Fixed an issue that allowed bypassing time-limited passwords in certain situations
+- Fixed an issue where report tables did not refresh after category changes
+- Removed scrollbars from pop-up charts for improved user experience
+
+## [3.7.10] - 2025-12-23
+
+- Fixed a bug on Firefox
+
+## [3.7.9] - 2025-12-23
+
+- Optimized UI
+- Supported to disable the side panel for Chrome and Edge
+
+## [3.7.8] - 2025-12-13 [For Chrome]
+
+- Same as v3.7.7
+
+## [3.7.7] - 2025-12-13
+
+- Reverted tracking the time in split-screen mode for Chrome, which caused tracking stopping
+- Supported donut chart on the popup pages
+
+## [3.7.6] - 2025-12-10
+
+- Supported tracking the time in split-screen mode for Chrome
+- Added the mask layer for date picker on the side page
+- Fixed bugs that caused errors when importing data from Web Activity Time Tracker
+
+## [3.7.5] - 2025-11-28
+
+- Supported tracking files for Firefox in background script
+- All charts use system fonts
+- Optimized the interaction for modifying restriction rules
+- Fixed some bugs
+
+## [3.7.4] - 2025-11-15
+
+- Improved the time display on popup page
+- Supported exporting and importing data for Android
+
## [3.7.3] - 2025-11-11
- Supported Polish
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 010d6b83e..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)
@@ -85,7 +85,7 @@ npm run build:firefox
npm run build:safari
```
-### 5. Testing Your Extension
+### 5. Debugging
#### Chrome/Edge
@@ -126,23 +126,24 @@ This will generate coverage reports in the `coverage/` directory.
#### Setup E2E Testing
-1. Build the test environment:
+Use the provided setup script to initialize the e2e testing environment:
```shell
-npm run dev:e2e
-npm run build
+# Initialize e2e environment (install dependencies and build outputs)
+bash script/setup-e2e.sh --init --build
+
+# Start test servers
+bash script/setup-e2e.sh --start-servers
```
-2. Start test servers:
+The setup script will:
-```shell
-npm install -g http-server pm2
-
-pm2 start 'http-server ./test-e2e/example -p 12345'
-pm2 start 'http-server ./test-e2e/example -p 12346'
-```
+- Install/upgrade global dependencies (`http-server`, `pm2`)
+- Download browser for Puppeteer (if needed)
+- Build e2e test output (`dist_e2e/`)
+- Start test servers on ports 12345 and 12346
-3. Run E2E tests:
+#### Run E2E Tests
```shell
npm run test-e2e
@@ -157,6 +158,35 @@ export USE_HEADLESS_PUPPETEER=true
npm run test-e2e
```
+#### Stop Test Servers
+
+After testing, stop the servers:
+
+```shell
+pm2 stop all && pm2 delete all
+```
+
+#### Setup Script Options
+
+The `setup-e2e.sh` script supports multiple options:
+
+```shell
+# Show help
+bash script/setup-e2e.sh --help
+
+# Initialize only (install dependencies)
+bash script/setup-e2e.sh --init
+
+# Build e2e output only
+bash script/setup-e2e.sh --build
+
+# Initialize and build
+bash script/setup-e2e.sh --init --build
+
+# Run all steps (init + build + build production)
+bash script/setup-e2e.sh --all
+```
+
## Code Quality
### Code Formatting
@@ -234,7 +264,8 @@ time-tracker-4-browser/
├── types/ # TypeScript declarations
├── rspack/ # Build configuration
├── script/ # Build and utility scripts
-│ └── android-firefox.sh # Android development helper
+│ ├── android-firefox.sh # Android development helper
+│ └── setup-e2e.sh # E2E test environment setup
├── public/ # Static assets
├── doc/ # Documentation
├── dist_dev/ # Chrome/Edge dev build
@@ -286,7 +317,7 @@ ts-node ./script/crowdin/sync-source.ts
ts-node ./script/crowdin/sync-translation.ts
```
-Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/timer/actions/workflows/crowdin-sync.yml)
+Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/time-tracker-4-browser/actions/workflows/crowdin-sync.yml)
2. Export translations from Crowdin
diff --git a/README-zh.md b/README-zh.md
index 498162e6b..9b49e936d 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -1,8 +1,15 @@
# 网费很贵
-[](https://codecov.io/gh/sheepzh/timer)
+[](https://codecov.io/gh/sheepzh/time-tracker-4-browser)
[](https://github.com/996icu/996.ICU)
-[](https://github.com/sheepzh/timer/releases)
+[](https://github.com/sheepzh/time-tracker-4-browser/releases)
+[](https://discord.gg/yXCngD8pKS)
+
+
+
+
+
+
\[ 简体中文 | [English](./README.md) \]
@@ -48,13 +55,25 @@
-## 贡献指南
+## 如何反馈
-如果你想参与到该项目的开源建设,可以考虑以下几种方式
+如果你在使用过程中遇到了问题,或有任何想法和建议,欢迎通过以下方式告诉我们:
#### 提交 Issue
-如果您有一些好的想法,或者 bug 反馈,可以新建一条 [issue](https://github.com/sheepzh/timer/issues) 。作者会在第一时间进行回复。
+在 [GitHub Issues](https://github.com/sheepzh/time-tracker-4-browser/issues) 中描述你遇到的问题或功能建议,我们会尽快回复。
+
+#### 加入 Discord
+
+加入我们的 [Discord 社区](https://discord.gg/yXCngD8pKS),与开发者和其他用户直接交流。
+
+#### 创建 Discussion
+
+在 [GitHub Discussions](https://github.com/sheepzh/time-tracker-4-browser/discussions) 中发起话题,适合分享使用经验或进行开放性讨论。
+
+## 贡献指南
+
+如果你想参与到该项目的开源建设,可以考虑以下几种方式
#### 参与开发
diff --git a/README.md b/README.md
index b8bf12a3a..acdd1f9dd 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,15 @@
# Time Tracker for Browser
-[](https://codecov.io/gh/sheepzh/timer)
+[](https://codecov.io/gh/sheepzh/time-tracker-4-browser)
[](https://github.com/996icu/996.ICU)
[](https://crowdin.com/project/timer-chrome-edge-firefox)
+[](https://discord.gg/yXCngD8pKS)
+
+
+
+
+
+
\[ English | [简体中文](./README-zh.md) \]
@@ -47,15 +54,27 @@ Time Tracker is a browser extension to track the time you spent on all websites.
Page Blocking
-## Contribution
+## Feedback
-There are some things you can do to contribute to this software.
+If you encounter any issues or have suggestions during use, feel free to reach out through the following channels:
+
+#### 1. Submit an Issue
+
+Describe your problem or feature request in [GitHub Issues](https://github.com/sheepzh/time-tracker-4-browser/issues), and we'll get back to you as soon as possible.
+
+#### 2. Join Discord
-#### 1. Submit issues
+Join our [Discord community](https://discord.gg/yXCngD8pKS) to chat directly with the developer and other users.
-You can [submit one issue](https://github.com/sheepzh/timer/issues) to us if you have some suggestions, feature requests, or feedback of bugs. And we will reply it as soon as possible.
+#### 3. Create a Discussion
+
+Start a topic in [GitHub Discussions](https://github.com/sheepzh/time-tracker-4-browser/discussions) — great for sharing experiences or open-ended conversations.
+
+## Contribution
+
+There are some things you can do to contribute to this software.
-#### 2. Participate in development
+#### 1. Participate in development
If you know how to develop browser extensions and are familiar with the project's technology stack (TypeScript + Vue3 + Element Plus + Echarts), you can also contribute code
@@ -63,7 +82,7 @@ See the [Development Guide](./CONTRIBUTING.md)
#### 3. Perfect translation
-In addition to Simplified Chinese, the other localized languages of this software all rely on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox).
+Most of the software's localization relies on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox).
#### 4. Rate 5 stars
diff --git a/doc/for-fire-fox.md b/doc/for-fire-fox.md
deleted file mode 100644
index 9f1ce08f4..000000000
--- a/doc/for-fire-fox.md
+++ /dev/null
@@ -1,14 +0,0 @@
-## How to build for Firefox
-
-```shell
-npm install
-npm run build:firefox
-```
-
-The output directory is `dist_prod_firefox` and you can find a zip file named `target.firefox.zip` in the `market_packages` directory
-
-## Another
-
-Also you could visit the sourcecode on GitHub
-
-[sheepzh/timer](https://github.com/sheepzh/timer)
diff --git a/doc/for-firefox.md b/doc/for-firefox.md
new file mode 100644
index 000000000..6a5a680b6
--- /dev/null
+++ b/doc/for-firefox.md
@@ -0,0 +1,24 @@
+## Building for Firefox
+
+### Prerequisites
+
++ Node.js v22
+
+### Build Steps
+
+```shell
+npm install
+npm run build:firefox
+```
+
+### Output Files
+
++ Build directory: `dist_prod_firefox`
+
++ Marketplace package: `market_packages/target.firefox.zip`
+
+## Source Code
+
+Visit our GitHub repository for the latest source code:
+
+[sheepzh/time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser)
diff --git a/doc/safari-install.md b/doc/safari-install.md
index a3c8c9b79..f08b32824 100644
--- a/doc/safari-install.md
+++ b/doc/safari-install.md
@@ -6,8 +6,8 @@ So please install it **manually**, GG Safari.
## 0. Download this repository
```shell
-git clone https://github.com/sheepzh/timer.git
-cd timer
+git clone https://github.com/sheepzh/time-tracker-4-browser.git
+cd time-tracker-4-browser
```
## 1. Install tools
@@ -30,14 +30,12 @@ npm install
npm run build:safari
```
-Then there will be one folder called **Timer**.
-
-Also, you can download the archived file from [the release page](https://github.com/sheepzh/timer/releases), and unzip it to gain this folder.
+Then there will be one folder called **dist_prod_safari**.
2. Convert js bundles to Xcode project
```shell
-[YOUR_PATH]/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter ./Timer
+[YOUR_PATH]/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter ./dist_prod_safari
```
3. Run Xcode project and one extension app will installed on your macOS
diff --git a/doc/similar-comparison.md b/doc/similar-comparison.md
deleted file mode 100644
index c0b7df02a..000000000
--- a/doc/similar-comparison.md
+++ /dev/null
@@ -1,25 +0,0 @@
-
-|Features|Webtime Tracker|Web Activity Time Tracker|Time Tracker|TimeYourWeb Time Tracker|
-|----|----|----|----|----|
-|Chrome|[👤 60k+
⭐ 4.79/5](https://chrome.google.com/webstore/detail/webtime-tracker/ppaojnbmmaigjmlpjaldnkgnklhicppk)|[👤 20k+
⭐ 4.71/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 2k+
⭐ 4.97/5](https://chrome.google.com/webstore/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm)|[👤 13k+
⭐ 4.38/5](https://chrome.google.com/webstore/detail/timeyourweb-time-tracker/kfmlkgchpffnaphmlmjnimonlldbcpnh)
-|Edge|❌|[👤 4k+
⭐ 4.50/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 3k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc)|[👤 400+
⭐ Not rated](https://microsoftedge.microsoft.com/addons/detail/insight-track-and-optim/kkcmfbejfopnopfcmcjgfkpalecbleio)|[👤 1k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timeyourweb-time-tracker/jodkkcjbahdphacgjhacggclncbaffoe)
-|Firefox|[👤 90+
⭐ Not rated](https://addons.mozilla.org/en-US/firefox/addon/webtime-tracker-2/)|❌|[👤 100+
⭐ 5.00/5](https://addons.mozilla.org/en-US/firefox/addon/besttimetracker/)|❌
-|Track by domain|✅|✅|✅|✅|
-|Track any URL|❌|❌|✅|❌
-|Track local files|❌|✅|❌|✅|
-|Whitelist|❌|✅|✅|✅
-|Data chart screenshot|✅|❌|✅|❌
-|Daily browsing restrictions|❌|✅|✅|❌
-|Daily notifications|❌|✅|❌|❌|❌
-|Pause switch|❌|❌|❌|✅
-|Access calendar|❌|✅|✅|❌
-|Site analysis|❌|❌|✅|❌
-|Active moments per day|❌|❌|✅|✅
-|Beginner's guide|✅|❌|✅|❌
-|Dark mode|❌|❌|✅|❌
-|Export data|✅|✅|✅|❌|
-|Sync data across browsers|❌|❌|✅|❌
-|Free|✅|✅|✅|✅|
-|Open Source|❌|[GitHub](https://github.com/Stigmatoz/web-activity-time-tracker)|[GitHub](https://github.com/sheepzh/timer)|❌
-|Languages|English|Deutsch, English, русский|English, português (Portugal), 中文 (简体), 中文 (繁體), 日本語|English
-|Memory usage with 5 tabs open on Chrome, MacOS
|21+M|19+M|21+M|20+M
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 4b4b1b320..000000000
--- a/jest.config.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { type Config } from "@jest/types"
-import { compilerOptions } from './tsconfig.json'
-
-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 (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.InitialOptions = {
- 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 2089103e8..c20c16056 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
- "name": "timer",
- "version": "3.7.3",
+ "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": {
@@ -24,51 +24,54 @@
"url": "https://www.github.com/sheepzh"
},
"repository": {
- "url": "https://github.com/sheepzh/timer"
+ "url": "https://github.com/sheepzh/time-tracker-4-browser"
},
"license": "MIT",
"devDependencies": {
- "@crowdin/crowdin-api-client": "^1.48.3",
+ "@commitlint/types": "^21.0.1",
+ "@crowdin/crowdin-api-client": "^1.55.2",
"@emotion/babel-plugin": "^11.13.5",
- "@emotion/css": "^11.13.5",
- "@rsdoctor/rspack-plugin": "^1.3.8",
- "@rspack/cli": "^1.6.1",
- "@rspack/core": "^1.6.1",
- "@swc/core": "^1.15.1",
- "@swc/jest": "^0.2.39",
- "@types/chrome": "0.1.27",
+ "@rsdoctor/rspack-plugin": "^1.5.11",
+ "@rspack/cli": "^2.0.4",
+ "@rspack/core": "^2.0.4",
+ "@rstest/core": "^0.10.1",
+ "@rstest/coverage-istanbul": "^0.10.1",
+ "@types/chrome": "0.1.42",
"@types/decompress": "^4.2.7",
- "@types/jest": "^30.0.0",
- "@types/node": "^24.10.0",
- "@types/punycode": "^2.1.4",
+ "@types/firefox-webext-browser": "^143.0.0",
+ "@types/node": "^25.9.1",
"@vue/babel-plugin-jsx": "^2.0.1",
- "babel-loader": "^10.0.0",
- "commitlint": "^20.1.0",
- "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.29.1",
- "ts-loader": "^9.5.4",
+ "knip": "^6.14.1",
+ "postcss": "^8.5.15",
+ "postcss-loader": "^8.2.1",
+ "postcss-rtlcss": "^6.0.0",
+ "puppeteer": "^25.0.4",
"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",
- "echarts": "^6.0.0",
- "element-plus": "2.11.7",
- "punycode": "^2.3.1",
- "vue": "^3.5.24",
- "vue-router": "^4.6.3"
+ "@emotion/css": "^11.13.5",
+ "echarts": "^6.1.0",
+ "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": ">=20"
+ "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 36bd6144c..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,19 +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-fire-fox.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', '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: [{
@@ -33,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/android-firefox.sh b/script/android-firefox.sh
index 47911382e..d5446ed69 100755
--- a/script/android-firefox.sh
+++ b/script/android-firefox.sh
@@ -21,29 +21,10 @@ cleanup() {
# Set trap to cleanup on exit
trap cleanup EXIT
-# Color definitions
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-# No Color
-NC='\033[0m'
-
-log_info() {
- echo -e "${BLUE}[ INFO]${NC} $1" >&4
-}
-
-log_success() {
- echo -e "${GREEN}[ SUCC]${NC} $1" >&4
-}
-
-log_warning() {
- echo -e "${YELLOW}[ WARN]${NC} $1" >&4
-}
-
-log_error() {
- echo -e "${RED}[ERROR]${NC} $1" >&4
-}
+# Source logging functions
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+LOG_FD=4
+source "${SCRIPT_DIR}/lib/log.sh"
# Check if command exists
check_command() {
@@ -163,23 +144,19 @@ check_firefox_nightly() {
build_and_run_extension() {
local device_id="$1"
local extension_dir="dist_dev_firefox"
-
# Clear existing extension directory
if [ -d "$extension_dir" ]; then
log_info "Clearing existing extension directory..."
rm -rf "$extension_dir"
fi
-
# Start npm run dev:firefox in background
log_info "Starting npm run dev:firefox in background..."
npm run dev:firefox > /dev/null 2>&1 &
NPM_PID=$!
-
# Wait for manifest.json to be created
local max_wait=60
local wait_count=0
log_info "Waiting for building finished..."
-
while [ ! -f "$extension_dir/manifest.json" ] && [ $wait_count -lt $max_wait ]; do
sleep 1
wait_count=$((wait_count + 1))
@@ -187,19 +164,19 @@ build_and_run_extension() {
log_info "Still waiting for building finished... ($wait_count/$max_wait)"
fi
done
-
+
if [ ! -f "$extension_dir/manifest.json" ]; then
log_error "Building not finished yet after ${max_wait}s"
cleanup
exit 1
fi
-
+
log_success "Extension built successfully, manifest.json found"
log_info "Background build process PID: $NPM_PID"
log_info "Starting extension development server..."
log_info "Command: web-ext run -t firefox-android --firefox-apk org.mozilla.fenix -s $extension_dir --adb-device $device_id --verbose"
-
+
if web-ext run \
-t firefox-android \
--firefox-apk org.mozilla.fenix \
diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts
index 2b9361f56..80fc7474f 100644
--- a/script/crowdin/client.ts
+++ b/script/crowdin/client.ts
@@ -19,14 +19,12 @@ const MAIN_BRANCH_NAME = 'main'
*/
class PaginationIterator {
private offset = 0
- private limit = 25
+ private limit = 500
private isEnd = false
private buf: T[] = []
private cursor = 0
- private query: (pagination: Pagination) => Promise>
- constructor(query: (pagination: Pagination) => Promise>) {
- this.query = query
+ constructor(private query: (pagination: Pagination) => Promise>) {
}
reset(): void {
@@ -79,8 +77,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 +96,7 @@ export type NameKey = {
branchId: number
}
-export type TranslationKey = {
+type TranslationKey = {
stringId: number
lang: CrowdinLanguage
}
@@ -169,17 +167,6 @@ export class CrowdinClient {
return response.data
}
- async restoreFile(storage: UploadStorageModel.Storage, existFile: SourceFilesModel.File): Promise {
- const response = await this.crowdin.sourceFilesApi.updateOrRestoreFile(PROJECT_ID, existFile.id, { storageId: storage.id })
- return response.data
- }
-
- getFileByName(param: NameKey): Promise {
- return new PaginationIterator(
- p => this.crowdin.sourceFilesApi.listProjectFiles(PROJECT_ID, { ...p, branchId: param.branchId })
- ).findFirst(t => t.name === param.name)
- }
-
getDirByName(param: NameKey): Promise {
return new PaginationIterator(
p => this.crowdin.sourceFilesApi.listProjectDirectories(PROJECT_ID, { ...p, branchId: param.branchId })
@@ -200,6 +187,12 @@ export class CrowdinClient {
).findAll()
}
+ async downloadSourceFile(fileId: number): Promise> {
+ const res = await this.crowdin.sourceFilesApi.downloadFile(PROJECT_ID, fileId)
+ const response = await fetch(res.data.url)
+ return await response.json() as Record
+ }
+
listStringsByFile(fileId: number): Promise {
return new PaginationIterator(
p => this.crowdin.sourceStringsApi.listProjectStrings(PROJECT_ID, { ...p, fileId: fileId })
@@ -229,15 +222,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,13 +272,25 @@ export class CrowdinClient {
targetLanguageIds: [...ALL_CROWDIN_LANGUAGES],
skipUntranslatedStrings: true,
})
- const buildId = buildRes?.data?.id
+ const buildId = buildRes.data.id
+ const maxRetries = 120
+ let retryCount = 0
while (true) {
- // Wait finished
- const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId)
- const url = res?.data?.url
- if (url) return url
+ if (retryCount >= maxRetries) {
+ throw new Error(`Build timed out after ${maxRetries} retries: buildId=${buildId}`)
+ }
+ const statusRes = await this.crowdin.translationsApi.checkBuildStatus(PROJECT_ID, buildId)
+ const { status, progress } = statusRes.data
+ console.log(`Build status: ${status}, progress: ${progress}%`)
+ if (status === 'finished') break
+ if (status === 'canceled' || status === 'failed') {
+ throw new Error(`Build ${status}: buildId=${buildId}`)
+ }
+ retryCount++
+ await new Promise(resolve => setTimeout(resolve, 1000))
}
+ const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId)
+ return res.data.url
}
}
@@ -296,7 +300,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..090f1baa4 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"
/**
@@ -106,7 +92,7 @@ export const RSC_FILE_SUFFIX = "-resource.json"
* @param dir the directory of messages
* @returns
*/
-export async function readAllMessages(dir: Dir): Promise>> {
+export async function readAllMessages(dir: Dir): Promise>>> {
const dirPath = path.join(MSG_BASE, dir)
const files = fs.readdirSync(dirPath)
@@ -115,7 +101,7 @@ export async function readAllMessages(dir: Dir): Promise
+ const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages>
const name = file.replace(RSC_FILE_SUFFIX, '')
message && (result[name] = message)
return
@@ -124,22 +110,22 @@ 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
+ 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,32 +141,36 @@ 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(`\x1b[31m[CROWDIN-ERROR]\x1b[0m ${msg}`)
+}
+
function checkPlaceholder(translated: string, source: string) {
const allSourcePlaceholders =
- Array.from(source.matchAll(/\{(.*?)\}/g))
+ Array.from(source.matchAll(/\{(.*?)}/g))
.map(matched => matched[1])
.sort()
const allTranslatedPlaceholders =
- Array.from(translated.matchAll(/\{(.*?)\}/g))
+ Array.from(translated.matchAll(/\{(.*?)}/g))
.map(matched => matched[1])
.sort()
if (allSourcePlaceholders.length != allTranslatedPlaceholders.length) {
return false
}
- for (let i = 0; i++; i < allSourcePlaceholders.length) {
+ for (let i = 0; i < allSourcePlaceholders.length; i++) {
if (allSourcePlaceholders[i] !== allTranslatedPlaceholders[i]) {
return false
}
@@ -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,7 @@ 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/download.ts b/script/crowdin/download.ts
new file mode 100644
index 000000000..4059af6ca
--- /dev/null
+++ b/script/crowdin/download.ts
@@ -0,0 +1,49 @@
+import decompress from "decompress"
+import { existsSync, readFileSync } from "fs"
+import { rm, writeFile } from "fs/promises"
+import { join } from "path"
+import { type Dir, type ItemSet, transMsg } from "./common"
+
+const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip")
+const TEMP_DIR = join(process.cwd(), ".crowdin-temp")
+
+export async function downloadProjectZip(url: string): Promise {
+ const res = await fetch(url)
+ const blob = await res.blob()
+ const buffer = Buffer.from(await blob.arrayBuffer())
+ await writeFile(TEMP_FILE_NAME, buffer)
+ console.log("Downloaded project zip file")
+ if (existsSync(TEMP_DIR)) {
+ await rm(TEMP_DIR, { recursive: true, force: true })
+ }
+ await decompress(TEMP_FILE_NAME, TEMP_DIR)
+ console.log("Decompressed zip file")
+ return TEMP_DIR
+}
+
+export async function clearTempFile() {
+ await rm(TEMP_FILE_NAME, { force: true })
+ await rm(TEMP_DIR, { recursive: true, force: true })
+}
+
+/**
+ * Read a single file from the unzipped Crowdin project translation.
+ *
+ * @param tmpDir temp directory containing unzipped translations
+ * @param langDir language subdirectory (e.g. 'zh-CN' or 'en')
+ * @param dir message directory
+ * @param crowdinFileName file name with .json extension
+ */
+export function readCrowdinZipFile(tmpDir: string, langDir: string, dir: Dir, crowdinFileName: string): ItemSet {
+ const filePath = join(tmpDir, langDir, dir, crowdinFileName)
+ if (!existsSync(filePath)) {
+ return {}
+ }
+ try {
+ const json = readFileSync(filePath).toString()
+ return transMsg(JSON.parse(json))
+ } catch (error) {
+ console.warn(`Failed to parse crowdin zip file: ${filePath}`, error)
+ return {}
+ }
+}
diff --git a/script/crowdin/export-translation.ts b/script/crowdin/export-translation.ts
index eca038ee3..bd12e5e59 100644
--- a/script/crowdin/export-translation.ts
+++ b/script/crowdin/export-translation.ts
@@ -1,26 +1,17 @@
-import decompress from "decompress"
-import { existsSync, readdirSync, readFileSync, rm, writeFile } from "fs"
+import { readdirSync, readFileSync } from "fs"
import { join } from "path"
import { getClientFromEnv } from "./client"
import {
- ALL_DIRS, ALL_TRANS_LOCALES,
- checkMainBranch,
- crowdinLangOf,
- type Dir,
- type ItemSet,
- mergeMessage,
- RSC_FILE_SUFFIX,
+ ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type Dir, type ItemSet, mergeMessage, RSC_FILE_SUFFIX,
transMsg
} from "./common"
+import { clearTempFile, downloadProjectZip } from "./download"
-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>> = {}
+async function processDir(tmpDir: string, dir: Dir): Promise {
+ const fileSets: Record>> = {}
for (const locale of ALL_TRANS_LOCALES) {
const crowdinLang = crowdinLangOf(locale)
- const dirPath = join(TEMP_DIR, crowdinLang, dir)
+ const dirPath = join(tmpDir, crowdinLang, dir)
const files = readdirSync(dirPath)
for (const fileName of files) {
const json = readFileSync(join(dirPath, fileName)).toString()
@@ -34,44 +25,18 @@ async function processDir(dir: Dir): Promise {
}
}
-async function downloadProjectZip(url: string): Promise {
- const res = await fetch(url)
- const blob = await res.blob()
- const buffer = Buffer.from(await blob.arrayBuffer())
- await new Promise(resolve => writeFile(TEMP_FILE_NAME, buffer, resolve))
-}
-
-async function compressProjectZip(): Promise {
- if (existsSync(TEMP_DIR)) {
- await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve))
- }
- await decompress(TEMP_FILE_NAME, TEMP_DIR)
-}
-
-async function clearTempFile() {
- await new Promise(resolve => rm(TEMP_FILE_NAME, resolve))
- await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve))
-}
-
async function main() {
const client = getClientFromEnv()
const branch = await checkMainBranch(client)
const zipUrl = await client.buildProjectTranslation(branch.id)
console.log("Built project translations")
console.log(zipUrl)
- await downloadProjectZip(zipUrl)
- console.log("Downloaded project zip file")
- try {
- await compressProjectZip()
- console.log("Compressed zip file")
- for (const dir of ALL_DIRS) {
- await processDir(dir)
- console.log("Processed dir: " + dir)
- }
- } finally {
- clearTempFile()
- console.log("Cleaned temp files")
+ const tmpDir = await downloadProjectZip(zipUrl)
+
+ for (const dir of ALL_DIRS) {
+ await processDir(tmpDir, dir)
+ console.log("Processed dir: " + dir)
}
}
-main()
\ No newline at end of file
+main().finally(clearTempFile)
\ No newline at end of file
diff --git a/script/crowdin/sync-source.ts b/script/crowdin/sync-source.ts
index a5f3f62ce..8fd494c30 100644
--- a/script/crowdin/sync-source.ts
+++ b/script/crowdin/sync-source.ts
@@ -1,15 +1,20 @@
-import { type SourceFilesModel, type SourceStringsModel } from "@crowdin/crowdin-api-client"
+import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client"
import { toMap } from "@util/array"
import { type CrowdinClient, getClientFromEnv, type NameKey } from "./client"
-import {
- ALL_DIRS,
- Dir,
- isIgnored,
- ItemSet,
- readAllMessages,
- SOURCE_LOCALE,
- transMsg
-} from "./common"
+import { ALL_DIRS, type Dir, isIgnored, type ItemSet, readAllMessages, SOURCE_LOCALE, transMsg } from "./common"
+
+/**
+ * Check if local source content is the same as Crowdin source content
+ */
+function isSourceUnchanged(localContent: ItemSet, crowdinContent: ItemSet): boolean {
+ const localKeys = Object.keys(localContent).filter(k => !!localContent[k])
+ const crowdinKeys = Object.keys(crowdinContent)
+ if (localKeys.length !== crowdinKeys.length) return false
+ for (const key of localKeys) {
+ if (localContent[key] !== crowdinContent[key]) return false
+ }
+ return true
+}
async function initBranch(client: CrowdinClient): Promise {
const branch = await client.getOrCreateMainBranch()
@@ -85,6 +90,14 @@ async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFiles
const storage = await client.createStorage(crowdinFilename, fileContent)
existFile = await client.createFile(directory.id, storage, crowdinFilename)
console.log(`Created new file: dir=${dir}, fileName=${crowdinFilename}, id=${existFile.id}`)
+ } else {
+ // Download source file from Crowdin and compare
+ const crowdinJson = await client.downloadSourceFile(existFile.id)
+ const crowdinContent = transMsg(crowdinJson)
+ if (isSourceUnchanged(fileContent, crowdinContent)) {
+ console.log(`No source diff for ${dir}/${crowdinFilename}, skipped`)
+ continue
+ }
}
// Process by strings
await processStrings(client, existFile, fileContent)
diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts
index 184cd3026..1510bc9b3 100644
--- a/script/crowdin/sync-translation.ts
+++ b/script/crowdin/sync-translation.ts
@@ -1,62 +1,118 @@
-
-import { type SourceFilesModel } from "@crowdin/crowdin-api-client"
+import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client"
import { toMap } from "@util/array"
import { exitWith } from "../util/process"
import { type CrowdinClient, getClientFromEnv } from "./client"
import {
- ALL_DIRS, ALL_TRANS_LOCALES,
- type CrowdinLanguage, type Dir, type ItemSet,
- checkMainBranch, crowdinLangOf, isIgnored, readAllMessages, transMsg,
+ ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type CrowdinLanguage, type Dir, isIgnored,
+ readAllMessages, transMsg,
} from "./common"
+import { clearTempFile, downloadProjectZip, readCrowdinZipFile } from "./download"
const CROWDIN_USER_ID_OF_OWNER = 15266594
-async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.File, message: ItemSet, lang: CrowdinLanguage): Promise {
- console.log(`Start to process dir message: fileName=${file.name}, lang=${lang}`)
- const strings = await client.listStringsByFile(file.id)
- const stringMap = toMap(strings, s => s.identifier)
- for (const [identifier, text] of Object.entries(message)) {
- const string = stringMap[identifier]
- if (!string) {
- console.log(`Can't found string of identifier: ${identifier}, file: ${file.path}`)
+/**
+ * Sync translation for a single string.
+ * Keeps the same behavior as the original processDirMessage:
+ * 1. Delete all owner's translations that differ from current text
+ * 2. Create new translation if no matching one exists
+ */
+async function syncStringTranslation(
+ client: CrowdinClient,
+ stringId: number,
+ text: string,
+ lang: CrowdinLanguage,
+): Promise {
+ const existList = await client.listTranslationByStringAndLang({ stringId, lang })
+ // Delete old translations by owner that differ from current
+ 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=${stringId}, lang=${lang}, text=${toDelete.text}`)
+ }
+ if (!existList.some(t => t.text === text)) {
+ await client.createTranslation({ stringId, lang }, text)
+ console.log(`Created trans: stringId=${stringId}, lang=${lang}, text=${text}`)
+ }
+}
+/**
+ * Process translations for a single file across all locales
+ */
+async function processFile(client: CrowdinClient, options: {
+ dir: Dir
+ fileName: string
+ message: Messages>
+ crowdinFile: SourceFilesModel.File
+ tmpDir: string
+}): Promise {
+ const { dir, fileName, message, crowdinFile, tmpDir } = options
+ const crowdinFileName = fileName + '.json'
+ let stringMap: Record = {}
+ let stringMapLoaded = false
+
+ for (const locale of ALL_TRANS_LOCALES) {
+ const translated = message[locale]
+ if (!translated || !Object.keys(translated).length) {
continue
}
- if (text === string.text) {
- // The same as original text
- console.log(`Translation same as origin text of ${string.identifier} in ${file.path}`)
+
+ const crowdinLang = crowdinLangOf(locale)
+ const strings = transMsg(translated)
+ const crowdinStrings = readCrowdinZipFile(tmpDir, crowdinLang, dir, crowdinFileName)
+
+ // Compare locally first — find strings that actually differ
+ const diffKeys = Object.entries(strings).filter(([identifier, text]) => {
+ if (!text) return false
+ return crowdinStrings[identifier] !== text
+ })
+
+ if (!diffKeys.length) {
+ console.log(`No diff for ${dir}/${fileName} [${crowdinLang}], skipped`)
continue
}
- 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)
- for (const toDelete of oldByOwner || []) {
- await client.deleteTranslation(toDelete.id)
- console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`)
+
+ console.log(`Found ${diffKeys.length} diff(s) for ${dir}/${fileName} [${crowdinLang}]`)
+
+ if (!stringMapLoaded) {
+ const existStrings = await client.listStringsByFile(crowdinFile.id)
+ stringMap = toMap(existStrings, s => s.identifier)
+ stringMapLoaded = true
}
- if (!existList?.find(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}`)
+
+ for (const [identifier, text] of diffKeys) {
+ const string = stringMap[identifier]
+ if (!string) {
+ console.log(`Can't find string of identifier: ${identifier}, file: ${crowdinFile.path}`)
+ continue
+ }
+ if (text === string.text) {
+ console.log(`Translation same as origin text of ${string.identifier} in ${crowdinFile.path}`)
+ continue
+ }
+ await syncStringTranslation(client, string.id, text, crowdinLang)
}
}
}
-async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesModel.Branch): Promise {
+/**
+ * Compare local translations with Crowdin zip, only sync differences
+ */
+async function processDir(
+ client: CrowdinClient,
+ dir: Dir,
+ branch: SourceFilesModel.Branch,
+ tmpDir: string,
+): Promise {
const messages = await readAllMessages(dir)
- const directory = await client.getDirByName({
- name: dir,
- branchId: branch.id,
- })
+ const directory = await client.getDirByName({ name: dir, branchId: branch.id })
if (!directory) {
exitWith("Directory not found: " + dir)
}
- const files = await client.listFilesByDirectory(directory!.id)
+ const files = await client.listFilesByDirectory(directory.id)
console.log(`find ${files.length} files of ${dir}`)
const fileMap = toMap(files, f => f.name)
for (const [fileName, message] of Object.entries(messages)) {
- console.log(`Start to sync translations of ${dir}/${fileName}`)
if (isIgnored(dir, fileName)) {
- console.log("Ignored file: " + fileName)
+ console.log(`Ignored file: ${dir}/${fileName}`)
continue
}
const crowdinFileName = fileName + '.json'
@@ -65,16 +121,7 @@ async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesMo
console.log(`Failed to find file: dir=${dir}, filename=${fileName}`)
continue
}
-
- for (const locale of ALL_TRANS_LOCALES) {
- const translated = message[locale]
- if (!translated || !Object.keys(translated).length) {
- continue
- }
- const strings = transMsg(message[locale])
- const crowdinLang = crowdinLangOf(locale)
- await processDirMessage(client, crowdinFile, strings, crowdinLang)
- }
+ await processFile(client, { dir, fileName, message, crowdinFile, tmpDir })
}
}
@@ -82,9 +129,14 @@ async function main() {
const client = getClientFromEnv()
const branch = await checkMainBranch(client)
+ // Download all translations as zip for local comparison
+ const zipUrl = await client.buildProjectTranslation(branch.id)
+ const tmpDir = await downloadProjectZip(zipUrl)
+ console.log("Downloaded project zip to: " + tmpDir)
+
for (const dir of ALL_DIRS) {
- await processDir(client, dir, branch)
+ await processDir(client, dir, branch, tmpDir)
}
}
-main()
\ No newline at end of file
+main().finally(clearTempFile)
\ No newline at end of file
diff --git a/script/lib/log.sh b/script/lib/log.sh
new file mode 100755
index 000000000..c5861f732
--- /dev/null
+++ b/script/lib/log.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Color definitions
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+# No Color
+NC='\033[0m'
+
+log_info() {
+ echo -e "${BLUE}[ INFO]${NC} $1" >&${LOG_FD:-1}
+}
+
+log_success() {
+ echo -e "${GREEN}[ SUCC]${NC} $1" >&${LOG_FD:-1}
+}
+
+log_warning() {
+ echo -e "${YELLOW}[ WARN]${NC} $1" >&${LOG_FD:-1}
+}
+
+log_error() {
+ echo -e "${RED}[ERROR]${NC} $1" >&${LOG_FD:-1}
+}
+
+log_step() {
+ echo -e "${CYAN}[ STEP]${NC} $1" >&${LOG_FD:-1}
+}
+
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
new file mode 100755
index 000000000..09b1da86e
--- /dev/null
+++ b/script/setup-e2e.sh
@@ -0,0 +1,419 @@
+#!/bin/bash
+
+# Exit immediately on error
+set -e
+
+PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+
+# Source logging functions
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "${SCRIPT_DIR}/lib/log.sh"
+
+# Detect OS
+detect_os() {
+ case "$(uname -s)" in
+ Linux*)
+ echo "linux"
+ ;;
+ Darwin*)
+ echo "macos"
+ ;;
+ MINGW*|MSYS*|CYGWIN*)
+ echo "windows"
+ ;;
+ *)
+ echo "unknown"
+ ;;
+ esac
+}
+
+# Check if command exists
+check_command() {
+ if ! command -v "$1" &> /dev/null; then
+ return 1
+ fi
+ return 0
+}
+
+# Check Node.js version
+check_node_version() {
+ if ! check_command node; then
+ log_error "Node.js is not installed"
+ log_info "Please install Node.js >= 22 from https://nodejs.org/"
+ exit 1
+ fi
+
+ local node_version
+ node_version=$(node -v | sed 's/v//')
+ local major_version
+ major_version=$(echo "$node_version" | cut -d. -f1)
+
+ log_info "Current Node.js version: $node_version"
+
+ if [ "$major_version" -lt 22 ]; then
+ log_error "Node.js version must be >= 22"
+ log_info "Current version: $node_version"
+ log_info "Please upgrade Node.js from https://nodejs.org/"
+ exit 1
+ fi
+
+ log_success "Node.js version check passed"
+}
+
+# Check npm
+check_npm() {
+ if ! check_command npm; then
+ log_error "npm is not installed"
+ log_info "npm should come with Node.js, please reinstall Node.js"
+ exit 1
+ fi
+
+ local npm_version
+ npm_version=$(npm -v)
+ log_info "Current npm version: $npm_version"
+ log_success "npm is available"
+}
+
+# Install global npm package (only if not installed)
+install_global_package() {
+ local package_name=$1
+ if command -v "$package_name" &> /dev/null; then
+ log_info "$package_name is already installed"
+ return 0
+ fi
+
+ log_info "$package_name is not installed, installing..."
+ if npm install -g "${package_name}@latest" &> /dev/null; then
+ log_success "$package_name installed successfully"
+ else
+ log_error "Failed to install $package_name"
+ exit 1
+ fi
+}
+
+# Upgrade global npm package
+upgrade_global_package() {
+ local package_name=$1
+ if ! command -v "$package_name" &> /dev/null; then
+ log_warning "$package_name is not installed, skipping upgrade"
+ return 0
+ fi
+
+ log_info "$package_name is already installed, upgrading..."
+ if npm install -g "${package_name}@latest" &> /dev/null; then
+ log_success "$package_name upgraded successfully"
+ else
+ log_error "Failed to upgrade $package_name"
+ exit 1
+ fi
+}
+
+# Install e2e dependencies
+install_e2e_dependencies() {
+ log_step "Installing e2e test dependencies..."
+
+ if [ ! -d "$PROJECT_ROOT/node_modules/puppeteer" ]; then
+ log_error "puppeteer is not installed. Please run 'npm install' first."
+ exit 1
+ fi
+
+ local browser_path
+ browser_path=$(node -e "try { const p = require('puppeteer'); console.log(p.executablePath()); } catch(e) { process.exit(1); }" 2>/dev/null)
+ if [ -n "$browser_path" ] && [ -f "$browser_path" ]; then
+ log_info "Browser is already downloaded"
+ elif [ -f "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" ]; then
+ log_info "Downloading browser for puppeteer..."
+ if node "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" &> /dev/null; then
+ log_success "Browser downloaded successfully"
+ else
+ log_error "Failed to download browser for puppeteer"
+ exit 1
+ fi
+ else
+ log_warning "puppeteer install script not found, browser may already be downloaded"
+ fi
+
+ install_global_package "http-server"
+ install_global_package "pm2"
+
+ 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..."
+
+ upgrade_global_package "http-server"
+ upgrade_global_package "pm2"
+
+ log_success "E2E dependencies upgraded successfully"
+}
+
+# Build npm script output
+build_npm_script() {
+ local script_name=$1
+ local output_dir=$2
+ local is_required=${3:-true}
+
+ log_step "Building $script_name output..."
+
+ cd "$PROJECT_ROOT" || exit 1
+
+ log_info "Running: npm run $script_name"
+ local build_output
+ local build_exit_code
+ set +e
+ build_output=$(npm run "$script_name" 2>&1)
+ build_exit_code=$?
+ set -e
+
+ if [ $build_exit_code -eq 0 ]; then
+ if [ -d "$PROJECT_ROOT/$output_dir" ]; then
+ log_success "$script_name output built successfully in $output_dir/"
+ else
+ if [ "$is_required" = true ]; then
+ log_error "$script_name output directory $output_dir/ not found after build"
+ exit 1
+ else
+ log_warning "$script_name output directory $output_dir/ not found after build"
+ fi
+ fi
+ else
+ log_error "Failed to build $script_name output"
+ echo "$build_output" >&2
+ exit 1
+ fi
+}
+
+# Build e2e output
+build_e2e_output() {
+ build_npm_script "dev:e2e" "dist_e2e" true
+}
+
+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
+start_test_servers() {
+ log_step "Starting test servers..."
+
+ if ! command -v pm2 &> /dev/null || ! command -v http-server &> /dev/null; then
+ log_error "pm2 or http-server is not installed. Please run setup first."
+ exit 1
+ fi
+
+ cd "$PROJECT_ROOT" || exit 1
+
+ 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 "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 "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 "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"
+}
+
+# Show usage
+show_usage() {
+ echo "Usage: $0 [OPTIONS]"
+ echo ""
+ echo "E2E Test Environment Setup Script"
+ echo ""
+ echo "This script initializes the e2e testing environment and can build e2e outputs."
+ echo "It installs/upgrades required dependencies and compiles the code for e2e testing."
+ echo ""
+ echo "Options:"
+ 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 " --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 ""
+ echo "Examples:"
+ echo " $0 --init # Install e2e dependencies (if not installed)"
+ echo " $0 --upgrade # Upgrade e2e dependencies"
+ echo " $0 --build # Build e2e output only"
+ echo " $0 --init --build # Initialize and build"
+ echo " $0 --all # Initialize, build e2e and production outputs"
+ echo " $0 --build --start-servers # Build and start test servers"
+}
+
+# Main function
+main() {
+ local do_init=false
+ local do_upgrade=false
+ local do_build=false
+ local do_start_servers=false
+
+ # Parse arguments
+ while [[ $# -gt 0 ]]; do
+ case $1 in
+ --init|-i)
+ do_init=true
+ shift
+ ;;
+ --upgrade|-u)
+ do_upgrade=true
+ shift
+ ;;
+ --build|-b)
+ do_build=true
+ shift
+ ;;
+ --start-servers|-s)
+ do_start_servers=true
+ shift
+ ;;
+ --all|-a)
+ do_init=true
+ do_build=true
+ do_start_servers=true
+ shift
+ ;;
+ --help|-h)
+ show_usage
+ exit 0
+ ;;
+ *)
+ log_error "Unknown option: $1"
+ show_usage
+ exit 1
+ ;;
+ esac
+ done
+
+ if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_start_servers" = false ]; then
+ show_usage
+ exit 0
+ fi
+
+ if [ ! -f "package.json" ]; then
+ log_error "Please run this script from the project root."
+ exit 1
+ fi
+
+ echo -e "${CYAN}=== E2E Test Environment Setup ===${NC}"
+ echo ""
+
+ # Detect OS and show info
+ local os_type
+ os_type=$(detect_os)
+ log_info "Detected OS: $os_type"
+
+ if [ "$os_type" = "windows" ]; then
+ log_warning "Running on Windows. Make sure you're using Git Bash or WSL."
+ fi
+
+ # Check prerequisites
+ check_node_version
+ check_npm
+
+ # Run requested operations
+ if [ "$do_init" = true ]; then
+ install_e2e_dependencies
+ install_mock_server_dependencies
+ fi
+
+ if [ "$do_upgrade" = true ]; then
+ upgrade_e2e_dependencies
+ fi
+
+ if [ "$do_build" = true ]; then
+ build_e2e_output
+ fi
+
+ if [ "$do_start_servers" = true ]; then
+ start_test_servers
+ fi
+
+ log_success "All operations completed successfully!"
+ log_info "Run e2e tests:"
+ log_info " 1. npm run test-e2e"
+ log_info " 2. USE_HEADLESS_PUPPETEER=true npm run test-e2e"
+}
+
+# Run main function
+main "$@"
\ No newline at end of file
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 5021576f5..b7c632fc8 100644
--- a/script/user-chart/add.ts
+++ b/script/user-chart/add.ts
@@ -5,48 +5,74 @@ import {
type Gist,
type GistForm,
updateGist as updateGistApi
-} from "@src/api/gist"
+} from "@api/gist"
+import { CHROME_ID } from "@util/constant/meta"
import fs from "fs"
import { exitWith } from "../util/process"
-import { descriptionOf, filenameOf, getExistGist, validateTokenFromEnv } from "./common"
+import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common"
-type AddArgv = {
+type AutoMode = {
+ mode: 'auto'
+ dirPath: string
+}
+
+type ManualMode = {
+ mode: 'manual'
browser: Browser
fileName: string
}
+type AddArgv = AutoMode | ManualMode
+
+const BROWSER_MAP: Record = {
+ c: 'chrome',
+ e: 'edge',
+ f: 'firefox',
+}
+
function parseArgv(): AddArgv {
const argv = process.argv.slice(2)
- const browserArgv = argv[0]
- const fileName = argv[1]
- if (!browserArgv || !fileName) {
- exitWith("add.ts [c/e/f] [file_name]")
- }
- const browserArgvMap: Record = {
- c: 'chrome',
- e: 'edge',
- f: 'firefox',
- }
- const browser: Browser = browserArgvMap[browserArgv]
- if (!browser) {
- exitWith("add.ts [c/e/f] [file_name]")
- }
- return {
- browser,
- fileName
+ const [a0, a1] = argv
+
+ if (!a0) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]")
+
+ if (a0 === 'auto') {
+ return a1 ? { mode: 'auto', dirPath: a1 } : exitWith("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_MAP[a0]
+ if (!browser) return exitWith("add.ts [c/e/f] [file_name]")
+
+ return { mode: 'manual', browser, fileName: a1 }
+}
+
+function detectBrowser(fileName: string): Browser | null {
+ if (fileName.includes(CHROME_ID)) return 'chrome'
+ if (fileName.includes('usage-day-')) return 'firefox'
+ if (fileName.includes('edgeaddon_analytics')) return 'edge'
+ return null
+}
+
+function sortDataByKey(data: UserCount): UserCount {
+ const sorted: UserCount = {}
+ Object.entries(data)
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .forEach(([key, val]) => sorted[key] = val)
+ return sorted
}
async function createGist(token: string, browser: Browser, data: UserCount) {
const description = descriptionOf(browser)
const filename = filenameOf(browser)
-
- // 1. sort by key
- const sorted: UserCount = {}
- Object.keys(data).sort().forEach(key => sorted[key] = data[key])
- // 2. create
- const files: Record = {}
- files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) }
+ const sorted = sortDataByKey(data)
+ const files: Record = {
+ [filename]: {
+ filename,
+ content: JSON.stringify(sorted, null, 2)
+ }
+ }
const gistForm: GistForm = {
public: true,
description,
@@ -60,25 +86,26 @@ 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
- }
- updateGistApi(token, gist.id, gistForm)
+ const gistForm: GistForm = { public: true, description, files }
+ await updateGistApi(token, gist.id, gistForm)
}
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 => {
@@ -86,10 +113,12 @@ function parseChrome(content: string): UserCount {
if (!dateStr || !numberStr) {
return
}
- // Replace '/' to '-', then rjust month and date
- const date = dateStr.split('/').map(str => rjust(str, 2, '0')).join('-')
+ // Chrome CSV format: YYYY/M/D
+ const [, y, m, d] = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(dateStr.trim()) ?? []
+ if (!y || !m || !d) return
+ const date = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`
const number = parseInt(numberStr)
- date && number && (result[date] = number)
+ number && (result[date] = number)
})
return result
}
@@ -97,7 +126,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 => {
@@ -118,7 +147,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 => {
@@ -142,22 +171,86 @@ function rjust(str: string, num: number, padding: string): string {
return Array.from(new Array(num - str.length).keys()).map(_ => padding).join('') + str
}
+const PARSERS: Record UserCount> = {
+ chrome: parseChrome,
+ edge: parseEdge,
+ firefox: parseFirefox,
+}
+
+async function processFile(browser: Browser, filePath: string): Promise {
+ const content = fs.readFileSync(filePath, { encoding: 'utf-8' })
+ return PARSERS[browser](content)
+}
+
+function groupFilesByBrowser(files: string[]): Record {
+ const grouped: Record = { chrome: [], edge: [], firefox: [] }
+ for (const file of files) {
+ const browser = detectBrowser(file)
+ if (browser) {
+ grouped[browser].push(file)
+ } else {
+ console.warn(`Unknown file format, skipping: ${file}`)
+ }
+ }
+ return grouped
+}
+
+function mergeData(target: UserCount, source: UserCount): void {
+ Object.entries(source).forEach(([key, val]) => {
+ target[key] = target[key] ? Math.max(target[key], val) : val
+ })
+}
+
+async function processDirectory(token: string, dirPath: string) {
+ const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.csv'))
+ if (files.length === 0) exitWith(`No CSV files found in ${dirPath}`)
+
+ const filesByBrowser = groupFilesByBrowser(files)
+
+ for (const [browser, browserFiles] of Object.entries(filesByBrowser)) {
+ if (browserFiles.length === 0) continue
+
+ const typedBrowser = browser as Browser
+ console.log(`Processing ${browser.toUpperCase()}: ${browserFiles.length} file(s)`)
+
+ let mergedData: UserCount = {}
+ for (const file of browserFiles) {
+ const filePath = `${dirPath}/${file}`
+ console.log(`Processing: ${file}`)
+ const data = await processFile(typedBrowser, filePath)
+ mergeData(mergedData, data)
+ }
+
+ if (Object.keys(mergedData).length === 0) {
+ console.log(`No valid data found for ${browser}`)
+ continue
+ }
+
+ console.log(`Merged ${Object.keys(mergedData).length} date entries for ${browser}`)
+
+ const gist = await getExistGist(token, typedBrowser)
+ if (!gist) {
+ console.log(`Creating new gist for ${browser}...`)
+ await createGist(token, typedBrowser, mergedData)
+ } else {
+ console.log(`Updating existing gist for ${browser}...`)
+ await updateGist(token, typedBrowser, mergedData, gist)
+ }
+ console.log(`${browser.toUpperCase()} completed successfully!`)
+ }
+}
+
async function main() {
const token = validateTokenFromEnv()
- const argv: AddArgv = parseArgv()
- const browser = argv.browser
- const fileName = argv.fileName
- const content = fs.readFileSync(fileName, { encoding: 'utf-8' })
- let newData: UserCount = {}
- if (browser === 'chrome') {
- newData = parseChrome(content)
- } else if (browser === 'edge') {
- newData = parseEdge(content)
- } else if (browser === 'firefox') {
- newData = parseFirefox(content)
- } else {
- exitWith("Un-supported browser: " + browser)
+ const argv = parseArgv()
+
+ if (argv.mode === 'auto') {
+ return await processDirectory(token, argv.dirPath)
}
+
+ const { browser, fileName } = argv
+
+ const newData = await processFile(browser, fileName)
const gist = await getExistGist(token, browser)
if (!gist) {
await createGist(token, browser, newData)
diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts
index 6b69633d4..a3a30cfc5 100644
--- a/script/user-chart/common.ts
+++ b/script/user-chart/common.ts
@@ -1,5 +1,12 @@
import { findTarget, type Gist } from "@api/gist"
-import { exitWith } from "../util/process"
+import { exitWith } from '../util/process'
+
+export type Browser =
+ | 'chrome'
+ | 'firefox'
+ | 'edge'
+
+export type UserCount = Record
/**
* Validate the token from environment variables
diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts
index b026106a3..709db982c 100644
--- a/script/user-chart/render.ts
+++ b/script/user-chart/render.ts
@@ -8,17 +8,13 @@ import {
} from "echarts"
import { writeFileSync } from "fs"
import { exit } from 'process'
-import { filenameOf, getExistGist, validateTokenFromEnv } from "./common"
+import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type UserCount } from "./common"
type EcOption = ComposeOption<
| LineSeriesOption
| TitleComponentOption
- | GridComponentOption
->
-
-const ALL_BROWSERS: Browser[] = ['firefox', 'edge', 'chrome']
-
-const POINT_COUNT = 500
+ | GridComponentOption>
+const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge']
type OriginData = {
[browser in Browser]: UserCount
@@ -31,11 +27,13 @@ type ChartData = {
}
}
+const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
+
function preProcess(originData: OriginData): ChartData {
// 1. sort dates
const dateSet = new Set()
Object.values(originData).forEach(ud => Object.keys(ud).forEach(date => dateSet.add(date)))
- let allDates = Array.from(dateSet).sort()
+ let allDates = Array.from(dateSet).filter(d => VALID_DATE_RE.test(d)).sort()
// 2. smooth the count
const ctx: { [browser in Browser]: SmoothContext } = {
@@ -47,7 +45,7 @@ function preProcess(originData: OriginData): ChartData {
allDates.forEach(
date => ALL_BROWSERS.forEach(b => ctx[b].process(originData[b][date]))
)
- const result: ChartData = {
+ return {
xAxis: allDates,
yAxises: {
chrome: ctx.chrome.end(),
@@ -55,12 +53,6 @@ function preProcess(originData: OriginData): ChartData {
edge: ctx.edge.end(),
}
}
-
- // 3. zoom
- const reduction = Math.floor(Object.keys(allDates).length / POINT_COUNT)
- result.xAxis = zoom(result.xAxis, reduction)
- ALL_BROWSERS.forEach(b => result.yAxises[b] = zoom(result.yAxises[b], reduction))
- return result
}
class SmoothContext {
@@ -111,16 +103,6 @@ class SmoothContext {
}
}
-function zoom(data: T[], reduction: number): T[] {
- let i = 0
- const newData: T[] = []
- while (i < data.length) {
- newData.push(data[i])
- i += reduction
- }
- return newData
-}
-
function render2Svg(chartData: ChartData): string {
const { xAxis, yAxises } = chartData
const chart: EChartsType = init(null, null, {
@@ -175,10 +157,15 @@ const USER_COUNT_GIST_DESC = "User count of timer, auto-generated"
const USER_COUNT_SVG_FILE_NAME = "user_count.svg"
async function getOriginData(token: string): Promise {
- const [firefox, edge, chrome]: UserCount[] = await Promise.all(
- ALL_BROWSERS.map(b => getDataFromGist(token, b))
- )
- return { chrome, firefox, edge }
+ const result: OriginData = {
+ chrome: {},
+ firefox: {},
+ edge: {},
+ }
+ for (const b of ALL_BROWSERS) {
+ result[b] = await getDataFromGist(token, b)
+ }
+ return result
}
/**
diff --git a/script/user-chart/user-chart.d.ts b/script/user-chart/user-chart.d.ts
deleted file mode 100644
index 56d0300c8..000000000
--- a/script/user-chart/user-chart.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-type Browser =
- | 'chrome'
- | 'firefox'
- | 'edge'
-
-type UserCount = Record
\ No newline at end of file
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 ffe1622c3..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,58 +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(() => reject(new Error('Message timeout: no response from runtime')), 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/sidePanel.ts b/src/api/chrome/sidePanel.ts
new file mode 100644
index 000000000..74cf7961f
--- /dev/null
+++ b/src/api/chrome/sidePanel.ts
@@ -0,0 +1,33 @@
+import { IS_ANDROID, IS_MV3 } from '@util/constant/environment'
+import { handleError } from './common'
+
+// NOT SUPPORTED in Firefox
+// Keep noticing at chrome.sidebarAction for Firefox
+export const SIDE_PANEL_STATE_SUPPORTED_CONTROL = !!chrome.sidePanel?.setOptions
+
+export async function isSidePanelEnabled(): Promise {
+ if (IS_ANDROID || !SIDE_PANEL_STATE_SUPPORTED_CONTROL) return false
+
+ if (IS_MV3) {
+ const result = await chrome.sidePanel.getOptions({})
+ return result.enabled ?? true
+ } else {
+ return new Promise(resolve => chrome.sidePanel.getOptions({}, options => {
+ handleError('isSidePanelEnabled')
+ resolve(options.enabled ?? true)
+ }))
+ }
+}
+
+export async function setSidePanelEnabled(enabled: boolean): Promise {
+ if (IS_ANDROID || !SIDE_PANEL_STATE_SUPPORTED_CONTROL) return
+
+ if (IS_MV3) {
+ await chrome.sidePanel.setOptions({ enabled })
+ } else {
+ return new Promise(resolve => chrome.sidePanel.setOptions({ enabled }, () => {
+ handleError('setSidePanelEnabled')
+ resolve()
+ }))
+ }
+}
\ No newline at end of file
diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts
index eb145cb0a..79c9e6b27 100644
--- a/src/api/chrome/tab.ts
+++ b/src/api/chrome/tab.ts
@@ -5,35 +5,20 @@
* https://opensource.org/licenses/MIT
*/
+import { IS_MV3 } from '@util/constant/environment'
import { handleError } from "./common"
-export function getTab(id: number): Promise {
+export function getTab(id: number): Promise {
+ if (id < 0) {
+ return Promise.resolve(undefined)
+ }
return new Promise(resolve => chrome.tabs.get(id, tab => {
handleError("getTab")
resolve(tab)
}))
}
-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)
@@ -78,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)
@@ -119,4 +110,16 @@ export function onTabUpdated(handler: TabHandler): void {
handleError("tabUpdated")
handler(tabId, changeInfo, tab)
})
+}
+
+export function updateTab(tabId: number, updateProperties: chrome.tabs.UpdateProperties): Promise {
+ if (IS_MV3) {
+ return chrome.tabs.update(tabId, updateProperties)
+ }
+ return new Promise((resolve) => {
+ chrome.tabs.update(tabId, updateProperties, (tab) => {
+ handleError("updateTab")
+ resolve(tab)
+ })
+ })
}
\ No newline at end of file
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 52cc97516..3b669d6b9 100644
--- a/src/api/chrome/window.ts
+++ b/src/api/chrome/window.ts
@@ -1,89 +1,37 @@
-import { IS_ANDROID } 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) {
- 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 fb292b017..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 siteService 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 siteService.get(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