commit bdc3d0d512d95db36e6113f5b019e1569924278e Author: johnathan <952508490@qq.com> Date: Sat Apr 12 17:26:36 2025 +0800 Initial Repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..453b76d --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +.idea/ +.vite-ssg-dist +.vite-ssg-temp +*.crx +*.local +*.log +*.pem +*.xpi +*.zip +dist +dist-ssr +extension/manifest.json +node_modules +src/auto-imports.d.ts +src/components.d.ts +.eslintcache diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..5ee7abd --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..009aa06 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +shamefully-hoist=true +auto-install-peers=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1b8ac88 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..544138b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..96f65d5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "vue.volar" + // "antfu.iconify", + // "antfu.unocss", + // "dbaeumer.vscode-eslint", + // "csstools.postcss" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fb068a7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "cSpell.words": ["Vitesse"], + "typescript.tsdk": "node_modules/typescript/lib", + "vite.autoStart": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "files.associations": { + "*.css": "postcss" + }, + "prettier.tabWidth": 2 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9b031a2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README_Template.md b/README_Template.md new file mode 100644 index 0000000..ba226cf --- /dev/null +++ b/README_Template.md @@ -0,0 +1,138 @@ +# WebExtension Vite Starter + +A [Vite](https://vitejs.dev/) powered WebExtension ([Chrome](https://developer.chrome.com/docs/extensions/reference/), [FireFox](https://addons.mozilla.org/en-US/developers/), etc.) starter template. + +

+Popup
+
+Options Page
+
+Inject Vue App into the Content Script
+ +

