├── .cursorrules
├── .eslintrc.json
├── .gitignore
├── .nvmrc
├── .prettierrc
├── LICENSE
├── PRIVACY.md
├── README.md
├── eslint.config.js
├── package.json
├── public
├── icons
│ └── icon.svg
└── manifest.json
├── rules
└── rules.json
├── scripts
└── build.ts
├── src
├── assets
│ └── react.svg
├── background
│ ├── constants.ts
│ ├── index.ts
│ └── utils.ts
├── components
│ ├── Toast.tsx
│ └── fileIcon.tsx
├── pages
│ ├── desktop
│ │ ├── App.tsx
│ │ ├── index.html
│ │ └── index.tsx
│ └── settings
│ │ ├── App.tsx
│ │ ├── index.html
│ │ └── index.tsx
├── popup
│ ├── App.tsx
│ ├── components
│ │ ├── CrxFileTree.tsx
│ │ ├── LoadingScreen.tsx
│ │ └── SourceMapTable.tsx
│ ├── index.html
│ └── index.tsx
├── storage
│ └── database.ts
├── theme
│ └── index.ts
├── types
│ ├── chrome.d.ts
│ ├── env.d.ts
│ └── index.ts
└── utils
│ ├── browser-polyfill.ts
│ ├── crx-to-zip.ts
│ ├── desktopApp.ts
│ ├── format.ts
│ ├── isExtensionPage.ts
│ ├── parseCrxFile.ts
│ ├── sourceMapDownloader.ts
│ └── sourceMapUtils.ts
├── tsconfig.json
└── yarn.lock
/.cursorrules:
--------------------------------------------------------------------------------
1 | You are an expert in Chrome Extension Development, JavaScript, TypeScript, HTML, CSS, Shadcn UI, Radix UI, Tailwind and Web APIs.
2 |
3 | Code Style and Structure:
4 |
5 | - Write concise, technical JavaScript/TypeScript code with accurate examples
6 | - Use modern JavaScript features and best practices
7 | - Prefer functional programming patterns; minimize use of classes
8 | - Use descriptive variable names (e.g., isExtensionEnabled, hasPermission)
9 | - Structure files: manifest.json, background scripts, content scripts, popup scripts, options page
10 |
11 | Naming Conventions:
12 |
13 | - Use lowercase with underscores for file names (e.g., content_script.js, background_worker.js)
14 | - Use camelCase for function and variable names
15 | - Use PascalCase for class names (if used)
16 |
17 | TypeScript Usage:
18 |
19 | - Encourage TypeScript for type safety and better developer experience
20 | - Use interfaces for defining message structures and API responses
21 | - Leverage TypeScript's union types and type guards for runtime checks
22 |
23 | Extension Architecture:
24 |
25 | - Implement a clear separation of concerns between different extension components
26 | - Use message passing for communication between different parts of the extension
27 | - Implement proper state management using chrome.storage API
28 |
29 | Manifest and Permissions:
30 |
31 | - Use the latest manifest version (v3) unless there's a specific need for v2
32 | - Follow the principle of least privilege for permissions
33 | - Implement optional permissions where possible
34 |
35 | Security and Privacy:
36 |
37 | - Implement Content Security Policy (CSP) in manifest.json
38 | - Use HTTPS for all network requests
39 | - Sanitize user inputs and validate data from external sources
40 | - Implement proper error handling and logging
41 |
42 | UI and Styling:
43 |
44 | - Create responsive designs for popup and options pages
45 | - Use CSS Grid or Flexbox for layouts
46 | - Implement consistent styling across all extension UI elements
47 |
48 | Performance Optimization:
49 |
50 | - Minimize resource usage in background scripts
51 | - Use event pages instead of persistent background pages when possible
52 | - Implement lazy loading for non-critical extension features
53 | - Optimize content scripts to minimize impact on web page performance
54 |
55 | Browser API Usage:
56 |
57 | - Utilize chrome.* APIs effectively (e.g., chrome.tabs, chrome.storage, chrome.runtime)
58 | - Implement proper error handling for all API calls
59 | - Use chrome.alarms for scheduling tasks instead of setInterval
60 |
61 | Cross-browser Compatibility:
62 |
63 | - Use WebExtensions API for cross-browser support where possible
64 | - Implement graceful degradation for browser-specific features
65 |
66 | Testing and Debugging:
67 |
68 | - Utilize Chrome DevTools for debugging
69 | - Implement unit tests for core extension functionality
70 | - Use Chrome's built-in extension loading for testing during development
71 |
72 | Context-Aware Development:
73 |
74 | - Always consider the whole project context when providing suggestions or generating code
75 | - Avoid duplicating existing functionality or creating conflicting implementations
76 | - Ensure that new code integrates seamlessly with the existing project structure and architecture
77 | - Before adding new features or modifying existing ones, review the current project state to maintain consistency and avoid redundancy
78 | - When answering questions or providing solutions, take into account previously discussed or implemented features to prevent contradictions or repetitions
79 |
80 | Code Output:
81 |
82 | - When providing code, always output the entire file content, not just new or modified parts
83 | - Include all necessary imports, declarations, and surrounding code to ensure the file is complete and functional
84 | - Provide comments or explanations for significant changes or additions within the file
85 | - If the file is too large to reasonably include in full, provide the most relevant complete section and clearly indicate where it fits in the larger file structure
86 |
87 | Follow Chrome Extension documentation for best practices, security guidelines, and API usage
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "es2020": true,
6 | "webextensions": true
7 | },
8 | "extends": [
9 | "eslint:recommended",
10 | "plugin:@typescript-eslint/recommended",
11 | "plugin:react-hooks/recommended"
12 | ],
13 | "ignorePatterns": [
14 | "dist",
15 | ".eslintrc.json"
16 | ],
17 | "parser": "@typescript-eslint/parser",
18 | "plugins": [
19 | "react-refresh",
20 | "@typescript-eslint"
21 | ],
22 | "rules": {
23 | "react-refresh/only-export-components": [
24 | "warn",
25 | {
26 | "allowConstantExport": true
27 | }
28 | ],
29 | "@typescript-eslint/no-explicit-any": "warn",
30 | "@typescript-eslint/no-unused-vars": [
31 | "warn",
32 | {
33 | "argsIgnorePattern": "^_"
34 | }
35 | ],
36 | "no-console": [
37 | "warn",
38 | {
39 | "allow": [
40 | "warn",
41 | "error"
42 | ]
43 | }
44 | ]
45 | },
46 | "settings": {
47 | "react": {
48 | "version": "detect"
49 | }
50 | }
51 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # TypeScript
27 | *.tsbuildinfo
28 | sample-projects/*
29 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "tabWidth": 2,
4 | "printWidth": 100,
5 | "singleQuote": true,
6 | "trailingComma": "es5",
7 | "bracketSpacing": true,
8 | "bracketSameLine": false,
9 | "arrowParens": "avoid",
10 | "endOfLine": "lf"
11 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Source Detector
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy for Source Detector
2 |
3 | Last updated: March 2024
4 |
5 | ## Overview
6 | Source Detector is committed to protecting your privacy. This privacy policy explains how our browser extension handles data collection, storage, and usage.
7 |
8 | ## Data Collection and Storage
9 |
10 | ### What We Collect
11 | - Source map files detected from websites you visit
12 | - Chrome extension (CRX) files you choose to analyze
13 | - Extension settings and preferences
14 | - File metadata (size, type, origin URL)
15 |
16 | ### How Data is Stored
17 | - All data is stored locally on your device
18 | - No data is transmitted to external servers
19 | - No personal information is collected
20 | - No browsing history is tracked
21 |
22 | ### Storage Duration
23 | - Data remains stored until you choose to delete it
24 | - You can clear all stored data through the extension settings
25 |
26 | ## Data Usage
27 |
28 | ### How We Use the Data
29 | - To display source maps and CRX files for analysis
30 | - To maintain a history of detected files
31 | - To organize files by website
32 | - To provide offline access to saved files
33 |
34 | ### Data Sharing
35 | - We do not share any data with third parties
36 | - We do not collect or transmit data to external servers
37 | - All operations are performed locally on your device
38 |
39 | ## Permissions
40 |
41 | ### Why We Need Permissions
42 | - Storage: To save detected files and settings locally
43 | - ActiveTab: To detect files on the current webpage
44 | - Downloads: To save files to your computer
45 | - WebRequest: To detect source map references
46 | - Host Permissions: To access source files from their original locations
47 |
48 | ## User Control
49 |
50 | ### Your Rights
51 | - View all stored data through the extension interface
52 | - Clear all data with one click
53 |
54 | ### Data Security
55 | - All operations are performed locally
56 | - No external network connections are made
57 | - No authentication or account required
58 |
59 | ## Changes to This Policy
60 | We may update this privacy policy from time to time. We will notify users of any material changes through the extension's update notes.
61 |
62 | ## Contact
63 | If you have questions about this privacy policy, please create an issue in our GitHub repository.
64 |
65 | ## Consent
66 | By using Source Detector, you consent to this privacy policy.
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Source Detector
2 |
3 | Source Detector is a Chrome extension that automatically collects and views source maps from websites.
4 |
5 | ## How to local development
6 |
7 | 1. Clone the repository
8 | 2. Install dependencies
9 | 3. Run `npm run dev`
10 | 4. Open Chrome and install the extension from the `dist` folder
11 | 1. Open `chrome://extensions`
12 | 2. Enable "Developer mode"
13 | 3. Click "Load unpacked"
14 | 4. Select the `dist` folder
15 |
16 | ## Functions
17 |
18 | - Displays a badge with the number when detected source files
19 | - Collects source map files from a website
20 | - .map files
21 | - Collects CRX files from a extension website or a extension page
22 | - Download source map files and parsed files
23 | - Download CRX files and parsed files
24 | - Show View All Pages in desktop app
25 | - Show history source map files in desktop app
26 | - Show source map files from x domains in desktop app
27 | - Show history CRX files in desktop app
28 | - Show CRX files from x domains in desktop app
29 |
30 | ## TODO
31 | - [ ] i18n
32 | - [ ] inline source map files
33 | - [ ] UI improvements
34 | - [ ] Open in desktop app
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 |
7 | export default tseslint.config(
8 | { ignores: ['dist'] },
9 | {
10 | extends: [js.configs.recommended, ...tseslint.configs.recommended],
11 | files: ['**/*.{ts,tsx}'],
12 | languageOptions: {
13 | ecmaVersion: 2020,
14 | globals: globals.browser,
15 | },
16 | plugins: {
17 | 'react-hooks': reactHooks,
18 | 'react-refresh': reactRefresh,
19 | },
20 | rules: {
21 | ...reactHooks.configs.recommended.rules,
22 | 'react-refresh/only-export-components': [
23 | 'warn',
24 | { allowConstantExport: true },
25 | ],
26 | },
27 | },
28 | )
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "source-detector",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "node --watch --import tsx scripts/build.ts --watch --browser=chrome",
8 | "dev:chrome": "node --watch --import tsx scripts/build.ts --watch --browser=chrome",
9 | "dev:firefox": "node --watch --import tsx scripts/build.ts --watch --browser=firefox",
10 | "build": "tsx scripts/build.ts --browser=chrome",
11 | "build:chrome": "tsx scripts/build.ts --browser=chrome",
12 | "build:firefox": "tsx scripts/build.ts --browser=firefox",
13 | "build:all": "npm run build:chrome && npm run build:firefox",
14 | "clean": "rimraf dist"
15 | },
16 | "dependencies": {
17 | "@emotion/react": "^11.13.5",
18 | "@emotion/styled": "^11.13.5",
19 | "@mui/icons-material": "^6.1.10",
20 | "@mui/lab": "^6.0.0-beta.18",
21 | "@mui/material": "^6.1.10",
22 | "@mui/x-tree-view": "^7.23.2",
23 | "@types/crypto-js": "^4.2.2",
24 | "@types/webextension-polyfill": "^0.12.1",
25 | "crypto-js": "^4.2.0",
26 | "dexie": "^4.0.1",
27 | "jszip": "^3.10.1",
28 | "react": "^18.2.0",
29 | "react-dom": "^18.2.0",
30 | "react-router-dom": "^7.0.2",
31 | "source-map-js": "^1.2.1",
32 | "webextension-polyfill": "^0.10.0"
33 | },
34 | "devDependencies": {
35 | "@eslint/js": "^9.15.0",
36 | "@types/archiver": "^6.0.3",
37 | "@types/chrome": "^0.0.287",
38 | "@types/fs-extra": "^11.0.4",
39 | "@types/node": "^22.10.1",
40 | "@types/react": "^18.3.14",
41 | "@types/react-dom": "^18.3.2",
42 | "@typescript-eslint/eslint-plugin": "^8.17.0",
43 | "@typescript-eslint/parser": "^8.17.0",
44 | "archiver": "^5.3.1",
45 | "esbuild": "^0.20.1",
46 | "eslint": "^9.16.0",
47 | "eslint-plugin-react-hooks": "^5.0.0",
48 | "eslint-plugin-react-refresh": "^0.4.14",
49 | "fs-extra": "^11.2.0",
50 | "globals": "^15.12.0",
51 | "prettier": "^3.4.2",
52 | "rimraf": "^5.0.5",
53 | "sharp": "^0.33.5",
54 | "tsx": "^4.7.1",
55 | "typescript": "^5.3.3",
56 | "typescript-eslint": "^8.15.0"
57 | },
58 | "description": "Chrome extension for automatically collecting and viewing source maps",
59 | "main": "eslint.config.js",
60 | "keywords": [
61 | "chrome-extension",
62 | "sourcemap",
63 | "developer-tools"
64 | ],
65 | "author": "",
66 | "license": "MIT"
67 | }
--------------------------------------------------------------------------------
/public/icons/icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "Source Detector",
4 | "version": "1.0.0",
5 | "description": "A powerful tool for developers to automatically detect, collect, and analyze source maps and CRX files from websites",
6 | "permissions": [
7 | "tabs",
8 | "webRequest",
9 | "declarativeNetRequest"
10 | ],
11 | "host_permissions": [
12 | ""
13 | ],
14 | "declarative_net_request": {
15 | "rule_resources": [
16 | {
17 | "id": "ruleset_1",
18 | "enabled": true,
19 | "path": "rules/rules.json"
20 | }
21 | ]
22 | },
23 | "background": {
24 | "service_worker": "background/index.js",
25 | "type": "module"
26 | },
27 | "action": {
28 | "default_popup": "popup/index.html",
29 | "default_icon": {
30 | "16": "icons/icon-16.png",
31 | "48": "icons/icon-48.png",
32 | "128": "icons/icon-128.png"
33 | }
34 | },
35 | "icons": {
36 | "16": "icons/icon-16.png",
37 | "48": "icons/icon-48.png",
38 | "128": "icons/icon-128.png"
39 | },
40 | "options_page": "pages/settings/index.html",
41 | "web_accessible_resources": [
42 | {
43 | "resources": [
44 | "js/*.js",
45 | "js/**/*.js",
46 | "js/**/*.js.map",
47 | "assets/*",
48 | "assets/**/*",
49 | "pages/*",
50 | "pages/**/*",
51 | "vs/*",
52 | "vs/**/*"
53 | ],
54 | "matches": [
55 | ""
56 | ]
57 | }
58 | ],
59 | "content_security_policy": {
60 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
61 | "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; worker-src 'self'"
62 | },
63 | "minimum_chrome_version": "88"
64 | }
--------------------------------------------------------------------------------
/rules/rules.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "priority": 1,
5 | "action": {
6 | "type": "modifyHeaders",
7 | "responseHeaders": [
8 | {
9 | "header": "Content-Security-Policy",
10 | "operation": "remove"
11 | }
12 | ]
13 | },
14 | "condition": {
15 | "urlFilter": "*",
16 | "resourceTypes": ["script", "stylesheet"]
17 | }
18 | }
19 | ]
--------------------------------------------------------------------------------
/scripts/build.ts:
--------------------------------------------------------------------------------
1 | import * as esbuild from 'esbuild';
2 | import fs from 'fs-extra';
3 | import { resolve } from 'path';
4 | import sharp from 'sharp';
5 | import archiver from 'archiver';
6 | import { ArchiverError } from 'archiver';
7 |
8 | const BUILD_DIR = 'dist';
9 | const SRC_DIR = 'src';
10 | const RULES_DIR = 'rules'; // Add this line
11 |
12 | function formatDuration(duration: number): string {
13 | if (duration < 1000) {
14 | return `${duration}ms`;
15 | }
16 | return `${(duration / 1000).toFixed(2)}s`;
17 | }
18 |
19 | async function convertIcons() {
20 | const sizes = [16, 48, 128];
21 | const sourceIcon = resolve('public/icons/icon.svg');
22 | const targetDir = resolve(BUILD_DIR, 'icons');
23 |
24 | // Ensure icons directory exists
25 | await fs.ensureDir(targetDir);
26 |
27 | // Convert icons to all sizes
28 | await Promise.all(sizes.map(size =>
29 | sharp(sourceIcon)
30 | .resize(size, size)
31 | .toFile(resolve(targetDir, `icon-${size}.png`))
32 | ));
33 |
34 | console.log('📦 Generated icon files in different sizes');
35 | }
36 |
37 | async function build(isWatch = false, browser = 'chrome') {
38 | const startTime = Date.now();
39 | console.log(`🚀 Building extension for ${browser}...`);
40 |
41 | // Add Firefox-specific manifest modifications
42 | if (browser === 'firefox') {
43 | const manifestContent = JSON.parse(
44 | await fs.readFile(resolve('public', 'manifest.json'), 'utf-8')
45 | );
46 |
47 | // Firefox-specific changes
48 | manifestContent.browser_specific_settings = {
49 | "gecko": {
50 | "id": "wavesun.org@gmail.com",
51 | "strict_min_version": "109.0"
52 | }
53 | };
54 |
55 | // Firefox uses different background script format
56 | manifestContent.background = {
57 | "scripts": ["background/index.js"],
58 | "type": "module"
59 | };
60 |
61 | console.log(manifestContent.background);
62 |
63 | // Remove service_worker if it exists
64 | if (manifestContent.background?.service_worker) {
65 | delete manifestContent.background.service_worker;
66 | }
67 | console.log(manifestContent.background);
68 |
69 | await fs.writeFile(
70 | resolve(BUILD_DIR, 'manifest.json'),
71 | JSON.stringify(manifestContent, null, 2),
72 | 'utf-8'
73 | );
74 | }
75 |
76 | // Add icon conversion before other build steps
77 | await convertIcons();
78 |
79 | // Copy rules directory to dist
80 | await fs.copy(RULES_DIR, resolve(BUILD_DIR, RULES_DIR));
81 | console.log(`📦 Copied ${RULES_DIR} to ${BUILD_DIR}`);
82 |
83 | try {
84 | // Build context for watch mode
85 | const ctx = await esbuild.context({
86 | entryPoints: {
87 | 'background/index': resolve(SRC_DIR, 'background/index.ts'),
88 | 'popup/index': resolve(SRC_DIR, 'popup/index.tsx'),
89 | 'pages/settings/index': resolve(SRC_DIR, 'pages/settings/index.tsx'),
90 | 'pages/desktop/index': resolve(SRC_DIR, 'pages/desktop/index.tsx'),
91 | },
92 | bundle: true,
93 | format: 'iife',
94 | outdir: BUILD_DIR,
95 | sourcemap: true,
96 | target: ['chrome88'],
97 | loader: {
98 | '.tsx': 'tsx',
99 | '.ts': 'ts',
100 | '.jsx': 'jsx',
101 | '.js': 'js',
102 | '.svg': 'file',
103 | '.png': 'file',
104 | '.css': 'css',
105 | '.ttf': 'file',
106 | '.woff': 'file',
107 | '.woff2': 'file',
108 | '.eot': 'file',
109 | },
110 | define: {
111 | 'process.env.NODE_ENV': '"production"',
112 | 'global': 'globalThis'
113 | },
114 | assetNames: 'assets/[name]-[hash]',
115 | publicPath: '/',
116 | metafile: true,
117 | logLevel: 'info'
118 | });
119 |
120 | // Copy static files
121 | await fs.copy('public', BUILD_DIR, {
122 | filter: (src) => !src.includes('manifest.json'),
123 | overwrite: true
124 | });
125 |
126 | // Copy HTML files
127 | await fs.copy(
128 | resolve(SRC_DIR, 'popup/index.html'),
129 | resolve(BUILD_DIR, 'popup/index.html')
130 | );
131 | await fs.copy(
132 | resolve(SRC_DIR, 'pages/settings/index.html'),
133 | resolve(BUILD_DIR, 'pages/settings/index.html')
134 | );
135 | await fs.copy(
136 | resolve(SRC_DIR, 'pages/desktop/index.html'),
137 | resolve(BUILD_DIR, 'pages/desktop/index.html')
138 | );
139 |
140 | // Read and modify manifest
141 | const manifestContent = JSON.parse(
142 | await fs.readFile(resolve('public', 'manifest.json'), 'utf-8')
143 | );
144 |
145 | // Add web_accessible_resources for fonts and other assets
146 | manifestContent.web_accessible_resources = [{
147 | "resources": [
148 | "assets/*",
149 | "assets/**/*",
150 | "*.js",
151 | "**/*.js",
152 | "**/*.css",
153 | "**/*.ttf",
154 | "**/*.woff",
155 | "**/*.woff2",
156 | "vs/*",
157 | "vs/**/*"
158 | ],
159 | "matches": [""]
160 | }];
161 |
162 | await fs.writeFile(
163 | resolve(BUILD_DIR, 'manifest.json'),
164 | JSON.stringify(manifestContent, null, 2),
165 | 'utf-8'
166 | );
167 |
168 | // Initial build
169 | const buildStart = Date.now();
170 | await ctx.rebuild();
171 |
172 | if (isWatch) {
173 | console.log('👀 Watching for changes...');
174 |
175 | // Start watching with rebuild callback
176 | await ctx.watch(async (error: Error | null, result: esbuild.BuildResult | null) => {
177 | const rebuildStart = Date.now();
178 | try {
179 | await ctx.rebuild();
180 | const duration = Date.now() - rebuildStart;
181 | const now = new Date().toLocaleTimeString();
182 | console.log(`🔄 [${now}] Rebuild completed successfully! (${formatDuration(duration)})`);
183 | } catch (e) {
184 | console.error('❌ Rebuild failed:', error || e);
185 | }
186 | });
187 | } else {
188 | await ctx.dispose();
189 | }
190 |
191 | // Add zip creation after build
192 | if (!isWatch) {
193 | await createExtensionZip(browser);
194 | }
195 |
196 | const duration = Date.now() - buildStart;
197 | console.log(`✅ Build completed successfully! (${formatDuration(duration)})`);
198 | } catch (err) {
199 | console.error('❌ Build failed:', err);
200 | process.exit(1);
201 | }
202 | }
203 |
204 | async function createExtensionZip(browser = 'chrome') {
205 | const zipName = `source-detector-${browser}.zip`;
206 | const zipPath = resolve(`dist/${zipName}`);
207 |
208 | // Remove existing zip if it exists
209 | if (await fs.pathExists(zipPath)) {
210 | await fs.remove(zipPath);
211 | }
212 |
213 | return new Promise((resolve, reject) => {
214 | const output = fs.createWriteStream(zipPath);
215 | const archive = archiver('zip', {
216 | zlib: { level: 9 } // Maximum compression
217 | });
218 |
219 | output.on('close', () => {
220 | console.log(`Extension packaged for ${browser}: ${archive.pointer()} bytes`);
221 | resolve();
222 | });
223 |
224 | archive.on('warning', function (err: ArchiverError) {
225 | if (err.code === 'ENOENT') {
226 | console.warn('Warning during zip creation:', err);
227 | } else {
228 | reject(err);
229 | }
230 | });
231 |
232 | archive.on('error', (err: ArchiverError) => {
233 | console.error('Error during zip creation:', err);
234 | reject(err);
235 | });
236 |
237 | archive.pipe(output);
238 |
239 | // Add the dist directory contents to the zip, excluding all zip files
240 | archive.glob('**/*', {
241 | cwd: BUILD_DIR,
242 | ignore: ['*.zip']
243 | });
244 |
245 | archive.finalize();
246 | });
247 | }
248 |
249 | // Handle arguments
250 | const args = process.argv.slice(2);
251 | const isWatch = args.includes('--watch');
252 | const browserArg = args.find(arg => arg.startsWith('--browser='));
253 | const browser = browserArg ? browserArg.split('=')[1] : 'chrome';
254 |
255 | // Validate browser
256 | if (browser !== 'chrome' && browser !== 'firefox') {
257 | console.error('❌ Invalid browser. Use --browser=chrome or --browser=firefox');
258 | process.exit(1);
259 | }
260 |
261 | build(isWatch, browser);
--------------------------------------------------------------------------------
/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/background/constants.ts:
--------------------------------------------------------------------------------
1 | import { AppSettings } from '@/types';
2 |
3 | export const MESSAGE_TYPES = {
4 | FOUND_SOURCE_MAP: 'FOUND_SOURCE_MAP',
5 | COLLECT_SOURCEMAP: 'COLLECT_SOURCEMAP',
6 | GET_SOURCEMAP: 'GET_SOURCEMAP',
7 | DELETE_SOURCEMAP: 'DELETE_SOURCEMAP',
8 | GET_STORAGE_STATS: 'GET_STORAGE_STATS',
9 | UPDATE_SETTINGS: 'UPDATE_SETTINGS',
10 | GET_SETTINGS: 'GET_SETTINGS',
11 | CLEAR_HISTORY: 'CLEAR_HISTORY',
12 | GET_PAGE_DATA: 'GET_PAGE_DATA',
13 | GET_ALL_PAGES: 'GET_ALL_PAGES',
14 | DELETE_PAGE: 'DELETE_PAGE',
15 | GET_FILE_DATA: 'GET_FILE_DATA',
16 | EXPORT_DATA: 'EXPORT_DATA',
17 | IMPORT_DATA: 'IMPORT_DATA',
18 | CLEAR_DATA: 'CLEAR_DATA',
19 | GET_ALL_SOURCE_MAPS: 'GET_ALL_SOURCE_MAPS',
20 | GET_CRX_FILE: 'GET_CRX_FILE',
21 | DOWNLOAD_CRX_FILE: 'DOWNLOAD_CRX_FILE',
22 | GET_SERVER_STATUS: 'GET_SERVER_STATUS',
23 | SERVER_STATUS_CHANGED: 'SERVER_STATUS_CHANGED'
24 | } as const;
25 |
26 | interface NumberSetting {
27 | min: number;
28 | max: number;
29 | default: number;
30 | unit?: string;
31 | }
32 |
33 | export const FILE_TYPES = {
34 | JS: 'js',
35 | CSS: 'css'
36 | } as const;
37 |
38 | export const SETTINGS = {
39 | FILE_TYPES,
40 | STORAGE: {
41 | CLEANUP_THRESHOLD: {
42 | min: 128,
43 | max: 4 * 1024 * 1024, // 4TB
44 | default: 2 * 1024, // 2GB
45 | unit: 'MB'
46 | } as NumberSetting,
47 | FILE_SIZE: {
48 | min: 1,
49 | max: 256,
50 | default: 32,
51 | unit: 'MB'
52 | } as NumberSetting,
53 | TOTAL_SIZE: {
54 | min: 64,
55 | max: 4 * 1024, // 4GB
56 | default: 1024,
57 | unit: 'MB'
58 | } as NumberSetting,
59 | FILES_COUNT: {
60 | min: 8,
61 | max: 1024,
62 | default: 1024,
63 | unit: 'files'
64 | } as NumberSetting,
65 | RETENTION_DAYS: {
66 | min: 1,
67 | max: 365,
68 | default: 30,
69 | unit: 'days'
70 | } as NumberSetting
71 | }
72 | } as const;
73 |
74 | export const STORAGE_LIMITS = SETTINGS.STORAGE;
75 |
76 | export const DEFAULT_SETTINGS: AppSettings = {
77 | id: 'settings',
78 | cleanupThreshold: 1000
79 | };
--------------------------------------------------------------------------------
/src/background/index.ts:
--------------------------------------------------------------------------------
1 | import { SourceDetectorDB } from '@/storage/database';
2 | import { AppSettings, CrxFile, PageData } from '@/types';
3 | import { isExtensionPage } from '@/utils/isExtensionPage';
4 | import { parseCrxFile } from '@/utils/parseCrxFile';
5 | import { MESSAGE_TYPES } from './constants';
6 | import { createHash } from './utils';
7 | import { browserAPI } from '@/utils/browser-polyfill';
8 |
9 | const db = new SourceDetectorDB();
10 |
11 | // Simple in-memory cache with 5s expiration
12 | const CACHE_EXPIRATION = 5000; // 5 seconds
13 | const cache = new Map();
14 |
15 | // Function to get content from cache or fetch
16 | async function getFileContent(url: string): Promise {
17 | try {
18 | const now = Date.now();
19 | const cached = cache.get(url);
20 |
21 | // Return cached content if it exists and hasn't expired
22 | if (cached && (now - cached.timestamp) < CACHE_EXPIRATION) {
23 | return cached.content;
24 | }
25 |
26 | // Fetch fresh content
27 | const response = await fetch(url);
28 | if (!response.ok) {
29 | throw new Error(`Failed to fetch ${url}: ${response.status}`);
30 | }
31 | const content = await response.text();
32 |
33 | // Update cache
34 | cache.set(url, { content, timestamp: now });
35 |
36 | // Clean old entries periodically
37 | if (cache.size > 100) { // Prevent memory leaks
38 | for (const [key, value] of cache.entries()) {
39 | if (now - value.timestamp > CACHE_EXPIRATION) {
40 | cache.delete(key);
41 | }
42 | }
43 | }
44 |
45 | return content;
46 | } catch (error) {
47 | console.error('Error getting file content:', error);
48 | throw error;
49 | }
50 | }
51 |
52 | // Store current page information
53 | let currentPage: { url: string; title: string } | null = null;
54 |
55 | // Function to update badge
56 | async function updateBadge(url: string, isCrx: boolean = false) {
57 | try {
58 | if (isCrx) {
59 | const crxFile = await db.getCrxFileByPageUrl(url);
60 | if (crxFile) {
61 | await browserAPI.action.setBadgeText({ text: String(crxFile.count) });
62 | await browserAPI.action.setBadgeBackgroundColor({ color: '#4CAF50' });
63 | } else {
64 | await browserAPI.action.setBadgeText({ text: '' });
65 | }
66 | } else {
67 | // Get source maps for current page using the new schema
68 | const files = await db.getPageFiles(url);
69 | const latestFiles = files.filter(file => file.isLatest);
70 |
71 | // Update badge text and color
72 | if (latestFiles.length > 0) {
73 | await browserAPI.action.setBadgeText({ text: String(latestFiles.length) });
74 | await browserAPI.action.setBadgeBackgroundColor({ color: '#4CAF50' }); // Green color
75 | } else {
76 | await browserAPI.action.setBadgeText({ text: '' });
77 | }
78 | }
79 | } catch (error) {
80 | console.error('Error updating badge:', error);
81 | await browserAPI.action.setBadgeText({ text: '' });
82 | }
83 | }
84 |
85 | // Function to update current page information
86 | async function updateCurrentPage() {
87 | try {
88 | const [tab] = await browserAPI.tabs.query({ active: true, currentWindow: true });
89 | if (tab?.url && tab?.title && !isExtensionPage(tab.url)) {
90 | currentPage = {
91 | url: tab.url,
92 | title: tab.title
93 | };
94 | // Update badge when page changes
95 | await updateBadge(tab.url);
96 | }
97 | } catch (error) {
98 | console.error('Error updating current page:', error);
99 | }
100 | }
101 |
102 | // Monitor tab updates
103 | browserAPI.tabs.onUpdated.addListener(async (_tabId, changeInfo, tab) => {
104 | if (changeInfo.status === 'complete' && tab.url) {
105 | console.log('tab.url', tab.url, isExtensionPage(tab.url))
106 | if (isExtensionPage(tab.url)) {
107 | await updateCrxPage(tab);
108 | } else {
109 | await updateCurrentPage();
110 | }
111 | }
112 | });
113 | async function onTabActivated() {
114 | const [activeTab] = await browserAPI.tabs.query({ active: true, currentWindow: true });
115 | if (isExtensionPage(activeTab?.url || '')) {
116 | await updateCrxPage(activeTab);
117 | } else {
118 | await updateCurrentPage();
119 | }
120 | }
121 |
122 | // Monitor tab activation
123 | browserAPI.tabs.onActivated.addListener(onTabActivated);
124 |
125 | // Monitor window focus
126 | browserAPI.windows.onFocusChanged.addListener(onTabActivated);
127 |
128 | async function updateCrxPage(tab: browserAPI.tabs.Tab) {
129 | const url = tab.url;
130 | if (!url) return;
131 | await updateBadge(url, true);
132 | const crxUrl = await getCrxUrl(url);
133 | if (crxUrl) {
134 | // check if the file exists
135 | let crxFile = await db.getCrxFileByPageUrl(url);
136 | if (!crxFile) {
137 | // Create empty content hash for new file
138 | const emptyBlob = new Blob();
139 |
140 | // save to db
141 | crxFile = await db.addCrxFile({
142 | pageUrl: url,
143 | pageTitle: tab.title || '',
144 | crxUrl: crxUrl,
145 | blob: emptyBlob,
146 | size: 0,
147 | timestamp: Date.now(),
148 | count: 0,
149 | contentHash: ''
150 | });
151 | }
152 |
153 | const result = await parseCrxFile(crxUrl);
154 | console.log('result', result);
155 | if (result && result.count > 0) {
156 | // Create content hash from blob
157 | const arrayBuffer = await result.blob.arrayBuffer();
158 | const uint8Array = new Uint8Array(arrayBuffer);
159 | const contentHash = await createHash('SHA-256')
160 | .update(Array.from(uint8Array).map(b => String.fromCharCode(b)).join(''))
161 | .digest('hex');
162 |
163 | await db.updateCrxFile({
164 | id: crxFile.id,
165 | pageUrl: url,
166 | pageTitle: tab.title || '',
167 | crxUrl: crxUrl,
168 | blob: result.blob,
169 | size: result.blob.size,
170 | count: result.count,
171 | timestamp: Date.now(),
172 | contentHash,
173 | });
174 | console.log('updateCrxFile', crxFile);
175 | // update badge
176 | await updateBadge(url, true);
177 | }
178 | }
179 | }
180 |
181 | // Modify fetchSourceMapContent to use cache
182 | async function fetchSourceMapContent(sourceUrl: string, mapUrl: string): Promise<{
183 | content: string;
184 | originalContent: string;
185 | size: number;
186 | hash: string;
187 | } | null> {
188 | try {
189 | // Get both files using the caching function
190 | const content = await getFileContent(mapUrl);
191 | const originalContent = await getFileContent(sourceUrl);
192 |
193 | // Calculate size and hash
194 | const size = new Blob([content]).size + new Blob([originalContent]).size;
195 | const hash = await createHash('SHA-256')
196 | .update(content + originalContent)
197 | .digest('hex');
198 |
199 | return { content, originalContent, size, hash };
200 | } catch (error) {
201 | console.error('Error fetching source map content:', error);
202 | return null;
203 | }
204 | }
205 |
206 | // Listen for requests to detect JS/CSS files
207 | browserAPI.webRequest.onCompleted.addListener(
208 | (details) => {
209 | if (isExtensionPage(details.url)) {
210 | return;
211 | }
212 |
213 | console.log('details', details.initiator)
214 | if (!/\.(js|css)(\?.*)?$/.test(details.url)) {
215 | return;
216 | }
217 |
218 | // Process asynchronously
219 | (async () => {
220 | try {
221 | // Get content from cache or fetch
222 | const content = await getFileContent(details.url);
223 |
224 | // Check for source map in the last line
225 | // lastLine is like this:
226 | // /*# sourceMappingURL=https://example.com/path/to/map.css.map */
227 | // or
228 | // //# sourceMappingURL=https://example.com/path/to/map.css.map
229 | const lastLine = content.split('\n').pop()?.trim() || '';
230 | const sourceMapMatch = lastLine.match(/#\s*sourceMappingURL=([^\s\*]+)/);
231 |
232 | if (sourceMapMatch) {
233 | const mapUrl = sourceMapMatch[1];
234 | const fullMapUrl = mapUrl.startsWith('http')
235 | ? mapUrl
236 | : new URL(mapUrl, details.url).toString();
237 |
238 | await handleSourceMapFound({
239 | pageTitle: currentPage?.title || '',
240 | pageUrl: currentPage?.url || '',
241 | sourceUrl: details.url,
242 | mapUrl: fullMapUrl,
243 | fileType: details.url.endsWith('.css') ? 'css' : 'js',
244 | originalContent: content
245 | });
246 | }
247 | } catch (error) {
248 | console.error('Error processing response:', error);
249 | }
250 | })();
251 | },
252 | { urls: [''] }
253 | );
254 |
255 | // 监听消息
256 | browserAPI.runtime.onMessage.addListener((message, _sender, sendResponse) => {
257 | const handleMessage = async () => {
258 | try {
259 | switch (message.type) {
260 | case MESSAGE_TYPES.FOUND_SOURCE_MAP:
261 | return await handleSourceMapFound(message.data);
262 | case MESSAGE_TYPES.GET_SOURCEMAP:
263 | return await handleGetSourceMap(message.data);
264 | case MESSAGE_TYPES.DELETE_SOURCEMAP:
265 | return await handleDeleteSourceMap(message.data);
266 | case MESSAGE_TYPES.GET_STORAGE_STATS:
267 | return await handleGetStorageStats();
268 | case MESSAGE_TYPES.GET_SETTINGS:
269 | return await handleGetSettings();
270 | case MESSAGE_TYPES.UPDATE_SETTINGS:
271 | return await handleUpdateSettings(message.data);
272 | case MESSAGE_TYPES.CLEAR_HISTORY:
273 | return await handleClearHistory();
274 | case MESSAGE_TYPES.GET_FILE_DATA:
275 | return await handleGetFileData(message.data);
276 | case MESSAGE_TYPES.GET_PAGE_DATA:
277 | return await handleGetPageData(message.data);
278 | case MESSAGE_TYPES.GET_ALL_SOURCE_MAPS:
279 | return await handleGetAllSourceMaps();
280 | case MESSAGE_TYPES.CLEAR_DATA:
281 | return await handleClearData();
282 | case MESSAGE_TYPES.GET_CRX_FILE:
283 | return await handleGetCrxFile(message.data);
284 | case MESSAGE_TYPES.GET_SERVER_STATUS:
285 | return { success: true, data: { isOnline: serverStatus } };
286 | default:
287 | return { success: false, reason: 'Unknown message type' };
288 | }
289 | } catch (error) {
290 | console.error('Error handling message:', error);
291 | return { success: false, reason: String(error) };
292 | }
293 | };
294 |
295 | handleMessage().then(sendResponse);
296 | return true;
297 | });
298 |
299 | async function handleGetCrxFile(data: { url: string }) {
300 | try {
301 | const crxFile = await db.getCrxFileByPageUrl(data.url);
302 | if (!crxFile) return { success: false, reason: 'CRX file not found' };
303 |
304 | return { success: true, data: crxFile };
305 | } catch (error) {
306 | console.error('Error getting CRX file:', error);
307 | return { success: false, reason: String(error) };
308 | }
309 | }
310 |
311 | async function handleClearData() {
312 | try {
313 | await db.sourceMapFiles.clear();
314 | return { success: true };
315 | } catch (error) {
316 | console.error('Error clearing data:', error);
317 | return { success: false, reason: String(error) };
318 | }
319 | }
320 |
321 | async function handleGetPageData(data: { url: string }) {
322 | try {
323 | const files = await db.getPageFiles(data.url);
324 | const pageData: PageData = {
325 | url: data.url,
326 | title: currentPage?.title || '',
327 | timestamp: Date.now(),
328 | files: files
329 | };
330 | return { success: true, data: pageData };
331 | } catch (error) {
332 | console.error('Error getting page data:', error);
333 | return { success: false, reason: String(error) };
334 | }
335 | }
336 |
337 | async function handleGetFileData(data: { url: string }) {
338 | try {
339 | const file = await db.sourceMapFiles.where('url').equals(data.url).first();
340 | return { success: true, data: file };
341 | } catch (error) {
342 | console.error('Error getting file data:', error);
343 | return { success: false, reason: String(error) };
344 | }
345 | }
346 |
347 | // Task queue implementation
348 | class TaskQueue {
349 | private queue: Map> = new Map();
350 |
351 | async enqueue(key: string, task: () => Promise): Promise {
352 | const currentTask = this.queue.get(key) || Promise.resolve();
353 | try {
354 | // Create a new task that waits for the current task to complete
355 | const newTask = currentTask.then(task, task);
356 |
357 | // Update the queue with the new task
358 | this.queue.set(key, newTask);
359 |
360 | // Wait for the task to complete and return its result
361 | const result = await newTask;
362 |
363 | // Clean up if this was the last task in the queue
364 | if (this.queue.get(key) === newTask) {
365 | this.queue.delete(key);
366 | }
367 |
368 | return result;
369 | } catch (error) {
370 | // Clean up on error
371 | if (this.queue.get(key) === currentTask) {
372 | this.queue.delete(key);
373 | }
374 | throw error;
375 | }
376 | }
377 | }
378 |
379 | const taskQueue = new TaskQueue();
380 |
381 | // Function to handle source map found
382 | async function handleSourceMapFound(data: { pageTitle: string; pageUrl: string; sourceUrl: string; mapUrl: string; fileType: 'js' | 'css'; originalContent: string }): Promise<{ success: boolean; reason?: string }> {
383 | return taskQueue.enqueue('sourceMap', async () => {
384 | try {
385 | // Fetch content
386 | const content = await fetchSourceMapContent(data.sourceUrl, data.mapUrl);
387 | if (!content) return { success: false, reason: 'Failed to fetch content' };
388 |
389 | // Check existing file
390 | const existingFile = await db.sourceMapFiles.where('url').equals(data.sourceUrl).first();
391 |
392 | // Check if content unchanged
393 | if (existingFile && existingFile.hash === content.hash) {
394 | // Even if content is unchanged, we still need to associate it with the current page
395 | await db.addSourceMapToPage(data.pageUrl, data.pageTitle, existingFile);
396 | return { success: true, reason: 'File content unchanged but added to page' };
397 | }
398 |
399 | // Update existing versions if they exist
400 | if (existingFile) {
401 | const existingFiles = await db.sourceMapFiles
402 | .where('url')
403 | .equals(data.sourceUrl)
404 | .toArray();
405 | await Promise.all(
406 | existingFiles.map(file =>
407 | db.sourceMapFiles.update(file.id, { isLatest: false })
408 | )
409 | );
410 | }
411 |
412 | // Get latest version number
413 | const latestVersion = existingFile ?
414 | (await db.sourceMapFiles
415 | .where('url')
416 | .equals(data.sourceUrl)
417 | .toArray())
418 | .reduce((max, file) => Math.max(max, file.version), 0) : 0;
419 |
420 | // Create new source map record
421 | const sourceMapFile = {
422 | url: data.sourceUrl,
423 | sourceMapUrl: data.mapUrl,
424 | content: content.content,
425 | originalContent: content.originalContent,
426 | fileType: data.fileType,
427 | size: content.size,
428 | timestamp: Date.now(),
429 | version: latestVersion + 1,
430 | hash: content.hash,
431 | isLatest: true,
432 | };
433 |
434 | // Store source map
435 | const savedSourceMapFile = await db.addSourceMapFile(sourceMapFile);
436 |
437 | // Associate with page
438 | await db.addSourceMapToPage(data.pageUrl, data.pageTitle, savedSourceMapFile);
439 |
440 | checkAndCleanStorage();
441 | // Update badge after storing new source map
442 | await updateBadge(data.pageUrl);
443 |
444 | return { success: true };
445 | } catch (error) {
446 | console.error('Error handling source map:', error);
447 | return { success: false, reason: String(error) };
448 | }
449 | });
450 | }
451 |
452 | // 获取 sourcemap
453 | async function handleGetSourceMap(data: { url: string }) {
454 | try {
455 | const file = await db.sourceMapFiles
456 | .where('url')
457 | .equals(data.url)
458 | .first();
459 |
460 | return { success: true, data: file };
461 | } catch (error) {
462 | console.error('Error getting sourcemap:', error);
463 | return { success: false, reason: String(error) };
464 | }
465 | }
466 |
467 | // 删除 sourcemap
468 | async function handleDeleteSourceMap(data: { url: string }) {
469 | try {
470 | await db.sourceMapFiles
471 | .where('url')
472 | .equals(data.url)
473 | .delete();
474 |
475 | return { success: true };
476 | } catch (error) {
477 | console.error('Error deleting sourcemap:', error);
478 | return { success: false, reason: String(error) };
479 | }
480 | }
481 |
482 | // 获取存储统计信息
483 | async function handleGetStorageStats() {
484 | try {
485 | const stats = await db.getStorageStats();
486 | return { success: true, data: stats };
487 | } catch (error) {
488 | console.error('Error getting storage stats:', error);
489 | return { success: false, reason: String(error) };
490 | }
491 | }
492 |
493 | // 获取设置
494 | async function handleGetSettings() {
495 | try {
496 | const settings = await db.getSettings();
497 | return { success: true, data: settings };
498 | } catch (error) {
499 | console.error('Error getting settings:', error);
500 | return { success: false, reason: String(error) };
501 | }
502 | }
503 |
504 | // 更新设置
505 | async function handleUpdateSettings(settings: Partial) {
506 | try {
507 | await db.updateSettings(settings);
508 | return { success: true };
509 | } catch (error) {
510 | console.error('Error updating settings:', error);
511 | return { success: false, reason: String(error) };
512 | }
513 | }
514 |
515 | // 清空史记
516 | async function handleClearHistory() {
517 | try {
518 | await Promise.all([
519 | db.sourceMapFiles.clear(),
520 | db.pages.clear(),
521 | db.pageSourceMaps.clear()
522 | ]);
523 | return { success: true };
524 | } catch (error) {
525 | console.error('Error clearing history:', error);
526 | return { success: false, reason: String(error) };
527 | }
528 | }
529 |
530 | // 检查并清理存储
531 | async function checkAndCleanStorage() {
532 | try {
533 | const settings = await db.getSettings();
534 | const stats = await db.getStorageStats();
535 | if (stats.totalSize > settings.cleanupThreshold * 1024 * 1024) {
536 | // Delete oldest files first
537 | await db.sourceMapFiles
538 | .orderBy('timestamp')
539 | .limit(100)
540 | .delete();
541 | }
542 | } catch (error) {
543 | console.error('Error cleaning storage:', error);
544 | }
545 | }
546 |
547 | async function handleGetAllSourceMaps() {
548 | try {
549 | const files = await db.sourceMapFiles.toArray();
550 | return { success: true, data: files };
551 | } catch (error) {
552 | console.error('Error getting all source maps:', error);
553 | return { success: false, reason: String(error) };
554 | }
555 | }
556 |
557 | // Get CRX download URL from extension page
558 | async function getCrxUrl(url: string): Promise {
559 | try {
560 | // Extract extension ID from various URL patterns
561 | const cws_pattern = /^https?:\/\/(?:chrome.google.com\/webstore|chromewebstore.google.com)\/.+?\/([a-z]{32})(?=[\/#?]|$)/;
562 | const match = cws_pattern.exec(url);
563 | const extId = match?.[1] || url.split('/')[6]?.split('?')[0] || url.split('//')[1]?.split('/')[0];
564 | if (!extId || !/^[a-z]{32}$/.test(extId)) return null;
565 |
566 | const platformInfo = await browserAPI.runtime.getPlatformInfo();
567 | const version = navigator.userAgent.split("Chrome/")[1]?.split(" ")[0] || '9999.0.9999.0';
568 |
569 | // Construct URL with all necessary parameters
570 | let downloadUrl = 'https://clients2.google.com/service/update2/crx?response=redirect';
571 | downloadUrl += '&os=' + platformInfo.os;
572 | downloadUrl += '&arch=' + platformInfo.arch;
573 | downloadUrl += '&os_arch=' + platformInfo.arch;
574 | downloadUrl += '&nacl_arch=' + platformInfo.nacl_arch;
575 | // Use chromiumcrx as product ID since we're not Chrome
576 | downloadUrl += '&prod=chromiumcrx';
577 | downloadUrl += '&prodchannel=unknown';
578 | downloadUrl += '&prodversion=' + version;
579 | downloadUrl += '&acceptformat=crx2,crx3';
580 | downloadUrl += '&x=id%3D' + extId;
581 | downloadUrl += '%26uc';
582 |
583 | return downloadUrl;
584 | } catch (error) {
585 | console.error('Error getting CRX URL:', error);
586 | return null;
587 | }
588 | }
589 |
590 | // Server configuration
591 | const SERVER_CONFIG = {
592 | host: '127.0.0.1',
593 | port: '63798'
594 | };
595 |
596 | const SERVER_URL = `http://${SERVER_CONFIG.host}:${SERVER_CONFIG.port}`;
597 | const HEARTBEAT_INTERVAL = 5000; // 5 seconds
598 | const SYNC_CHECK_INTERVAL = 60 * 1000; // 1 minute
599 |
600 | // Tables to sync
601 | const TableChunkSizeMap = {
602 | crxFiles: 1,
603 | sourceMapFiles: 1,
604 | pages: 100,
605 | pageSourceMaps: 100
606 | } as const;
607 |
608 | let serverStatus = false;
609 |
610 | // Function to check server health
611 | async function checkServerHealth(): Promise {
612 | try {
613 | const response = await fetch(`${SERVER_URL}/health`, {
614 | method: 'GET',
615 | headers: {
616 | 'Accept': 'application/json',
617 | }
618 | });
619 | const { status } = await response.json();
620 | return response.ok && status === 'ok';
621 | } catch (error) {
622 | console.error('Error checking server health:', error);
623 | return false;
624 | }
625 | }
626 |
627 | // Heartbeat function
628 | async function checkServerStatus() {
629 | serverStatus = await checkServerHealth();
630 |
631 | // Broadcast status to all tabs
632 | browserAPI.runtime.sendMessage({
633 | type: MESSAGE_TYPES.SERVER_STATUS_CHANGED,
634 | data: { isOnline: serverStatus }
635 | }).catch(() => { }); // Ignore errors if no listeners
636 | }
637 |
638 | // Function to convert Blob to base64
639 | async function blobToBase64(blob: Blob): Promise {
640 | const arrayBuffer = await blob.arrayBuffer();
641 | const uint8Array = new Uint8Array(arrayBuffer);
642 |
643 | // Process the array in chunks to avoid call stack size exceeded
644 | const chunkSize = 0x8000; // 32KB chunks
645 | let result = '';
646 |
647 | for (let i = 0; i < uint8Array.length; i += chunkSize) {
648 | const chunk = uint8Array.slice(i, i + chunkSize);
649 | result += String.fromCharCode.apply(null, Array.from(chunk));
650 | }
651 |
652 | return btoa(result);
653 | }
654 |
655 | // Function to sync data to server
656 | async function syncDataToServer() {
657 | try {
658 | for (const table of Object.keys(TableChunkSizeMap)) {
659 | console.log('syncDataToServer', table);
660 | let lastId = await db.getLastSyncId(table);
661 | console.log('lastId', lastId);
662 | let modifiedData = await db.getModifiedData(table, lastId, TableChunkSizeMap[table as keyof typeof TableChunkSizeMap]);
663 | console.log('modifiedData', modifiedData);
664 |
665 | while (modifiedData.length > 0) {
666 | try {
667 | let processedChunk = table === 'crxFiles'
668 | ? await Promise.all(modifiedData.map(async (file) => {
669 | const crxFile = file as CrxFile;
670 | return {
671 | ...crxFile,
672 | blob: await blobToBase64(crxFile.blob)
673 | };
674 | }))
675 | : modifiedData;
676 |
677 | const response = await fetch(`${SERVER_URL}/sync`, {
678 | method: 'POST',
679 | headers: { 'Content-Type': 'application/json' },
680 | body: JSON.stringify({
681 | table,
682 | lastId,
683 | data: processedChunk
684 | })
685 | });
686 |
687 | if (response.ok) {
688 | const result = await response.json();
689 |
690 | // Log failed records for debugging
691 | if (result.failedRecords?.length > 0) {
692 | console.log(`Failed records for ${table}:`, result.failedRecords);
693 | }
694 |
695 | // Update last synced ID to the last successful record's ID
696 | if (result.lastSuccessId > lastId) {
697 | lastId = result.lastSuccessId;
698 | await db.updateLastSyncId(table, lastId);
699 | }
700 |
701 | console.log(`Synced ${modifiedData.length} records for ${table}`);
702 | } else {
703 | console.log(`Failed to sync chunk: ${response.statusText}`);
704 | break;
705 | }
706 | } catch (error) {
707 | console.log('error', error);
708 | }
709 |
710 | modifiedData = await db.getModifiedData(table, lastId, TableChunkSizeMap[table as keyof typeof TableChunkSizeMap]);
711 | console.log('modifiedData2', modifiedData);
712 | }
713 | }
714 |
715 | console.log('Data sync completed successfully');
716 | } catch (error) {
717 | console.error('Error syncing data:', error);
718 | }
719 | }
720 |
721 | let inSync = false;
722 | // Function to check server status and trigger sync
723 | async function checkServerAndSync() {
724 | if (inSync) {
725 | return;
726 | }
727 | inSync = true;
728 | console.log('checkServerAndSync');
729 | if (await checkServerHealth()) {
730 | console.log('checkServerAndSync2');
731 | await syncDataToServer();
732 | }
733 | inSync = false;
734 | }
735 |
736 | // Start heartbeat and sync
737 | setInterval(checkServerStatus, HEARTBEAT_INTERVAL);
738 | setInterval(checkServerAndSync, SYNC_CHECK_INTERVAL);
739 |
740 | // Initial checks
741 | checkServerStatus();
742 | checkServerAndSync();
--------------------------------------------------------------------------------
/src/background/utils.ts:
--------------------------------------------------------------------------------
1 | import { SourceMapFile } from '@/types';
2 | import { formatBytes } from '@/utils/format';
3 | import JSZip from 'jszip';
4 |
5 |
6 | /**
7 | * Create a download URL for a file
8 | */
9 | export function createDownloadUrl(file: SourceMapFile): string {
10 | const blob = new Blob([file.content], { type: 'application/json' });
11 | return URL.createObjectURL(blob);
12 | }
13 |
14 | /**
15 | * Clean up a download URL
16 | */
17 | export function revokeDownloadUrl(url: string): void {
18 | URL.revokeObjectURL(url);
19 | }
20 |
21 | /**
22 | * Format a timestamp to a readable date string
23 | */
24 | export function formatDate(timestamp: number): string {
25 | return new Date(timestamp).toLocaleString();
26 | }
27 |
28 | /**
29 | * Create a ZIP file containing sourcemaps
30 | */
31 | export async function createZipFile(files: SourceMapFile[]): Promise {
32 | const zip = new JSZip();
33 |
34 | // Group files by type
35 | const jsFiles = files.filter(f => f.fileType === 'js');
36 | const cssFiles = files.filter(f => f.fileType === 'css');
37 |
38 | // Add JS files
39 | if (jsFiles.length > 0) {
40 | const jsFolder = zip.folder('js');
41 | jsFiles.forEach(file => {
42 | const fileName = new URL(file.url).pathname.split('/').pop() || 'unknown.js.map';
43 | jsFolder?.file(fileName, file.content);
44 | });
45 | }
46 |
47 | // Add CSS files
48 | if (cssFiles.length > 0) {
49 | const cssFolder = zip.folder('css');
50 | cssFiles.forEach(file => {
51 | const fileName = new URL(file.url).pathname.split('/').pop() || 'unknown.css.map';
52 | cssFolder?.file(fileName, file.content);
53 | });
54 | }
55 |
56 | // Generate metadata.json
57 | const metadata = {
58 | timestamp: Date.now(),
59 | fileCount: files.length,
60 | files: files.map(f => ({
61 | url: f.url,
62 | sourceMapUrl: f.sourceMapUrl,
63 | fileType: f.fileType,
64 | size: f.size
65 | }))
66 | };
67 | zip.file('metadata.json', JSON.stringify(metadata, null, 2));
68 |
69 | return await zip.generateAsync({ type: 'blob' });
70 | }
71 |
72 | /**
73 | * Get icon for file type
74 | */
75 | export function getFileTypeIcon(fileType: 'js' | 'css'): string {
76 | return fileType === 'js' ? '📄' : '🎨';
77 | }
78 |
79 | /**
80 | * Extract filename from URL
81 | */
82 | export function getFileNameFromUrl(url: string): string {
83 | try {
84 | const urlObj = new URL(url);
85 | const fileName = urlObj.pathname.split('/').pop();
86 | return fileName || 'unknown';
87 | } catch {
88 | return 'unknown';
89 | }
90 | }
91 |
92 | /**
93 | * Create a hash for a given string
94 | */
95 | export function createHash(algorithm: string) {
96 | const encoder = new TextEncoder();
97 |
98 | return {
99 | update(data: string) {
100 | const buffer = encoder.encode(data);
101 | return {
102 | async digest(_encoding: string) {
103 | const hashBuffer = await crypto.subtle.digest(algorithm.toUpperCase(), buffer);
104 | const hashArray = Array.from(new Uint8Array(hashBuffer));
105 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
106 | }
107 | };
108 | }
109 | };
110 | }
111 |
112 | export function getFileExtension(filename: string): string {
113 | const ext = filename.split('.').pop()?.toLowerCase() || '';
114 | return ext;
115 | }
116 |
117 | export function isJavaScriptFile(filename: string): boolean {
118 | const ext = getFileExtension(filename);
119 | return ext === 'js' || ext === 'mjs' || ext === 'cjs';
120 | }
121 |
122 | export function isCSSFile(filename: string): boolean {
123 | const ext = getFileExtension(filename);
124 | return ext === 'css';
125 | }
126 |
127 | export function isSourceMapFile(filename: string): boolean {
128 | const ext = getFileExtension(filename);
129 | return ext === 'map';
130 | }
131 |
132 | export function isChromePage(url: string): boolean {
133 | return url.startsWith('chrome://') || url.startsWith('chrome-extension://');
134 | }
135 |
136 | export function isChromeWebStorePage(url: string): boolean {
137 | return url.startsWith('https://chromewebstore.google.com/');
138 | }
139 |
140 | export function isExtensionPage(url: string): boolean {
141 | return isChromePage(url) || isChromeWebStorePage(url);
142 | }
143 |
144 | export { formatBytes };
145 |
--------------------------------------------------------------------------------
/src/components/Toast.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Snackbar, Box, Typography } from '@mui/material';
3 | import {
4 | CheckCircle as SuccessIcon,
5 | Error as ErrorIcon,
6 | Info as InfoIcon,
7 | Warning as WarningIcon
8 | } from '@mui/icons-material';
9 |
10 | type ToastSeverity = 'success' | 'error' | 'info' | 'warning';
11 |
12 | interface ToastProps {
13 | open: boolean;
14 | message: string;
15 | severity?: ToastSeverity;
16 | onClose: () => void;
17 | autoHideDuration?: number;
18 | }
19 |
20 | const severityColors: Record = {
21 | success: '#4caf50',
22 | error: '#f44336',
23 | info: '#2196f3',
24 | warning: '#ff9800'
25 | };
26 |
27 | const severityIcons: Record = {
28 | success: ,
29 | error: ,
30 | info: ,
31 | warning:
32 | };
33 |
34 | export function Toast({
35 | open,
36 | message,
37 | severity = 'info',
38 | onClose,
39 | autoHideDuration = 2000
40 | }: ToastProps) {
41 | return (
42 |
49 |
64 | {severityIcons[severity]}
65 |
66 | {message}
67 |
68 |
69 |
70 | );
71 | }
--------------------------------------------------------------------------------
/src/components/fileIcon.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Code as CodeIcon,
3 | Css as CssIcon,
4 | Html as HtmlIcon,
5 | Image as ImageIcon,
6 | InsertDriveFile,
7 | Javascript as JavascriptIcon,
8 | DataObject as JsonIcon,
9 | Description as TextIcon
10 | } from '@mui/icons-material';
11 |
12 | export const getFileIcon = (fileName: string) => {
13 | const extension = fileName.split('.').pop()?.toLowerCase();
14 | switch (extension) {
15 | case 'js':
16 | case 'jsx':
17 | case 'ts':
18 | case 'tsx':
19 | return ;
20 | case 'css':
21 | case 'scss':
22 | case 'sass':
23 | case 'less':
24 | return ;
25 | case 'json':
26 | return ;
27 | case 'html':
28 | case 'htm':
29 | return ;
30 | case 'png':
31 | case 'jpg':
32 | case 'jpeg':
33 | case 'gif':
34 | case 'svg':
35 | case 'webp':
36 | case 'ico':
37 | return ;
38 | case 'xml':
39 | case 'yaml':
40 | case 'yml':
41 | return ;
42 | case 'txt':
43 | case 'md':
44 | return ;
45 | default:
46 | return ;
47 | }
48 | };
--------------------------------------------------------------------------------
/src/pages/desktop/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {
3 | AppBar,
4 | Toolbar,
5 | Typography,
6 | Button,
7 | Container,
8 | Grid,
9 | Card,
10 | CardContent,
11 | CardHeader,
12 | Box,
13 | Link,
14 | ThemeProvider,
15 | createTheme,
16 | CssBaseline
17 | } from '@mui/material';
18 | import { styled } from '@mui/system';
19 | import DownloadIcon from '@mui/icons-material/Download';
20 |
21 | // Create a custom theme
22 | const theme = createTheme({
23 | palette: {
24 | primary: {
25 | main: '#3f51b5',
26 | },
27 | secondary: {
28 | main: '#f50057',
29 | },
30 | },
31 | });
32 |
33 | // Styled components
34 | const HeroSection = styled('section')(({ theme }) => ({
35 | paddingTop: theme.spacing(12),
36 | paddingBottom: theme.spacing(6),
37 | textAlign: 'center',
38 | }));
39 |
40 | const FeatureCard = styled(Card)(({ theme }) => ({
41 | height: '100%',
42 | display: 'flex',
43 | flexDirection: 'column',
44 | }));
45 |
46 | const DownloadSection = styled('section')(({ theme }) => ({
47 | padding: theme.spacing(8, 0),
48 | backgroundColor: theme.palette.grey[100],
49 | }));
50 |
51 | export default function LandingPage() {
52 | return (
53 |
54 |
55 |
56 | {/* Header */}
57 |
58 |
59 |
60 | Source Detector
61 |
62 |
63 |
64 |
65 |
66 |
67 | {/* Hero Section */}
68 |
69 |
70 |
71 | Source Map & CRX File Detection Made Easy
72 |
73 |
74 | Powerful desktop application for detecting and managing source maps and Chrome extension files. Seamlessly sync with your browser extension.
75 |
76 |
77 |
80 |
83 |
84 |
85 |
86 |
87 | {/* Features Section */}
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | Automatically detect source maps and Chrome extension (CRX) files while browsing. Our intelligent system identifies and catalogs these files for easy access.
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | Keep your source maps and CRX files neatly organized by website, making it simple to find and manage your collected files.
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | View and analyze the contents of your detected files with our built-in code viewer. Syntax highlighting and file structure navigation make code exploration effortless.
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 | Download any detected source maps or CRX files with a single click. Perfect for developers who need quick access to these resources for analysis or debugging.
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 | {/* Download Section */}
134 |
135 |
136 |
137 |
138 | Ready to Get Started?
139 |
140 |
141 | Download Source Detector now and take control of your source maps and Chrome extensions.
142 |
143 | }
147 | sx={{ mt: 2 }}
148 | >
149 | Download for Desktop
150 |
151 |
152 |
153 |
154 |
155 | {/* Footer */}
156 |
157 |
158 |
159 |
160 |
161 | © 2024 Source Detector. All rights reserved.
162 |
163 |
164 |
165 | Privacy
166 | Terms
167 | Contact
168 |
169 |
170 |
171 |
172 |
173 |
174 | );
175 | }
176 |
177 |
--------------------------------------------------------------------------------
/src/pages/desktop/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Source Detector Desktop
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/pages/desktop/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import App from './App';
4 |
5 | createRoot(document.getElementById('root')!).render(
6 |
7 |
8 |
9 | );
--------------------------------------------------------------------------------
/src/pages/settings/App.tsx:
--------------------------------------------------------------------------------
1 | import { DEFAULT_SETTINGS, MESSAGE_TYPES, STORAGE_LIMITS } from '@/background/constants';
2 | import { formatBytes } from '@/background/utils';
3 | import { Toast } from '@/components/Toast';
4 | import { AppSettings, StorageStats } from '@/types';
5 | import { Delete as DeleteIcon } from '@mui/icons-material';
6 | import {
7 | Alert,
8 | AppBar,
9 | Box,
10 | Button,
11 | CircularProgress,
12 | Dialog,
13 | DialogActions,
14 | DialogContent,
15 | DialogTitle,
16 | List,
17 | ListItem,
18 | ListItemSecondaryAction,
19 | ListItemText,
20 | Slider,
21 | Toolbar,
22 | Typography
23 | } from '@mui/material';
24 | import { createTheme, ThemeProvider } from '@mui/material/styles';
25 | import React, { useEffect, useState } from 'react';
26 | import { browserAPI } from '@/utils/browser-polyfill';
27 |
28 | export default function App() {
29 | const [loading, setLoading] = useState(true);
30 | const [settings, setSettings] = useState(null);
31 | const [stats, setStats] = useState(null);
32 | const [clearDialogOpen, setClearDialogOpen] = useState(false);
33 | const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info' | 'warning'; text: string } | null>(null);
34 |
35 | useEffect(() => {
36 | loadData();
37 | }, []);
38 |
39 | const loadData = async () => {
40 | try {
41 | const response = await browserAPI.runtime.sendMessage({
42 | type: MESSAGE_TYPES.GET_SETTINGS
43 | });
44 |
45 | if (response.success) {
46 | setSettings(response.data);
47 | }
48 |
49 | const statsResponse = await browserAPI.runtime.sendMessage({
50 | type: MESSAGE_TYPES.GET_STORAGE_STATS
51 | });
52 |
53 | if (statsResponse.success) {
54 | setStats(statsResponse.data);
55 | }
56 | } catch (error) {
57 | console.error('Error loading settings:', error);
58 | } finally {
59 | setLoading(false);
60 | }
61 | };
62 |
63 | const handleSettingChange = async (key: keyof AppSettings, value: any) => {
64 | if (!settings) return;
65 |
66 | try {
67 | const newSettings = { ...settings, [key]: value };
68 | const response = await browserAPI.runtime.sendMessage({
69 | type: MESSAGE_TYPES.UPDATE_SETTINGS,
70 | data: newSettings
71 | });
72 |
73 | if (response.success) {
74 | setSettings(newSettings);
75 | setMessage({ type: 'success', text: 'Settings saved successfully' });
76 | }
77 | } catch (error) {
78 | console.error('Error updating settings:', error);
79 | setMessage({ type: 'error', text: 'Failed to save settings' });
80 | }
81 | };
82 |
83 | const handleClearData = async () => {
84 | try {
85 | setLoading(true);
86 | const response = await browserAPI.runtime.sendMessage({
87 | type: MESSAGE_TYPES.CLEAR_DATA
88 | });
89 |
90 | if (response.success) {
91 | setStats({
92 | usedSpace: 0,
93 | fileCount: 0,
94 | totalSize: 0,
95 | pagesCount: 0,
96 | oldestTimestamp: Date.now(),
97 | uniqueSiteCount: 0
98 | });
99 | setSettings(DEFAULT_SETTINGS);
100 | setMessage({ type: 'success', text: 'Data cleared successfully' });
101 | } else {
102 | throw new Error(response.error || 'Failed to clear data');
103 | }
104 | } catch (error) {
105 | console.error('Error clearing data:', error);
106 | setMessage({ type: 'error', text: 'Failed to clear data' });
107 | } finally {
108 | setClearDialogOpen(false);
109 | setLoading(false);
110 | }
111 | };
112 |
113 | // Create theme based on dark mode setting
114 | const theme = React.useMemo(
115 | () =>
116 | createTheme({
117 | palette: {
118 | mode: 'light',
119 | },
120 | }),
121 | []
122 | );
123 |
124 | if (loading) {
125 | return (
126 |
127 |
128 |
129 | );
130 | }
131 |
132 | return (
133 |
134 |
143 |
144 |
145 |
146 | Source Detector - Settings
147 |
148 |
149 |
150 |
151 |
152 | setMessage(null)}
157 | />
158 |
159 | {stats && (
160 |
161 | Storage used: {formatBytes(stats.usedSpace)} • {stats.fileCount} Source Maps Found on {stats.uniqueSiteCount} {stats.uniqueSiteCount === 1 ? 'Site' : 'Sites'}
162 |
163 | )}
164 |
165 |
166 |
167 |
173 |
174 | handleSettingChange('cleanupThreshold', value)}
180 | />
181 |
182 |
183 |
184 |
185 |
186 | }
190 | onClick={() => setClearDialogOpen(true)}
191 | >
192 | Clear Data
193 |
194 |
195 |
196 |
197 |
213 |
214 |
215 |
216 | );
217 | }
--------------------------------------------------------------------------------
/src/pages/settings/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Source Detector Settings
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/pages/settings/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { ThemeProvider, CssBaseline } from '@mui/material';
4 | import { useAppTheme } from '@/theme';
5 | import App from './App';
6 |
7 | function Root() {
8 | const theme = useAppTheme();
9 |
10 | return (
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | createRoot(document.getElementById('root')!).render(
19 |
20 |
21 |
22 | );
--------------------------------------------------------------------------------
/src/popup/App.tsx:
--------------------------------------------------------------------------------
1 | import { MESSAGE_TYPES } from '@/background/constants';
2 | import { formatBytes } from '@/background/utils';
3 | import { Toast } from '@/components/Toast';
4 | import { CrxFile, PageData, ParsedCrxFile, SourceMapFile, StorageStats } from '@/types';
5 | import { isExtensionPage } from '@/utils/isExtensionPage';
6 | import { parseCrxFile } from '@/utils/parseCrxFile';
7 | import { SourceMapDownloader } from '@/utils/sourceMapDownloader';
8 | import { groupSourceMapFiles } from '@/utils/sourceMapUtils';
9 | import {
10 | CloudDownload as CloudDownloadIcon,
11 | ListAlt as ListAltIcon,
12 | Settings as SettingsIcon,
13 | CircleOutlined,
14 | CheckCircle
15 | } from '@mui/icons-material';
16 | import {
17 | Box,
18 | Button,
19 | CircularProgress,
20 | IconButton,
21 | Tooltip,
22 | Typography
23 | } from '@mui/material';
24 | import JSZip from 'jszip';
25 | import { useEffect, useMemo, useState } from 'react';
26 | import { CrxFileTree } from './components/CrxFileTree';
27 | import { SourceMapTable } from './components/SourceMapTable';
28 | import { openInDesktop } from '@/utils/desktopApp';
29 | import { browserAPI } from '@/utils/browser-polyfill';
30 |
31 | // Helper function to format bundle size
32 | function getBundleSize(files: SourceMapFile[]): string {
33 | const totalSize = files.reduce((sum, file) => sum + file.size, 0);
34 | return formatBytes(totalSize);
35 | }
36 |
37 | export default function App() {
38 | const [loading, setLoading] = useState(true);
39 | const [pageData, setPageData] = useState(null);
40 | const [crxFile, setCrxFile] = useState(null);
41 | const [parsed, setParsed] = useState(null);
42 | const [stats, setStats] = useState(null);
43 | const [downloading, setDownloading] = useState<{ [key: string]: boolean }>({});
44 | const [downloadingAll, setDownloadingAll] = useState(false);
45 | const [toast, setToast] = useState<{
46 | open: boolean;
47 | message: string;
48 | severity: 'success' | 'error' | 'info' | 'warning';
49 | }>({
50 | open: false,
51 | message: '',
52 | severity: 'info'
53 | });
54 | const [serverStatus, setServerStatus] = useState(false);
55 |
56 | useEffect(() => {
57 | loadData();
58 | }, []);
59 |
60 | useEffect(() => {
61 | // Check initial server status
62 | browserAPI.runtime.sendMessage({
63 | type: MESSAGE_TYPES.GET_SERVER_STATUS
64 | }).then(response => {
65 | if (response.success) {
66 | setServerStatus(response.data.isOnline);
67 | }
68 | });
69 |
70 | // Listen for server status changes
71 | const listener = (message: any) => {
72 | if (message.type === MESSAGE_TYPES.SERVER_STATUS_CHANGED) {
73 | setServerStatus(message.data.isOnline);
74 | }
75 | };
76 | browserAPI.runtime.onMessage.addListener(listener);
77 | return () => browserAPI.runtime.onMessage.removeListener(listener);
78 | }, []);
79 |
80 | const loadData = async () => {
81 | try {
82 | console.log('loadData')
83 | const [tab] = await browserAPI.tabs.query({ active: true, currentWindow: true });
84 | console.log('tab.url', tab.url)
85 | if (!tab.url) return;
86 | if (isExtensionPage(tab.url)) {
87 | console.log('isExtensionPage', tab.url)
88 | const response = await browserAPI.runtime.sendMessage({
89 | type: MESSAGE_TYPES.GET_CRX_FILE,
90 | data: { url: tab.url }
91 | });
92 | console.log('response', response);
93 | if (response.success && response.data) {
94 | setCrxFile(response.data);
95 | const result = await parseCrxFile(response.data.crxUrl);
96 | setParsed(result);
97 | }
98 | } else {
99 | console.log('is not extension page', tab.url)
100 | const response = await browserAPI.runtime.sendMessage({
101 | type: MESSAGE_TYPES.GET_PAGE_DATA,
102 | data: { url: tab.url }
103 | });
104 | console.log('response', response)
105 | setPageData(response.data);
106 | }
107 | const statsResponse = await browserAPI.runtime.sendMessage({
108 | type: MESSAGE_TYPES.GET_STORAGE_STATS
109 | });
110 | console.log('statsResponse', statsResponse)
111 | setStats(statsResponse.data);
112 | } catch (error) {
113 | console.error('Error loading data:', error);
114 | } finally {
115 | setLoading(false);
116 | }
117 | };
118 |
119 | const handleViewAllPages = () => {
120 | openInDesktop('handleViewAllPages', serverStatus, {});
121 | };
122 |
123 | const handleDownload = async (file: SourceMapFile) => {
124 | try {
125 | setDownloading(prev => ({ ...prev, [file.id]: true }));
126 | await SourceMapDownloader.downloadSingle(file, {
127 | onError: (error) => {
128 | showToast(error.message, 'error');
129 | }
130 | });
131 | showToast('Download completed successfully', 'success');
132 | } catch (error) {
133 | showToast('Failed to download file', 'error');
134 | } finally {
135 | setDownloading(prev => ({ ...prev, [file.id]: false }));
136 | }
137 | };
138 |
139 | const handleVersionMenuOpen = (groupUrl: string) => {
140 | openInDesktop('handleVersionMenuOpen', serverStatus, { groupUrl });
141 | };
142 |
143 | const handleDownloadAll = async () => {
144 | if (crxFile && parsed) {
145 | try {
146 | setDownloadingAll(true);
147 | // Create a new zip file
148 | const newZip = new JSZip();
149 |
150 | // Add the original CRX file directly
151 | newZip.file('extension.crx', parsed.blob);
152 |
153 | // Create a folder for parsed files
154 | const parsedFolder = newZip.folder('parsed');
155 | if (!parsedFolder) {
156 | throw new Error('Failed to create parsed folder');
157 | }
158 |
159 | // Get all files from the parsed CRX file
160 | const zip = parsed.zip;
161 | await Promise.all(
162 | Object.keys(zip.files).map(async (path) => {
163 | const zipObject = zip.files[path];
164 | if (!zipObject.dir) {
165 | const content = await zipObject.async('uint8array');
166 | parsedFolder.file(path, content);
167 | }
168 | })
169 | );
170 |
171 | // Generate and download the zip
172 | const blob = await newZip.generateAsync({ type: 'blob' });
173 | const url = URL.createObjectURL(blob);
174 | const a = document.createElement('a');
175 | a.href = url;
176 | // Use page title for the zip file name, fallback to 'extension-files' if no title
177 | const safeTitle = crxFile.pageTitle.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'extension-files';
178 | a.download = `${safeTitle}.zip`;
179 | document.body.appendChild(a);
180 | a.click();
181 | document.body.removeChild(a);
182 | URL.revokeObjectURL(url);
183 |
184 | showToast('All files downloaded successfully', 'success');
185 | } catch (error) {
186 | console.error('Error downloading files:', error);
187 | showToast('Failed to download files', 'error');
188 | } finally {
189 | setDownloadingAll(false);
190 | }
191 | } else if (pageData?.files.length) {
192 | try {
193 | setDownloadingAll(true);
194 | const latestVersions = groupedFiles.map(group => group.versions[0]);
195 | await SourceMapDownloader.downloadAllLatest(latestVersions, pageData.url, {
196 | onError: (error) => {
197 | showToast(error.message, 'error');
198 | }
199 | });
200 | showToast('All files downloaded successfully', 'success');
201 | } catch (error) {
202 | console.error('Error downloading files:', error);
203 | showToast('Failed to download files', 'error');
204 | } finally {
205 | setDownloadingAll(false);
206 | }
207 | }
208 | };
209 |
210 | const showToast = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'info') => {
211 | setToast({
212 | open: true,
213 | message,
214 | severity
215 | });
216 | };
217 |
218 | const handleCloseToast = () => {
219 | setToast(prev => ({ ...prev, open: false }));
220 | };
221 |
222 | const handleCrxFileDownload = async (path: string) => {
223 | if (!parsed) return;
224 | try {
225 | setDownloading(prev => ({ ...prev, [path]: true }));
226 | const file = parsed.zip.files[path];
227 | if (!file) {
228 | throw new Error('File not found');
229 | }
230 | const content = await file.async('blob');
231 | const url = URL.createObjectURL(content);
232 | const a = document.createElement('a');
233 | a.href = url;
234 | a.download = path.split('/').pop() || path;
235 | document.body.appendChild(a);
236 | a.click();
237 | document.body.removeChild(a);
238 | URL.revokeObjectURL(url);
239 | showToast('File downloaded successfully', 'success');
240 | } catch (error) {
241 | console.error('Error downloading file:', error);
242 | showToast('Failed to download file', 'error');
243 | } finally {
244 | setDownloading(prev => ({ ...prev, [path]: false }));
245 | }
246 | };
247 |
248 | const groupedFiles = useMemo(() => {
249 | if (!pageData?.files) return [];
250 | return groupSourceMapFiles(pageData.files).sort((a, b) => {
251 | const aFilename = a.url.split('/').pop() || '';
252 | const bFilename = b.url.split('/').pop() || '';
253 | return aFilename.localeCompare(bFilename);
254 | });
255 | }, [pageData?.files]);
256 |
257 | const handleOpenDesktopApp = () => {
258 | // Use the existing openInDesktop function which handles fallback
259 | openInDesktop('handleOpenDesktopApp', serverStatus, {
260 | type: crxFile ? 'crx-files' : 'source-files',
261 | url: crxFile ? crxFile.crxUrl : pageData?.url
262 | });
263 | };
264 |
265 | if (loading) {
266 | return (
267 |
268 |
269 |
270 |
271 |
272 | );
273 | }
274 |
275 | return (
276 |
284 | {/* Fixed Header */}
285 |
296 |
297 |
298 | {crxFile ? 'Extension Files' : 'Source Maps'}
299 |
300 |
301 |
302 |
307 | {serverStatus ? (
308 |
309 | ) : (
310 |
311 | )}
312 |
313 |
314 | {((groupedFiles.length > 0 && groupedFiles.map(g => g.versions[0]).reduce((sum, file) => sum + file.size, 0) > 0) || (crxFile && (parsed?.size || 0 + crxFile.size) > 0)) && (
315 | g.versions[0]))})`
318 | }>
319 |
320 | : }
322 | onClick={handleDownloadAll}
323 | disabled={downloadingAll}
324 | size="small"
325 | variant="outlined"
326 | sx={{ mr: 1 }}
327 | >
328 | {downloadingAll ? 'Downloading...' : crxFile ?
329 | `Download All (${formatBytes(parsed?.size || 0 + crxFile.size)})` :
330 | `Download Latest (${getBundleSize(groupedFiles.map(g => g.versions[0]))})`
331 | }
332 |
333 |
334 |
335 | )}
336 |
337 |
338 |
339 |
340 |
341 | browserAPI.runtime.openOptionsPage()}>
342 |
343 |
344 |
345 |
346 |
347 |
348 | {/* Scrollable Content */}
349 |
350 | {crxFile ? (
351 |
356 | ) : groupedFiles.length > 0 ? (
357 |
363 | ) : (
364 |
365 |
366 | No {crxFile ? 'files' : 'source maps'} found on this page
367 |
368 |
369 | )}
370 |
371 |
372 | {/* Fixed Footer */}
373 | {stats && (
374 |
384 |
385 |
386 | {`Storage Used: ${formatBytes(stats.usedSpace)}`}
387 |
388 |
389 | {stats.fileCount} Source Maps Found on {stats.uniqueSiteCount} {stats.uniqueSiteCount === 1 ? 'Site' : 'Sites'}
390 |
391 |
392 |
393 | )}
394 |
395 |
401 |
402 | );
403 | }
--------------------------------------------------------------------------------
/src/popup/components/CrxFileTree.tsx:
--------------------------------------------------------------------------------
1 | import { getFileIcon } from '@/components/fileIcon';
2 | import { ParsedCrxFile } from '@/types';
3 | import { formatBytes } from '@/utils/format';
4 | import {
5 | ChevronRight,
6 | CloudDownload,
7 | ExpandMore,
8 | Folder,
9 | } from '@mui/icons-material';
10 | import { Box, IconButton, Tooltip, Typography } from '@mui/material';
11 | import { TreeItem as MuiTreeItem, SimpleTreeView as MuiTreeView } from '@mui/x-tree-view';
12 | import JSZip from 'jszip';
13 | import React, { useEffect, useState } from 'react';
14 | import { LoadingScreen } from './LoadingScreen';
15 |
16 | interface FileNode {
17 | name: string;
18 | path: string;
19 | size?: number;
20 | isDirectory?: boolean;
21 | children: { [key: string]: FileNode };
22 | }
23 |
24 | interface Props {
25 | crxUrl: string;
26 | parsed: ParsedCrxFile | null;
27 | onDownload: (path: string) => void;
28 | }
29 |
30 | const TreeItem = MuiTreeItem as any;
31 | const TreeView = MuiTreeView as any;
32 |
33 | export function CrxFileTree({ crxUrl, parsed, onDownload }: Props) {
34 | const [expanded, setExpanded] = useState([]);
35 | const [fileTree, setFileTree] = useState(null);
36 | const [loading, setLoading] = useState(true);
37 |
38 | useEffect(() => {
39 | const loadFileTree = async () => {
40 | try {
41 | setLoading(true);
42 | if (parsed?.zip) {
43 | const tree = await buildFileTreeFromJszip(parsed.zip);
44 | setFileTree(tree);
45 | }
46 | } catch (error) {
47 | console.error('Error loading file tree:', error);
48 | } finally {
49 | setLoading(false);
50 | }
51 | };
52 | loadFileTree();
53 | }, [parsed]);
54 |
55 | const buildFileTreeFromJszip = async (jszip: JSZip): Promise => {
56 | const root: FileNode = {
57 | name: 'root',
58 | path: '',
59 | isDirectory: true,
60 | children: {},
61 | };
62 |
63 | // Process each file in the zip
64 | for (const [path, file] of Object.entries(jszip.files)) {
65 | if (file.dir) continue; // Skip directory entries as we'll create them implicitly
66 |
67 | // Split the path into segments
68 | const segments = path.split('/');
69 | let currentNode = root;
70 |
71 | // Create/traverse the folder structure
72 | for (let i = 0; i < segments.length - 1; i++) {
73 | const segment = segments[i];
74 | if (!currentNode.children[segment]) {
75 | console.log('create folder', segment, segments.slice(0, i + 1).join('/'))
76 | currentNode.children[segment] = {
77 | name: segment,
78 | path: segments.slice(0, i + 1).join('/'),
79 | isDirectory: true,
80 | children: {},
81 | };
82 | }
83 | currentNode = currentNode.children[segment];
84 | }
85 |
86 | // Add the file to its parent folder
87 | const fileName = segments[segments.length - 1];
88 | try {
89 | const fileData = await file.async('uint8array');
90 | console.log('path', path, fileName, fileData.length)
91 | currentNode.children[fileName] = {
92 | name: fileName,
93 | path: path,
94 | size: fileData.length,
95 | isDirectory: false,
96 | children: {}, // Empty children for files
97 | };
98 | } catch (error) {
99 | console.error(`Error processing file ${path}:`, error);
100 | console.log('create file- error ', fileName, path)
101 | currentNode.children[fileName] = {
102 | name: fileName,
103 | path: path,
104 | isDirectory: false,
105 | children: {},
106 | };
107 | }
108 | }
109 |
110 | return root;
111 | };
112 |
113 | const renderTree = (node: FileNode, nodeId: string) => {
114 | const label = (
115 |
116 | {node.isDirectory ? (
117 |
118 | ) : (
119 | getFileIcon(node.name)
120 | )}
121 |
122 | {node.name}
123 |
124 | {!node.isDirectory && (
125 | {
128 | e.stopPropagation();
129 | onDownload(node.path);
130 | }}
131 | sx={{ ml: 'auto' }}
132 | >
133 |
134 |
135 | )}
136 |
137 | );
138 |
139 | return (
140 |
145 | {Object.entries(node.children).map(([childName, childNode]) =>
146 | renderTree(childNode, `${node.path || nodeId}-${childName}`)
147 | )}
148 |
149 | );
150 | };
151 |
152 | const handleToggle = (_event: React.SyntheticEvent, nodeIds: string[]) => {
153 | setExpanded(nodeIds);
154 | };
155 |
156 | if (loading) {
157 | return
158 | }
159 |
160 | if (!fileTree) {
161 | return
162 |
163 |
164 | No files found on this page
165 |
166 |
167 |
168 | }
169 |
170 | return (
171 |
172 | }
174 | defaultExpandIcon={}
175 | expanded={expanded}
176 | onNodeToggle={handleToggle}
177 | sx={{ flexGrow: 1 }}
178 | >
179 | {Object.entries(fileTree.children).map(([name, node]) =>
180 | renderTree(node, name)
181 | )}
182 |
183 |
184 | );
185 | }
--------------------------------------------------------------------------------
/src/popup/components/LoadingScreen.tsx:
--------------------------------------------------------------------------------
1 | import { Box, CircularProgress } from '@mui/material';
2 |
3 | export function LoadingScreen() {
4 | return (
5 |
12 |
13 |
14 | );
15 | }
--------------------------------------------------------------------------------
/src/popup/components/SourceMapTable.tsx:
--------------------------------------------------------------------------------
1 | import { getFileIcon } from '@/components/fileIcon';
2 | import { SourceMapFile } from '@/types';
3 | import { formatBytes } from '@/utils/format';
4 | import { GroupedSourceMapFile } from '@/utils/sourceMapUtils';
5 | import { CloudDownload as CloudDownloadIcon, History as HistoryIcon } from '@mui/icons-material';
6 | import {
7 | Box,
8 | CircularProgress,
9 | IconButton,
10 | Paper,
11 | Table,
12 | TableBody,
13 | TableCell,
14 | TableContainer,
15 | TableHead,
16 | TableRow,
17 | Tooltip,
18 | Typography
19 | } from '@mui/material';
20 |
21 | interface Props {
22 | groupedFiles: GroupedSourceMapFile[];
23 | onDownload: (file: SourceMapFile) => void;
24 | onVersionMenuOpen: (groupUrl: string) => void;
25 | downloading: { [key: string]: boolean };
26 | }
27 |
28 | export function SourceMapTable({ groupedFiles, onDownload, onVersionMenuOpen, downloading }: Props) {
29 | return (
30 |
38 |
54 |
62 |
63 | Source File
64 | Latest Version
65 | Previous Versions
66 |
67 |
68 |
69 | {groupedFiles.map((group) => (
70 |
71 |
76 |
77 |
80 | {getFileIcon(group.url.split('/').pop())}
81 |
91 | {group.url.split('/').pop()}
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | onDownload(group.versions[0])}
103 | disabled={downloading[group.versions[0].id]}
104 | >
105 | {downloading[group.versions[0].id] ? (
106 |
107 | ) : (
108 |
109 | )}
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | {group.versions.length > 1 && (
118 |
119 |
120 | onVersionMenuOpen(group.url)}
123 | >
124 |
125 |
126 |
127 |
128 | )}
129 |
130 |
131 |
132 | ))}
133 |
134 |
135 |
136 | );
137 | }
--------------------------------------------------------------------------------
/src/popup/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Source Detector
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/popup/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import { ThemeProvider, CssBaseline } from '@mui/material';
4 | import { useAppTheme } from '@/theme';
5 | import App from './App';
6 |
7 | function Root() {
8 | const theme = useAppTheme();
9 |
10 | return (
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | createRoot(document.getElementById('root')!).render(
19 |
20 |
21 |
22 | );
--------------------------------------------------------------------------------
/src/storage/database.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_SETTINGS } from '@/background/constants';
2 | import { AppSettings, Page, PageSourceMap, SourceMapFile, CrxFile, SyncStatus } from '@/types';
3 | import Dexie from 'dexie';
4 |
5 | const DB_VERSION = 1;
6 | const DB_NAME = 'SourceDetectorDB';
7 |
8 | export class SourceDetectorDB extends Dexie {
9 | sourceMapFiles!: Dexie.Table;
10 | pages!: Dexie.Table;
11 | pageSourceMaps!: Dexie.Table;
12 | settings!: Dexie.Table;
13 | crxFiles!: Dexie.Table;
14 | syncStatus!: Dexie.Table;
15 |
16 | constructor() {
17 | super(DB_NAME);
18 |
19 | this.version(DB_VERSION).stores({
20 | sourceMapFiles: '++id, url, timestamp, fileType, isLatest, hash, size',
21 | pages: '++id, url, timestamp',
22 | pageSourceMaps: '++id, pageId, sourceMapId, timestamp',
23 | settings: 'id',
24 | crxFiles: '++id, pageUrl, pageTitle, crxUrl, blob, size, timestamp, count, contentHash',
25 | syncStatus: 'tableName'
26 | });
27 | }
28 |
29 | async addPage(page: Omit): Promise {
30 | const id = await this.pages.add(page as any);
31 | return { ...page, id };
32 | }
33 |
34 | async addPageSourceMap(relation: Omit): Promise {
35 | const id = await this.pageSourceMaps.add(relation as any);
36 | return { ...relation, id };
37 | }
38 |
39 | async addSourceMapFile(file: Omit): Promise {
40 | const id = await this.sourceMapFiles.add(file as any);
41 | return { ...file, id };
42 | }
43 |
44 | async addCrxFile(file: Omit): Promise {
45 | const id = await this.crxFiles.add(file as any);
46 | return { ...file, id };
47 | }
48 |
49 | async updateCrxFile(crxFile: CrxFile): Promise {
50 | await this.crxFiles.put(crxFile);
51 | }
52 |
53 | async getCrxFileByPageUrl(pageUrl: string): Promise {
54 | return this.crxFiles
55 | .where('pageUrl')
56 | .equals(pageUrl)
57 | .first();
58 | }
59 |
60 | async getSettings(): Promise {
61 | try {
62 | const settings = await this.settings.toArray();
63 | if (settings.length === 0) {
64 | await this.settings.add(DEFAULT_SETTINGS);
65 | return DEFAULT_SETTINGS;
66 | }
67 | return settings[0];
68 | } catch (error) {
69 | console.error('Error in getSettings:', error);
70 | throw error;
71 | }
72 | }
73 |
74 | async updateSettings(settings: Partial): Promise {
75 | try {
76 | const currentSettings = await this.getSettings();
77 | const updatedSettings = {
78 | ...currentSettings,
79 | ...settings,
80 | id: 'settings'
81 | };
82 | await this.settings.put(updatedSettings);
83 | } catch (error) {
84 | console.error('Error in updateSettings:', error);
85 | throw error;
86 | }
87 | }
88 |
89 | async getStorageStats() {
90 | // Run all queries in parallel for better performance
91 | let totalSize = 0;
92 | let oldestTimestamp = Date.now();
93 |
94 | // Count unique sites using index, processing one record at a time
95 | const uniqueSites = new Set();
96 |
97 | const [fileCount, pagesCount] = await Promise.all([
98 |
99 | // Get file count without loading data
100 | this.sourceMapFiles.count(),
101 |
102 | // Get page count without loading data
103 | this.pages.count(),
104 |
105 | // Get total size and oldest file in a single table scan
106 | this.sourceMapFiles.each((file: SourceMapFile) => {
107 | totalSize += file.size;
108 | if (file.timestamp < oldestTimestamp) {
109 | oldestTimestamp = file.timestamp;
110 | }
111 | }),
112 |
113 | this.pages
114 | .orderBy('url')
115 | .each(page => {
116 | try {
117 | uniqueSites.add(new URL(page.url).hostname);
118 | } catch {
119 | uniqueSites.add(page.url);
120 | }
121 | })
122 | ]);
123 |
124 | return {
125 | usedSpace: totalSize,
126 | totalSize: totalSize,
127 | fileCount,
128 | uniqueSiteCount: uniqueSites.size,
129 | pagesCount,
130 | oldestTimestamp: oldestTimestamp
131 | };
132 | }
133 |
134 | async getPageFiles(pageUrl: string): Promise {
135 | const page = await this.pages.where('url').equals(pageUrl).first();
136 | if (!page) return [];
137 |
138 | const pageSourceMaps = await this.pageSourceMaps
139 | .where('pageId')
140 | .equals(page.id)
141 | .toArray();
142 |
143 | const sourceMapIds = pageSourceMaps.map(psm => psm.sourceMapId);
144 | return await this.sourceMapFiles
145 | .where('id')
146 | .anyOf(sourceMapIds)
147 | .toArray();
148 | }
149 |
150 | async addSourceMapToPage(pageUrl: string, pageTitle: string, sourceMap: SourceMapFile): Promise {
151 | // Get or create page
152 | let page = await this.pages.where('url').equals(pageUrl).first();
153 |
154 | if (!page) {
155 | page = await this.addPage({
156 | url: pageUrl,
157 | title: pageTitle,
158 | timestamp: Date.now()
159 | });
160 | }
161 |
162 | // Check for existing relation
163 | const existingRelation = await this.pageSourceMaps
164 | .where('pageId').equals(page.id)
165 | .and(psm => psm.sourceMapId === sourceMap.id)
166 | .first();
167 | if (existingRelation) {
168 | return;
169 | }
170 |
171 | // Create page-sourcemap relation
172 | await this.addPageSourceMap({
173 | pageId: page.id,
174 | sourceMapId: sourceMap.id,
175 | timestamp: Date.now()
176 | });
177 | }
178 |
179 | // Sync status methods
180 | async getLastSyncId(table: string): Promise {
181 | const status = await this.syncStatus.get(table);
182 | return status?.lastId || 0;
183 | }
184 |
185 | async updateLastSyncId(table: string, id: number): Promise {
186 | await this.syncStatus.put({ tableName: table, lastId: id });
187 | }
188 |
189 | async getModifiedData(table: string, lastId: number, chunkSize: number): Promise {
190 | const tableMap = {
191 | sourceMapFiles: this.sourceMapFiles,
192 | pages: this.pages,
193 | pageSourceMaps: this.pageSourceMaps,
194 | crxFiles: this.crxFiles
195 | };
196 |
197 | const dbTable = tableMap[table as keyof typeof tableMap];
198 | if (!dbTable) return [];
199 |
200 | return await dbTable
201 | .where('id')
202 | .above(lastId)
203 | .limit(chunkSize)
204 | .toArray();
205 | }
206 | }
--------------------------------------------------------------------------------
/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { createTheme } from '@mui/material/styles';
2 | import { useEffect, useState } from 'react';
3 | import { MESSAGE_TYPES } from '@/background/constants';
4 |
5 | export function useAppTheme() {
6 | const [darkMode, setDarkMode] = useState(false);
7 |
8 | useEffect(() => {
9 | // Load theme setting
10 | chrome.runtime.sendMessage({
11 | type: MESSAGE_TYPES.GET_SETTINGS
12 | }).then(response => {
13 | if (response.success) {
14 | setDarkMode(response.data.darkMode);
15 | }
16 | });
17 | }, []);
18 |
19 | return createTheme({
20 | palette: {
21 | mode: darkMode ? 'dark' : 'light',
22 | primary: {
23 | main: '#1976d2',
24 | },
25 | secondary: {
26 | main: '#dc004e',
27 | },
28 | },
29 | components: {
30 | MuiButton: {
31 | styleOverrides: {
32 | root: {
33 | textTransform: 'none',
34 | },
35 | },
36 | },
37 | MuiAppBar: {
38 | styleOverrides: {
39 | root: {
40 | boxShadow: 'none',
41 | borderBottom: '1px solid rgba(0, 0, 0, 0.12)',
42 | },
43 | },
44 | },
45 | },
46 | });
47 | }
--------------------------------------------------------------------------------
/src/types/chrome.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare namespace chrome {
4 | export interface Runtime {
5 | sendMessage: (
6 | message: any,
7 | responseCallback?: (response: T) => void
8 | ) => void;
9 | onMessage: {
10 | addListener: (
11 | callback: (
12 | message: any,
13 | sender: chrome.runtime.MessageSender,
14 | sendResponse: (response?: any) => void
15 | ) => void | boolean
16 | ) => void;
17 | };
18 | }
19 |
20 | export interface Tabs {
21 | query: (queryInfo: {
22 | active: boolean;
23 | currentWindow: boolean;
24 | }) => Promise;
25 | }
26 |
27 | export interface Storage {
28 | local: {
29 | get: (keys?: string | string[] | null) => Promise;
30 | set: (items: { [key: string]: any }) => Promise;
31 | remove: (keys: string | string[]) => Promise;
32 | clear: () => Promise;
33 | };
34 | }
35 | }
--------------------------------------------------------------------------------
/src/types/env.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css';
2 | declare module '*.less';
3 | declare module '*.scss';
4 | declare module '*.svg';
5 | declare module '*.png';
6 | declare module '*.jpg';
7 | declare module '*.jpeg';
8 | declare module '*.gif';
9 | declare module '*.bmp';
10 | declare module '*.tiff';
11 | declare module '*.json' {
12 | const value: any;
13 | export default value;
14 | }
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip';
2 |
3 | export interface Page {
4 | id: number;
5 | url: string;
6 | title: string;
7 | timestamp: number;
8 | }
9 |
10 | export interface PageSourceMap {
11 | id: number;
12 | pageId: number;
13 | sourceMapId: number;
14 | timestamp: number;
15 | }
16 |
17 | export interface SourceMapFile {
18 | id: number;
19 | url: string;
20 | sourceMapUrl: string;
21 | content: string;
22 | originalContent: string;
23 | fileType: 'js' | 'css';
24 | size: number;
25 | timestamp: number;
26 | version: number;
27 | hash: string;
28 | isLatest: boolean;
29 | }
30 |
31 | export interface SourceMapFoundData {
32 | pageTitle: string;
33 | pageUrl: string;
34 | sourceUrl: string;
35 | mapUrl: string;
36 | fileType: 'js' | 'css';
37 | originalContent: string;
38 | }
39 |
40 | export interface PageData {
41 | url: string;
42 | title: string;
43 | timestamp: number;
44 | files: SourceMapFile[];
45 | }
46 |
47 | export interface StorageStats {
48 | usedSpace: number;
49 | totalSize: number;
50 | fileCount: number;
51 | uniqueSiteCount: number;
52 | pagesCount: number;
53 | oldestTimestamp: number;
54 | }
55 |
56 | export interface AppSettings {
57 | id: string;
58 | cleanupThreshold: number;
59 | }
60 |
61 | export interface Message {
62 | type: string;
63 | payload: T;
64 | }
65 |
66 | export interface CrxFile {
67 | id: number;
68 | pageUrl: string;
69 | pageTitle: string;
70 | crxUrl: string;
71 | blob: Blob;
72 | size: number;
73 | timestamp: number;
74 | count: number;
75 | contentHash: string;
76 | }
77 |
78 | export interface ParsedCrxFile {
79 | zip: JSZip;
80 | size: number;
81 | count: number;
82 | timestamp: number;
83 | blob: Blob;
84 | }
85 |
86 | export interface SyncStatus {
87 | tableName: string;
88 | lastId: number;
89 | }
--------------------------------------------------------------------------------
/src/utils/browser-polyfill.ts:
--------------------------------------------------------------------------------
1 | // Create a browser API compatibility layer
2 | import browser from 'webextension-polyfill';
3 |
4 | export const browserAPI = {
5 | action: browser.action,
6 | runtime: browser.runtime,
7 | tabs: browser.tabs,
8 | webRequest: browser.webRequest,
9 | windows: browser.windows
10 | };
11 |
12 | // Helper types for better TypeScript support
13 | export type BrowserStorage = typeof browser.storage;
14 | export type BrowserRuntime = typeof browser.runtime;
15 | export type BrowserTabs = typeof browser.tabs;
16 | export type BrowserAction = typeof browser.action;
17 | export type BrowserWebRequest = typeof browser.webRequest;
--------------------------------------------------------------------------------
/src/utils/crx-to-zip.ts:
--------------------------------------------------------------------------------
1 | import CryptoJS from 'crypto-js';
2 |
3 | /**
4 | * Strips CRX headers from zip
5 | * @param arraybuffer - CRX file data
6 | * @returns Promise that resolves with the ZIP blob and public key
7 | */
8 | export function crxToZip(arraybuffer: ArrayBuffer): Promise<{ zip: Blob; publicKey: string | undefined }> {
9 | return new Promise((resolve, reject) => {
10 | // Definition of crx format: http://developer.chrome.com/extensions/crx.html
11 | const view = new Uint8Array(arraybuffer);
12 |
13 | // 50 4b 03 04
14 | if (view[0] === 80 && view[1] === 75 && view[2] === 3 && view[3] === 4) {
15 | console.warn('Input is not a CRX file, but a ZIP file.');
16 | resolve({ zip: new Blob([arraybuffer], { type: 'application/zip' }), publicKey: undefined });
17 | return;
18 | }
19 |
20 | // 43 72 32 34
21 | if (view[0] !== 67 || view[1] !== 114 || view[2] !== 50 || view[3] !== 52) {
22 | if (isMaybeZipData(view)) {
23 | console.warn('Input is not a CRX file, but possibly a ZIP file.');
24 | resolve({ zip: new Blob([arraybuffer], { type: 'application/zip' }), publicKey: undefined });
25 | return;
26 | }
27 | reject(new Error('Invalid header: Does not start with Cr24.'));
28 | return;
29 | }
30 |
31 | // 02 00 00 00
32 | // 03 00 00 00 CRX3
33 | if (view[4] !== 2 && view[4] !== 3 || view[5] || view[6] || view[7]) {
34 | reject(new Error('Unexpected crx format version number.'));
35 | return;
36 | }
37 |
38 | let zipStartOffset: number;
39 | let publicKeyBase64: string | undefined;
40 |
41 | if (view[4] === 2) {
42 | const publicKeyLength = calcLength(view[8], view[9], view[10], view[11]);
43 | const signatureLength = calcLength(view[12], view[13], view[14], view[15]);
44 | // 16 = Magic number (4), CRX format version (4), lengths (2x4)
45 | zipStartOffset = 16 + publicKeyLength + signatureLength;
46 |
47 | // Public key
48 | publicKeyBase64 = btoa(getBinaryString(view, 16, 16 + publicKeyLength));
49 | } else { // view[4] === 3
50 | // CRX3 - https://cs.chromium.org/chromium/src/components/crx_file/crx3.proto
51 | const crx3HeaderLength = calcLength(view[8], view[9], view[10], view[11]);
52 | // 12 = Magic number (4), CRX format version (4), header length (4)
53 | zipStartOffset = 12 + crx3HeaderLength;
54 |
55 | // Public key
56 | publicKeyBase64 = getPublicKeyFromProtoBuf(view, 12, zipStartOffset);
57 | }
58 |
59 | // addons.opera.com creates CRX3 files by prepending the CRX3 header to the CRX2 data.
60 | if (
61 | // CRX3
62 | view[4] === 3 &&
63 | // 43 72 32 34 - Cr24 = CRX magic
64 | view[zipStartOffset + 0] === 67 &&
65 | view[zipStartOffset + 1] === 114 &&
66 | view[zipStartOffset + 2] === 50 &&
67 | view[zipStartOffset + 3] === 52
68 | ) {
69 | console.warn('Nested CRX: Expected zip data, but found another CRX file instead.');
70 | return crxToZip(arraybuffer.slice(zipStartOffset))
71 | .then(({ zip, publicKey: nestedKey }) => {
72 | if (publicKeyBase64 !== nestedKey) {
73 | console.warn('Nested CRX: pubkey mismatch; found ' + nestedKey);
74 | }
75 | resolve({ zip, publicKey: publicKeyBase64 });
76 | })
77 | .catch(reject);
78 | }
79 |
80 | // Create a new view for the existing buffer, and wrap it in a Blob object.
81 | const zipFragment = new Blob([
82 | new Uint8Array(arraybuffer, zipStartOffset)
83 | ], {
84 | type: 'application/zip'
85 | });
86 | resolve({ zip: zipFragment, publicKey: publicKeyBase64 });
87 | });
88 | }
89 |
90 | function calcLength(a: number, b: number, c: number, d: number): number {
91 | let length = 0;
92 | length += a << 0;
93 | length += b << 8;
94 | length += c << 16;
95 | length += d << 24 >>> 0;
96 | return length;
97 | }
98 |
99 | function getBinaryString(bytesView: Uint8Array, startOffset: number, endOffset: number): string {
100 | let binaryString = '';
101 | for (let i = startOffset; i < endOffset; ++i) {
102 | binaryString += String.fromCharCode(bytesView[i]);
103 | }
104 | return binaryString;
105 | }
106 |
107 | function getPublicKeyFromProtoBuf(bytesView: Uint8Array, startOffset: number, endOffset: number): string | undefined {
108 | function getvarint(): number {
109 | let val = bytesView[startOffset] & 0x7F;
110 | if (bytesView[startOffset++] < 0x80) return val;
111 | val |= (bytesView[startOffset] & 0x7F) << 7;
112 | if (bytesView[startOffset++] < 0x80) return val;
113 | val |= (bytesView[startOffset] & 0x7F) << 14;
114 | if (bytesView[startOffset++] < 0x80) return val;
115 | val |= (bytesView[startOffset] & 0x7F) << 21;
116 | if (bytesView[startOffset++] < 0x80) return val;
117 | val = (val | (bytesView[startOffset] & 0xF) << 28) >>> 0;
118 | if (bytesView[startOffset++] & 0x80) console.warn('proto: not a uint32');
119 | return val;
120 | }
121 |
122 | const publicKeys: string[] = [];
123 | let crxIdBin: Uint8Array | undefined;
124 |
125 | while (startOffset < endOffset) {
126 | const key = getvarint();
127 | const length = getvarint();
128 | if (key === 80002) { // This is ((10000 << 3) | 2) (signed_header_data).
129 | const sigdatakey = getvarint();
130 | const sigdatalen = getvarint();
131 | if (sigdatakey !== 0xA) {
132 | console.warn('proto: Unexpected key in signed_header_data: ' + sigdatakey);
133 | } else if (sigdatalen !== 16) {
134 | console.warn('proto: Unexpected signed_header_data length ' + length);
135 | } else if (crxIdBin) {
136 | console.warn('proto: Unexpected duplicate signed_header_data');
137 | } else {
138 | crxIdBin = bytesView.subarray(startOffset, startOffset + 16);
139 | }
140 | startOffset += sigdatalen;
141 | continue;
142 | }
143 | if (key !== 0x12) {
144 | // Likely 0x1a (sha256_with_ecdsa).
145 | if (key !== 0x1a) {
146 | console.warn('proto: Unexpected key: ' + key);
147 | }
148 | startOffset += length;
149 | continue;
150 | }
151 | // Found 0x12 (sha256_with_rsa); Look for 0xA (public_key).
152 | const keyproofend = startOffset + length;
153 | let keyproofkey = getvarint();
154 | let keyprooflength = getvarint();
155 | // AsymmetricKeyProof could contain 0xA (public_key) or 0x12 (signature).
156 | if (keyproofkey === 0x12) {
157 | startOffset += keyprooflength;
158 | if (startOffset >= keyproofend) {
159 | // signature without public_key...? The protocol definition allows it...
160 | continue;
161 | }
162 | keyproofkey = getvarint();
163 | keyprooflength = getvarint();
164 | }
165 | if (keyproofkey !== 0xA) {
166 | startOffset += keyprooflength;
167 | console.warn('proto: Unexpected key in AsymmetricKeyProof: ' + keyproofkey);
168 | continue;
169 | }
170 | if (startOffset + keyprooflength > endOffset) {
171 | console.warn('proto: size of public_key field is too large');
172 | break;
173 | }
174 | // Found 0xA (public_key).
175 | publicKeys.push(getBinaryString(bytesView, startOffset, startOffset + keyprooflength));
176 | startOffset = keyproofend;
177 | }
178 |
179 | if (!publicKeys.length) {
180 | console.warn('proto: Did not find any public key');
181 | return undefined;
182 | }
183 | if (!crxIdBin) {
184 | console.warn('proto: Did not find crx_id');
185 | return undefined;
186 | }
187 |
188 | const crxIdHex = CryptoJS.enc.Latin1.parse(getBinaryString(crxIdBin, 0, 16)).toString();
189 | for (const publicKey of publicKeys) {
190 | const sha256sum = CryptoJS.SHA256(CryptoJS.enc.Latin1.parse(publicKey)).toString();
191 | if (sha256sum.slice(0, 32) === crxIdHex) {
192 | return btoa(publicKey);
193 | }
194 | }
195 | console.warn('proto: None of the public keys matched with crx_id');
196 | return undefined;
197 | }
198 |
199 | function isMaybeZipData(view: Uint8Array): boolean {
200 | // Find EOCD (0xFFFF is the maximum size of an optional trailing comment).
201 | for (let i = view.length - 22, ii = Math.max(0, i - 0xFFFF); i >= ii; --i) {
202 | if (view[i] === 0x50 && view[i + 1] === 0x4b &&
203 | view[i + 2] === 0x05 && view[i + 3] === 0x06) {
204 | return true;
205 | }
206 | }
207 | return false;
208 | }
--------------------------------------------------------------------------------
/src/utils/desktopApp.ts:
--------------------------------------------------------------------------------
1 | import { browserAPI } from '@/utils/browser-polyfill';
2 |
3 | // Protocol name for the Source Detector desktop app
4 | // This needs to be registered in the desktop app for each operating system
5 | const PROTOCOL = 'source-detector://';
6 |
7 | export type DesktopAction =
8 | | 'handleVersionMenuOpen'
9 | | 'handleViewAllPages'
10 | | 'handleOpenDesktopApp';
11 |
12 | const getDesktopAppUrl = (type: DesktopAction, options?: any) => {
13 | let desktopUrl = '';
14 | switch (type) {
15 | case 'handleVersionMenuOpen':
16 | desktopUrl = `${PROTOCOL}source-files?url=${encodeURIComponent(options.url)}`;
17 | break;
18 | case 'handleViewAllPages':
19 | desktopUrl = `${PROTOCOL}`;
20 | break;
21 | case 'handleOpenDesktopApp':
22 | const { type, url } = options;
23 | desktopUrl = `${PROTOCOL}${type}?url=${encodeURIComponent(url)}`;
24 | break;
25 | }
26 | return desktopUrl;
27 | }
28 |
29 | export async function openInDesktop(type: DesktopAction, serverStatus: boolean, options: object) {
30 | try {
31 | // Try to open in desktop app first
32 | try {
33 | // Create the desktop app URL
34 | const desktopUrl = getDesktopAppUrl(type, options);
35 |
36 | // Try to open the desktop app
37 | window.open(desktopUrl, '_self');
38 | if (serverStatus) {
39 | return;
40 | }
41 |
42 | // Set a timeout to check if the desktop app opened
43 | setTimeout(() => {
44 | // If we're still here after a short delay, the desktop app probably didn't open
45 | openWebVersion(type, options);
46 | }, 200);
47 | } catch (error) {
48 | console.error('Failed to open desktop app:', error);
49 | openWebVersion(type, options);
50 | }
51 | } catch (error) {
52 | console.error('Error checking desktop app settings:', error);
53 | openWebVersion(type, options);
54 | }
55 | }
56 |
57 | function openWebVersion(type: string, options?: any) {
58 | browserAPI.tabs.create({
59 | url: browserAPI.runtime.getURL(`pages/desktop/index.html?type=${type}${options ? `&options=${encodeURIComponent(JSON.stringify(options))}` : ''}`)
60 | });
61 | }
--------------------------------------------------------------------------------
/src/utils/format.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Format bytes to a readable string
3 | */
4 | export function formatBytes(bytes: number): string {
5 | if (bytes === 0) return '0 B';
6 |
7 | const k = 1024;
8 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
9 | const i = Math.floor(Math.log(bytes) / Math.log(k));
10 |
11 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/isExtensionPage.ts:
--------------------------------------------------------------------------------
1 | export function isExtensionPage(url: string): boolean {
2 | return url.startsWith('https://chrome.google.com/webstore/detail/') ||
3 | url.startsWith('https://chromewebstore.google.com/detail/') ||
4 | url.startsWith('chrome-extension://') ||
5 | url.startsWith('moz-extension://') || // Firefox
6 | url.startsWith('edge-extension://'); // Edge
7 | }
--------------------------------------------------------------------------------
/src/utils/parseCrxFile.ts:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip';
2 | import { crxToZip } from './crx-to-zip';
3 | import { ParsedCrxFile, CrxFile } from '../types';
4 |
5 | const CACHE_EXPIRATION = 5000; // 5 seconds
6 | const cache = new Map();
7 |
8 | export async function parsedCrxFileFromCrxFile(crxFile: CrxFile): Promise {
9 | const blob = crxFile.blob;
10 | return parsedCrxFileFromBlob(blob, crxFile.timestamp);
11 | }
12 |
13 | export async function parsedCrxFileFromBlob(blob: Blob, timestamp: number): Promise {
14 | console.log('blob', blob, typeof blob);
15 | const buffer = await blob.arrayBuffer();
16 | console.log('buffer', buffer);
17 | // Convert CRX to ZIP
18 | const { zip } = await crxToZip(buffer);
19 | console.log('zip', zip);
20 | const jszip = await JSZip.loadAsync(zip);
21 | console.log('jszip', jszip);
22 | const parsedFileCount = Object.values(jszip.files).filter(file => !file.dir).length;
23 | const size = zip.size;
24 | return {
25 | zip: jszip,
26 | count: parsedFileCount,
27 | timestamp,
28 | blob,
29 | size
30 | };
31 | }
32 |
33 | export async function parsedCrxFileFromUrl(crxUrl: string): Promise {
34 | try {
35 | console.log('parseCrxFile', crxUrl);
36 | const cached = cache.get(crxUrl);
37 | console.log('cached', cached);
38 | if (cached && (Date.now() - cached.timestamp) < CACHE_EXPIRATION) {
39 | console.log('cached', cached);
40 | return cached;
41 | }
42 |
43 | const response = await fetch(crxUrl);
44 | console.log('response', response);
45 | const blob = await response.blob();
46 |
47 | const result = await parsedCrxFileFromBlob(blob, Date.now());
48 |
49 | if (result) {
50 | cache.set(crxUrl, result);
51 | }
52 |
53 | return result;
54 | } catch (error) {
55 | console.error('Error parsing CRX file:', error);
56 | return null;
57 | }
58 | }
59 |
60 | // Parse CRX file and extract source maps
61 | export async function parseCrxFile(crxUrl: string): Promise {
62 | if (typeof crxUrl === 'object') {
63 | return parsedCrxFileFromCrxFile(crxUrl);
64 | } else {
65 | return parsedCrxFileFromUrl(crxUrl);
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/utils/sourceMapDownloader.ts:
--------------------------------------------------------------------------------
1 | import JSZip from 'jszip';
2 | import { SourceMapConsumer } from 'source-map-js';
3 | import { SourceMapFile } from '@/types';
4 |
5 | interface DownloadOptions {
6 | onError?: (error: Error) => void;
7 | }
8 |
9 | export class SourceMapDownloader {
10 | private static async createZipWithSourceMap(
11 | file: SourceMapFile,
12 | _zip: JSZip,
13 | compiledFolder: JSZip,
14 | sourceFolder: JSZip
15 | ) {
16 | // Parse the source map
17 | const rawSourceMap = JSON.parse(file.content);
18 | const consumer = new SourceMapConsumer(rawSourceMap);
19 |
20 | // Get full path including domain for compiled file
21 | const originalUrl = new URL(file.url);
22 | const compiledPath = `${originalUrl.hostname}${originalUrl.pathname}`;
23 |
24 | // Add original file and source map maintaining the full path structure
25 | compiledFolder.file(compiledPath, file.originalContent);
26 | compiledFolder.file(`${compiledPath}.map`, file.content);
27 |
28 | // Process source files maintaining their relative paths
29 | const processedPaths = new Set();
30 | consumer.sources.forEach((sourcePath) => {
31 | if (processedPaths.has(sourcePath)) return;
32 | processedPaths.add(sourcePath);
33 |
34 | const sourceContent = consumer.sourceContentFor(sourcePath);
35 | if (sourceContent) {
36 | // Clean up source path (remove leading slash and any '../' or './')
37 | const cleanPath = sourcePath
38 | .replace(/^\//, '') // Remove leading /
39 | .replace(/^(\.\.\/)*/, '') // Remove leading ../
40 | .replace(/^(\.\/)*/, ''); // Remove leading ./
41 |
42 | // Add to source folder with full path structure
43 | sourceFolder.file(cleanPath, sourceContent);
44 | }
45 | });
46 |
47 | return { compiledPath, originalUrl };
48 | }
49 |
50 | private static async downloadZip(zip: JSZip, fileName: string) {
51 | const zipBlob = await zip.generateAsync({ type: "blob" });
52 | const url = URL.createObjectURL(zipBlob);
53 | const a = document.createElement('a');
54 | a.href = url;
55 | a.download = fileName;
56 | document.body.appendChild(a);
57 | a.click();
58 | document.body.removeChild(a);
59 | URL.revokeObjectURL(url);
60 | }
61 |
62 | static async downloadSingle(file: SourceMapFile, options?: DownloadOptions) {
63 | try {
64 | const zip = new JSZip();
65 | const compiledFolder = zip.folder("compiled");
66 | const sourceFolder = zip.folder("src");
67 |
68 | if (!compiledFolder || !sourceFolder) {
69 | throw new Error('Failed to create folders');
70 | }
71 |
72 | const { compiledPath, originalUrl } = await this.createZipWithSourceMap(
73 | file,
74 | zip,
75 | compiledFolder,
76 | sourceFolder
77 | );
78 |
79 | const domainName = originalUrl.hostname.replace(/[^a-zA-Z0-9]/g, '_');
80 | const fileName = `${domainName}_${compiledPath.split('/').pop()}_v${file.version}_with_sources.zip`;
81 |
82 | await this.downloadZip(zip, fileName);
83 | } catch (error) {
84 | console.error('Error downloading source map:', error);
85 | options?.onError?.(error as Error);
86 | }
87 | }
88 |
89 | static async downloadAllLatest(files: SourceMapFile[], pageUrl: string, options?: DownloadOptions) {
90 | try {
91 | const zip = new JSZip();
92 | const compiledFolder = zip.folder("compiled");
93 | const sourceFolder = zip.folder("src");
94 |
95 | if (!compiledFolder || !sourceFolder) {
96 | throw new Error('Failed to create folders');
97 | }
98 |
99 | // Process each file
100 | for (const file of files) {
101 | try {
102 | await this.createZipWithSourceMap(
103 | file,
104 | zip,
105 | compiledFolder,
106 | sourceFolder
107 | );
108 | } catch (error) {
109 | console.error(`Error processing file ${file.url}:`, error);
110 | // Continue with other files
111 | }
112 | }
113 |
114 | const domainName = new URL(pageUrl).hostname.replace(/[^a-zA-Z0-9]/g, '_');
115 | const fileName = `${domainName}_all_latest_source_maps.zip`;
116 |
117 | await this.downloadZip(zip, fileName);
118 | } catch (error) {
119 | console.error('Error downloading source maps:', error);
120 | options?.onError?.(error as Error);
121 | }
122 | }
123 | }
--------------------------------------------------------------------------------
/src/utils/sourceMapUtils.ts:
--------------------------------------------------------------------------------
1 | import { SourceMapFile } from '@/types';
2 |
3 | export interface GroupedSourceMapFile {
4 | url: string;
5 | fileType: 'js' | 'css';
6 | versions: SourceMapFile[];
7 | }
8 |
9 | export function groupSourceMapFiles(files: SourceMapFile[]): GroupedSourceMapFile[] {
10 | const groups: { [key: string]: SourceMapFile[] } = {};
11 |
12 | // Group files by URL
13 | files.forEach(file => {
14 | if (!groups[file.url]) {
15 | groups[file.url] = [];
16 | }
17 | groups[file.url].push(file);
18 | });
19 |
20 | // Convert groups to array and sort versions
21 | return Object.entries(groups).map(([url, files]) => ({
22 | url,
23 | fileType: files[0].fileType,
24 | versions: files.sort((a, b) => b.version - a.version) // Sort by version descending
25 | }));
26 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "es6",
7 | "dom",
8 | "ES2020",
9 | "DOM",
10 | "DOM.Iterable"
11 | ],
12 | "module": "ESNext",
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "strict": true,
20 | "skipLibCheck": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "allowSyntheticDefaultImports": true,
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/*": [
28 | "./src/*"
29 | ]
30 | },
31 | "types": [
32 | "chrome",
33 | "node",
34 | "react",
35 | "webextension-polyfill"
36 | ]
37 | },
38 | "include": [
39 | "src/**/*",
40 | "scripts/**/*"
41 | ]
42 | }
--------------------------------------------------------------------------------