+ +## Features + +- ⚡️ **Instant HMR** - use **Vite** on dev (no more refresh!) +- 🥝 Vue 3 - Composition API, [` + + + + diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..bd19ad3 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,14 @@ +const forbiddenProtocols = [ + 'chrome-extension://', + 'chrome-search://', + 'chrome://', + 'devtools://', + 'edge://', + 'https://chrome.google.com/webstore', +]; + +export function isForbiddenUrl(url: string): boolean { + return forbiddenProtocols.some((protocol) => url.startsWith(protocol)); +} + +export const isFirefox = navigator.userAgent.includes('Firefox'); diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..e3a1e02 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,10 @@ +/// + +declare const __DEV__: boolean; +/** Extension name, defined in packageJson.name */ +declare const __NAME__: string; + +declare module '*.vue' { + const component: any; + export default component; +} diff --git a/src/logic/common-setup.ts b/src/logic/common-setup.ts new file mode 100644 index 0000000..7d98d66 --- /dev/null +++ b/src/logic/common-setup.ts @@ -0,0 +1,15 @@ +import type { App } from 'vue'; + +export function setupApp(app: App) { + // Inject a globally available `$app` object in template + app.config.globalProperties.$app = { + context: '', + }; + + // Provide access to `app` in script setup with `const app = inject('app')` + app.provide('app', app.config.globalProperties.$app); + + // Here you can install additional plugins for all contexts: popup, options page and content-script. + // example: app.use(i18n) + // example excluding content-script context: if (context !== 'content-script') app.use(i18n) +} diff --git a/src/logic/index.ts b/src/logic/index.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/logic/index.ts @@ -0,0 +1 @@ + diff --git a/src/logic/page-worker/index.ts b/src/logic/page-worker/index.ts new file mode 100644 index 0000000..b4a4c63 --- /dev/null +++ b/src/logic/page-worker/index.ts @@ -0,0 +1,64 @@ +class AmazonPageWorkerImpl implements AmazonPageWorker { + public async doSearch(keywords: string): Promise { + const url = new URL('https://www.amazon.com/s'); + url.searchParams.append('k', keywords); + + const tab = await browser.tabs + .query({ active: true, currentWindow: true }) + .then((tabs) => tabs[0]); + const currentUrl = new URL(tab.url!); + if ( + currentUrl.hostname !== url.hostname || + currentUrl.searchParams.get('k') !== keywords + ) { + await browser.tabs.update(tab.id, { url: url.toString() }); + } + return url.toString(); + } + + public async wanderSearchList(): Promise { + const tab = await browser.tabs + .query({ active: true, currentWindow: true }) + .then((tabs) => tabs[0]); + const results = await browser.scripting.executeScript({ + target: { tabId: tab.id! }, + func: async () => { + try { + await new Promise((resolve) => + setTimeout(resolve, 500 + ~~(500 * Math.random())), + ); + while (!document.querySelector('.s-pagination-strip')) { + window.scrollBy(0, ~~(Math.random() * 500) + 500); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const nextButton = + document.querySelector('.s-pagination-next'); + if ( + nextButton && + !nextButton.classList.contains('s-pagination-disabled') + ) { + await new Promise((resolve) => + setTimeout(resolve, 500 + ~~(500 * Math.random())), + ); + nextButton.click(); + } + return true; + } catch (e) { + return false; + } + }, + }); + console.log('results', results); + return new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +class PageWorkerFactory { + public createAmazonPageWorker(): AmazonPageWorker { + return new AmazonPageWorkerImpl(); + } +} + +const pageWorkerFactory = new PageWorkerFactory(); + +export default pageWorkerFactory; diff --git a/src/logic/page-worker/types.d.ts b/src/logic/page-worker/types.d.ts new file mode 100644 index 0000000..4741766 --- /dev/null +++ b/src/logic/page-worker/types.d.ts @@ -0,0 +1,14 @@ +interface AmazonPageWorker { + /** + * Search for a list of items on Amazon + * @param keywords - The keywords to search for on Amazon. + * @returns A promise that resolves to a string representing the search URL. + */ + doSearch(keywords: string): Promise; + + /** + * Browsing item search page and collect links to those items. + * @param entryUrl - The URL of the Amazon search page to start from. + */ + wanderSearchList(): Promise; +} diff --git a/src/logic/storage.ts b/src/logic/storage.ts new file mode 100644 index 0000000..84b4e5b --- /dev/null +++ b/src/logic/storage.ts @@ -0,0 +1,3 @@ +import { useWebExtensionStorage } from '~/composables/useWebExtensionStorage'; + +export const keywords = useWebExtensionStorage('keywords', ''); \ No newline at end of file diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..e626f87 --- /dev/null +++ b/src/manifest.ts @@ -0,0 +1,81 @@ +import fs from 'fs-extra'; +import type { Manifest } from 'webextension-polyfill'; +import type PkgType from '../package.json'; +import { isDev, isFirefox, port, r } from '../scripts/utils'; + +export async function getManifest() { + const pkg = (await fs.readJSON(r('package.json'))) as typeof PkgType; + + // update this file to update this manifest.json + // can also be conditional based on your need + const manifest: Manifest.WebExtensionManifest = { + manifest_version: 3, + name: pkg.displayName || pkg.name, + version: pkg.version, + description: pkg.description, + action: { + default_icon: './assets/icon-512.png', + default_popup: './dist/popup/index.html', + }, + options_ui: { + page: './dist/options/index.html', + open_in_tab: true, + }, + background: isFirefox + ? { + scripts: ['dist/background/index.mjs'], + type: 'module', + } + : { + service_worker: './dist/background/index.mjs', + }, + icons: { + 16: './assets/icon-512.png', + 48: './assets/icon-512.png', + 128: './assets/icon-512.png', + }, + permissions: ['tabs', 'storage', 'activeTab', 'sidePanel', 'scripting'], + host_permissions: ['*://*/*'], + content_scripts: [ + { + matches: [''], + js: ['dist/contentScripts/index.global.js'], + }, + ], + web_accessible_resources: [ + { + resources: ['dist/contentScripts/index.css'], + matches: [''], + }, + ], + content_security_policy: { + extension_pages: isDev + ? // this is required on dev for Vite script to load + `script-src \'self\' http://localhost:${port}; object-src \'self\'` + : "script-src 'self'; object-src 'self'", + }, + }; + + // add sidepanel + if (isFirefox) { + manifest.sidebar_action = { + default_panel: 'dist/sidepanel/index.html', + }; + } else { + // the sidebar_action does not work for chromium based + (manifest as any).side_panel = { + default_path: 'dist/sidepanel/index.html', + }; + } + + // FIXME: not work in MV3 + if (isDev && false) { + // for content script, as browsers will cache them for each reload, + // we use a background script to always inject the latest version + // see src/background/contentScriptHMR.ts + delete manifest.content_scripts; + manifest.permissions?.push('webNavigation'); + } + + return manifest; +} diff --git a/src/options/Options.vue b/src/options/Options.vue new file mode 100644 index 0000000..29b5196 --- /dev/null +++ b/src/options/Options.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/options/index.html b/src/options/index.html new file mode 100644 index 0000000..72ab198 --- /dev/null +++ b/src/options/index.html @@ -0,0 +1,12 @@ + + + + + + Options + + +
+ + + diff --git a/src/options/main.ts b/src/options/main.ts new file mode 100644 index 0000000..a1aa667 --- /dev/null +++ b/src/options/main.ts @@ -0,0 +1,8 @@ +import { createApp } from 'vue'; +import App from './Options.vue'; +import { setupApp } from '~/logic/common-setup'; +import '../styles'; + +const app = createApp(App); +setupApp(app); +app.mount('#app'); diff --git a/src/popup/Popup.vue b/src/popup/Popup.vue new file mode 100644 index 0000000..1614abf --- /dev/null +++ b/src/popup/Popup.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/popup/index.html b/src/popup/index.html new file mode 100644 index 0000000..28ab624 --- /dev/null +++ b/src/popup/index.html @@ -0,0 +1,12 @@ + + + + + + Popup + + +
+ + + diff --git a/src/popup/main.ts b/src/popup/main.ts new file mode 100644 index 0000000..a9d6ea0 --- /dev/null +++ b/src/popup/main.ts @@ -0,0 +1,7 @@ +import App from './Popup.vue'; +import { setupApp } from '~/logic/common-setup'; +import '../styles'; + +const app = createApp(App); +setupApp(app); +app.mount('#app'); diff --git a/src/sidepanel/Sidepanel.vue b/src/sidepanel/Sidepanel.vue new file mode 100644 index 0000000..0bcddab --- /dev/null +++ b/src/sidepanel/Sidepanel.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/sidepanel/index.html b/src/sidepanel/index.html new file mode 100644 index 0000000..616df1f --- /dev/null +++ b/src/sidepanel/index.html @@ -0,0 +1,12 @@ + + + + + + Sidepanel + + +
+ + + diff --git a/src/sidepanel/main.ts b/src/sidepanel/main.ts new file mode 100644 index 0000000..7afa3b2 --- /dev/null +++ b/src/sidepanel/main.ts @@ -0,0 +1,7 @@ +import App from './Sidepanel.vue'; +import { setupApp } from '~/logic/common-setup'; +import '../styles'; + +const app = createApp(App); +setupApp(app); +app.mount('#app'); diff --git a/src/styles/index.ts b/src/styles/index.ts new file mode 100644 index 0000000..74f07de --- /dev/null +++ b/src/styles/index.ts @@ -0,0 +1 @@ +import './main.css'; diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 0000000..716ffbb --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,20 @@ +html, +body, +#app { + margin: 0; + padding: 0; +} + +.btn { + @apply px-4 py-1 rounded inline-block + bg-teal-600 text-white cursor-pointer + hover:bg-teal-700 + disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50; +} + +.icon-btn { + @apply inline-block cursor-pointer select-none + opacity-75 transition duration-200 ease-in-out + hover:opacity-100 hover:text-teal-600; + font-size: 0.9em; +} diff --git a/src/tests/demo.spec.ts b/src/tests/demo.spec.ts new file mode 100644 index 0000000..06c8725 --- /dev/null +++ b/src/tests/demo.spec.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; + +describe('demo', () => { + it('should work', () => { + expect(1 + 1).toBe(2); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4ed786c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "incremental": false, + "target": "es2016", + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "node", + "paths": { + "~/*": ["src/*"] + }, + "resolveJsonModule": true, + "types": ["vite/client"], + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "exclude": ["dist", "node_modules"] +} diff --git a/vite.config.background.mts b/vite.config.background.mts new file mode 100644 index 0000000..502f1df --- /dev/null +++ b/vite.config.background.mts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vite'; +import { sharedConfig } from './vite.config.mjs'; +import { isDev, r } from './scripts/utils'; +import packageJson from './package.json'; + +// bundling the content script using Vite +export default defineConfig({ + ...sharedConfig, + define: { + __DEV__: isDev, + __NAME__: JSON.stringify(packageJson.name), + // https://github.com/vitejs/vite/issues/9320 + // https://github.com/vitejs/vite/issues/9186 + 'process.env.NODE_ENV': JSON.stringify( + isDev ? 'development' : 'production', + ), + }, + build: { + watch: isDev ? {} : undefined, + outDir: r('extension/dist/background'), + cssCodeSplit: false, + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + lib: { + entry: r('src/background/main.ts'), + name: packageJson.name, + formats: ['iife'], + }, + rollupOptions: { + output: { + entryFileNames: 'index.mjs', + extend: true, + }, + }, + }, +}); diff --git a/vite.config.content.mts b/vite.config.content.mts new file mode 100644 index 0000000..19f3aac --- /dev/null +++ b/vite.config.content.mts @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite'; +import { sharedConfig } from './vite.config.mjs'; +import { isDev, r } from './scripts/utils'; +import packageJson from './package.json'; + +// bundling the content script using Vite +export default defineConfig({ + ...sharedConfig, + define: { + __DEV__: isDev, + __NAME__: JSON.stringify(packageJson.name), + // https://github.com/vitejs/vite/issues/9320 + // https://github.com/vitejs/vite/issues/9186 + 'process.env.NODE_ENV': JSON.stringify( + isDev ? 'development' : 'production', + ), + }, + build: { + watch: isDev ? {} : undefined, + outDir: r('extension/dist/contentScripts'), + cssCodeSplit: false, + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + lib: { + entry: r('src/contentScripts/index.ts'), + name: packageJson.name, + formats: ['iife'], + cssFileName: 'index', + }, + rollupOptions: { + output: { + entryFileNames: 'index.global.js', + extend: true, + }, + }, + }, +}); diff --git a/vite.config.mts b/vite.config.mts new file mode 100644 index 0000000..c561b83 --- /dev/null +++ b/vite.config.mts @@ -0,0 +1,114 @@ +/// + +import { dirname, relative } from 'node:path'; +import type { UserConfig } from 'vite'; +import { defineConfig } from 'vite'; +import Vue from '@vitejs/plugin-vue'; +import Icons from 'unplugin-icons/vite'; +import IconsResolver from 'unplugin-icons/resolver'; +import Components from 'unplugin-vue-components/vite'; +import AutoImport from 'unplugin-auto-import/vite'; +import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'; + +import { isDev, port, r } from './scripts/utils'; +import packageJson from './package.json'; + +export const sharedConfig: UserConfig = { + root: r('src'), + resolve: { + alias: { + '~/': `${r('src')}/`, + }, + }, + define: { + __DEV__: isDev, + __NAME__: JSON.stringify(packageJson.name), + }, + plugins: [ + Vue(), + AutoImport({ + imports: [ + 'vue', + { + 'webextension-polyfill': [['=', 'browser']], + }, + { + 'naive-ui': [ + 'useDialog', + 'useMessage', + 'useNotification', + 'useLoadingBar', + ], + }, + ], + dts: r('src/auto-imports.d.ts'), + }), + + // https://github.com/antfu/unplugin-vue-components + Components({ + dirs: [r('src/components')], + // generate `components.d.ts` for ts support with Volar + dts: r('src/components.d.ts'), + resolvers: [ + // auto import icons + IconsResolver({ + prefix: '', + }), + NaiveUiResolver(), + ], + }), + + // https://github.com/antfu/unplugin-icons + Icons(), + + // rewrite assets to use relative path + { + name: 'assets-rewrite', + enforce: 'post', + apply: 'build', + transformIndexHtml(html, { path }) { + return html.replace( + /"\/assets\//g, + `"${relative(dirname(path), '/assets')}/`, + ); + }, + }, + ], + optimizeDeps: { + include: ['vue', '@vueuse/core', 'webextension-polyfill'], + exclude: ['vue-demi'], + }, +}; + +export default defineConfig(({ command }) => ({ + ...sharedConfig, + base: command === 'serve' ? `http://localhost:${port}/` : '/dist/', + server: { + port, + hmr: { + host: 'localhost', + }, + origin: `http://localhost:${port}`, + }, + build: { + watch: isDev ? {} : undefined, + outDir: r('extension/dist'), + emptyOutDir: false, + sourcemap: isDev ? 'inline' : false, + // https://developer.chrome.com/docs/webstore/program_policies/#:~:text=Code%20Readability%20Requirements + terserOptions: { + mangle: false, + }, + rollupOptions: { + input: { + options: r('src/options/index.html'), + popup: r('src/popup/index.html'), + sidepanel: r('src/sidepanel/index.html'), + }, + }, + }, + test: { + globals: true, + environment: 'jsdom', + }, +}));