Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84944882a | ||
|
|
fb57fd7002 | ||
|
|
9e53bddd34 | ||
|
|
bfb8f302c0 | ||
|
|
048cd3b6ac | ||
|
|
dff3541dcf | ||
|
|
9679787a41 | ||
|
|
7db6bef352 | ||
|
|
96055d8978 | ||
|
|
988504500e | ||
|
|
41fce44ca9 | ||
|
|
7fb39c835b | ||
|
|
c17b19de70 | ||
|
|
92ecb55d6c | ||
|
|
e497dcae27 | ||
|
|
1cac2f79e9 | ||
|
|
650553e793 | ||
|
|
de676eb498 | ||
|
|
d334286cbd | ||
|
|
538331c847 | ||
|
|
df1af592b6 | ||
|
|
7db5b12629 | ||
|
|
cc3264f058 | ||
|
|
ea12322686 | ||
|
|
5a9e5bc77d | ||
|
|
f489800663 | ||
|
|
49056dc605 | ||
|
|
9567590e47 | ||
|
|
008cb10b30 | ||
|
|
6b8e9a3580 | ||
|
|
6aab10b7d0 | ||
|
|
7de46bf590 | ||
|
|
c67b794f40 | ||
|
|
9872a376c1 | ||
|
|
f34718f352 | ||
|
|
08520a90ff | ||
|
|
264128d1fb | ||
|
|
dbb617f516 |
4
.github/workflows/ci.yaml
vendored
@@ -43,11 +43,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-lint-test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Download extension from build-lint-test job
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tlsn-extension-${{ github.ref_name }}.zip
|
||||
path: ./tlsn-extension-${{ github.ref_name }}.zip
|
||||
path: .
|
||||
|
||||
- name: 📦 Add extension zip file to release
|
||||
env:
|
||||
|
||||
9
.gitignore
vendored
@@ -8,3 +8,12 @@ bin/
|
||||
build
|
||||
tlsn/
|
||||
zip
|
||||
.vscode
|
||||
.claude
|
||||
coverage
|
||||
**/dist
|
||||
|
||||
# Plugin SDK demo artifacts
|
||||
packages/plugin-sdk/browser/
|
||||
packages/plugin-sdk/hello.component.wasm
|
||||
packages/plugin-sdk/test.html
|
||||
|
||||
10
README.md
@@ -7,12 +7,16 @@
|
||||
[actions-badge]: https://github.com/tlsnotary/tlsn-extension/actions/workflows/build.yaml/badge.svg
|
||||
[actions-url]: https://github.com/tlsnotary/tlsn-extension/actions?query=workflow%3Abuild+branch%3Amain++
|
||||
|
||||
<img src="src/assets/img/icon-128.png" width="64"/>
|
||||
<img src="packages/extension/src/assets/img/icon-128.png" width="64"/>
|
||||
|
||||
# Chrome Extension (MV3) for TLSNotary
|
||||
# TLSN Extension Monorepo
|
||||
|
||||
This repository contains:
|
||||
- **extension**: Chrome Extension (MV3) for TLSNotary
|
||||
- **plugin-sdk**: SDK for developing WASM plugins
|
||||
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ When running the extension against a [notary server](https://github.com/tlsnotary/tlsn/tree/dev/notary-server), please ensure that the server's version is the same as the version of this extension
|
||||
> ⚠️ When running the extension against a [notary server](https://github.com/tlsnotary/tlsn/tree/main/crates/notary/server), please ensure that the server's version is the same as the version of this extension
|
||||
|
||||
## License
|
||||
This repository is licensed under either of
|
||||
|
||||
6987
package-lock.json
generated
105
package.json
@@ -1,98 +1,25 @@
|
||||
{
|
||||
"name": "tlsn-extension",
|
||||
"version": "0.1.0.900",
|
||||
"name": "tlsn-monorepo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "TLSN Extension monorepo with plugin SDK",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tlsnotary/tlsn-extension.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"clone:tlsn": "bash ./utils/download-tlsn.sh",
|
||||
"build": "NODE_ENV=production node utils/build.js",
|
||||
"build:webpack": "NODE_ENV=production webpack --config webpack.config.js",
|
||||
"websockify": "docker run -it --rm -p 55688:80 -v $(pwd):/app novnc/websockify 80 --target-config /app/websockify_config",
|
||||
"dev": "NODE_ENV=development node utils/webserver.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"build": "npm run build --workspace=extension",
|
||||
"build:all": "npm run build --workspaces",
|
||||
"dev": "npm run dev --workspace=extension",
|
||||
"lint": "npm run lint --workspaces",
|
||||
"lint:fix": "npm run lint:fix --workspaces",
|
||||
"test": "npm run test --workspaces",
|
||||
"serve:test": "npm run serve:test --workspace=extension",
|
||||
"clean": "rm -rf packages/*/node_modules packages/*/dist packages/*/build node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "^1.0.3",
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"async-mutex": "^0.4.0",
|
||||
"buffer": "^6.0.3",
|
||||
"charwise": "^3.0.1",
|
||||
"classnames": "^2.3.2",
|
||||
"comlink": "^4.4.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^6.6.2",
|
||||
"http-parser-js": "^0.5.9",
|
||||
"level": "^8.0.0",
|
||||
"minimatch": "^9.0.4",
|
||||
"node-cache": "^5.1.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router": "^6.15.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"redux": "^4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tlsn-js": "0.1.0-alpha.9",
|
||||
"tlsn-js-v5": "npm:tlsn-js@0.1.0-alpha.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||
"@types/chrome": "^0.0.202",
|
||||
"@types/node": "^20.4.10",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/webextension-polyfill": "^0.10.7",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-preset-react-app": "^10.0.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.7.3",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-flowtype": "^8.0.3",
|
||||
"eslint-plugin-import": "^2.27.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"html-loader": "^4.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"postcss-loader": "^7.3.3",
|
||||
"postcss-preset-env": "^9.1.1",
|
||||
"prettier": "^3.0.2",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-refresh-typescript": "^2.0.7",
|
||||
"sass": "^1.57.1",
|
||||
"sass-loader": "^13.2.0",
|
||||
"source-map-loader": "^3.0.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"ts-loader": "^9.4.2",
|
||||
"type-fest": "^3.5.2",
|
||||
"typescript": "^4.9.4",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^4.10.0",
|
||||
"webpack-dev-server": "^4.11.1",
|
||||
"webpack-ext-reloader": "^1.1.12",
|
||||
"zip-webpack-plugin": "^4.0.1"
|
||||
}
|
||||
"devDependencies": {}
|
||||
}
|
||||
308
packages/extension/CLAUDE.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
### Development
|
||||
- `npm install` - Install dependencies
|
||||
- `npm run dev` - Start webpack dev server with hot reload on port 3000 (default)
|
||||
- `npm run build` - Build extension (uses NODE_ENV from utils/build.js, defaults to production)
|
||||
- `npm run build:webpack` - Direct webpack build with production mode
|
||||
- `npm run lint` - Run ESLint to check code quality
|
||||
- `npm run lint:fix` - Run ESLint with auto-fix for issues
|
||||
|
||||
## TLSNotary Extension
|
||||
|
||||
This is a Chrome Extension (Manifest V3) for TLSNotary, enabling secure notarization of TLS data. The extension was recently refactored (commit 92ecb55) to a minimal boilerplate, with TLSN overlay functionality being incrementally added back.
|
||||
|
||||
**Important**: The extension must match the version of the notary server it connects to.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Extension Entry Points
|
||||
The extension has 5 main entry points defined in `webpack.config.js`:
|
||||
|
||||
#### 1. **Background Service Worker** (`src/entries/Background/index.ts`)
|
||||
Core responsibilities:
|
||||
- **TLSN Window Management**: Creates popup windows for TLSN operations, tracks window/tab IDs
|
||||
- **Request Interception**: Uses `webRequest.onBeforeRequest` API to intercept all HTTP requests from TLSN windows
|
||||
- **Request Storage**: Maintains in-memory array of intercepted requests (`tlsnRequests`)
|
||||
- **Message Routing**: Forwards messages between content scripts, popup, and injected page scripts
|
||||
- **Offscreen Document Management**: Creates offscreen documents for background DOM operations (Chrome 109+)
|
||||
- Uses `webextension-polyfill` for cross-browser compatibility
|
||||
|
||||
Key message handlers:
|
||||
- `PING` → `PONG` (connectivity test)
|
||||
- `TLSN_CONTENT_TO_EXTENSION` → Opens new popup window, tracks requests
|
||||
- `CONTENT_SCRIPT_READY` → Confirms content script loaded
|
||||
|
||||
#### 2. **Content Script** (`src/entries/Content/index.ts`)
|
||||
Injected into all HTTP/HTTPS pages via manifest. Responsibilities:
|
||||
- **Script Injection**: Injects `content.bundle.js` into page context to expose page-accessible API
|
||||
- **TLSN Overlay Management**: Creates/updates full-screen overlay showing intercepted requests
|
||||
- **Message Bridge**: Bridges messages between page scripts and extension background
|
||||
- **Request Display**: Real-time updates of intercepted requests in overlay UI
|
||||
|
||||
Message handlers:
|
||||
- `GET_PAGE_INFO` → Returns page title, URL, domain
|
||||
- `SHOW_TLSN_OVERLAY` → Creates overlay with initial requests
|
||||
- `UPDATE_TLSN_REQUESTS` → Updates overlay with new requests
|
||||
- `HIDE_TLSN_OVERLAY` → Removes overlay and clears state
|
||||
|
||||
Window message handler:
|
||||
- Listens for `TLSN_CONTENT_SCRIPT_MESSAGE` from page scripts
|
||||
- Forwards to background via `TLSN_CONTENT_TO_EXTENSION`
|
||||
|
||||
#### 3. **Content Module** (`src/entries/Content/content.ts`)
|
||||
Injected script running in page context (not content script context):
|
||||
- **Page API**: Exposes `window.extensionAPI` object to web pages
|
||||
- **Message Bridge**: Provides `sendMessage()` method that posts messages via `window.postMessage`
|
||||
- **Lifecycle Event**: Dispatches `extension_loaded` custom event when ready
|
||||
- **Web Accessible Resource**: Listed in manifest's `web_accessible_resources`
|
||||
|
||||
Page API usage:
|
||||
```javascript
|
||||
window.extensionAPI.sendMessage({ action: 'startTLSN' });
|
||||
window.addEventListener('extension_loaded', () => { /* ready */ });
|
||||
```
|
||||
|
||||
#### 4. **Popup UI** (`src/entries/Popup/index.tsx`)
|
||||
React-based extension popup:
|
||||
- **Simple Interface**: "Hello World" boilerplate with test button
|
||||
- **Redux Integration**: Connected to Redux store via `react-redux`
|
||||
- **Message Sending**: Can send messages to background script
|
||||
- **Styling**: Uses Tailwind CSS with custom button/input classes
|
||||
- Entry point: `popup.html` (400x300px default size)
|
||||
|
||||
#### 5. **Offscreen Document** (`src/entries/Offscreen/index.tsx`)
|
||||
Isolated React component for background processing:
|
||||
- **Purpose**: Handles DOM operations unavailable in service workers
|
||||
- **Message Handling**: Listens for `PROCESS_DATA` messages (example implementation)
|
||||
- **Lifecycle**: Created dynamically by background script, reused if exists
|
||||
- Entry point: `offscreen.html`
|
||||
|
||||
### State Management
|
||||
Redux store located in `src/reducers/index.tsx`:
|
||||
- **App State Interface**: `{ message: string, count: number }`
|
||||
- **Action Creators**:
|
||||
- `setMessage(message: string)` - Updates message state
|
||||
- `incrementCount()` - Increments counter
|
||||
- **Store Configuration** (`src/utils/store.ts`):
|
||||
- Development: Uses `redux-thunk` + `redux-logger` middleware
|
||||
- Production: Uses `redux-thunk` only
|
||||
- **Type Safety**: Exports `RootState` and `AppRootState` types
|
||||
|
||||
### Message Passing Architecture
|
||||
|
||||
**Page → Extension Flow**:
|
||||
```
|
||||
Page (window.postMessage)
|
||||
↓
|
||||
Content Script (window.addEventListener('message'))
|
||||
↓
|
||||
Background (browser.runtime.sendMessage)
|
||||
```
|
||||
|
||||
**Extension → Page Flow**:
|
||||
```
|
||||
Background (browser.tabs.sendMessage)
|
||||
↓
|
||||
Content Script (browser.runtime.onMessage)
|
||||
↓
|
||||
Page DOM manipulation (overlay, etc.)
|
||||
```
|
||||
|
||||
**Security**: Content script only accepts messages from same origin (`event.origin === window.location.origin`)
|
||||
|
||||
### TLSN Overlay Feature
|
||||
|
||||
The overlay is a full-screen modal showing intercepted requests:
|
||||
- **Design**: Dark gradient background (rgba(0,0,0,0.85)) with glassmorphic message box
|
||||
- **Content**:
|
||||
- Header: "TLSN Plugin In Progress" with gradient text
|
||||
- Request list: Scrollable container showing METHOD + URL for each request
|
||||
- Request count: Displayed in header
|
||||
- **Styling**: Inline CSS with animations (fadeInScale), custom scrollbar styling
|
||||
- **Updates**: Real-time updates as new requests are intercepted
|
||||
- **Lifecycle**: Created when TLSN window opens, updated via background messages, cleared on window close
|
||||
|
||||
### Build Configuration
|
||||
|
||||
**Webpack 5 Setup** (`webpack.config.js`):
|
||||
- **Entry Points**: popup, background, contentScript, content, offscreen
|
||||
- **Output**: `build/` directory with `[name].bundle.js` pattern
|
||||
- **Loaders**:
|
||||
- `ts-loader` - TypeScript compilation (transpileOnly in dev)
|
||||
- `babel-loader` - JavaScript transpilation with React Refresh
|
||||
- `style-loader` + `css-loader` + `postcss-loader` + `sass-loader` - Styling pipeline
|
||||
- `html-loader` - HTML templates
|
||||
- `asset/resource` - File assets (images, fonts)
|
||||
- **Plugins**:
|
||||
- `ReactRefreshWebpackPlugin` - Hot module replacement (dev only)
|
||||
- `CleanWebpackPlugin` - Cleans build directory
|
||||
- `CopyWebpackPlugin` - Copies manifest, icons, CSS files
|
||||
- `HtmlWebpackPlugin` - Generates popup.html and offscreen.html
|
||||
- `TerserPlugin` - Code minification (production only)
|
||||
- **Dev Server** (`utils/webserver.js`):
|
||||
- Port: 3000 (configurable via `PORT` env var)
|
||||
- Hot reload enabled with `webpack/hot/dev-server`
|
||||
- Writes to disk for Chrome to load (`writeToDisk: true`)
|
||||
- WebSocket transport for HMR
|
||||
|
||||
**Production Build** (`utils/build.js`):
|
||||
- Adds `ZipPlugin` to create `tlsn-extension-{version}.zip` in `zip/` directory
|
||||
- Uses package.json version for naming
|
||||
- Exits with code 1 on errors or warnings
|
||||
|
||||
### Extension Permissions
|
||||
|
||||
Defined in `src/manifest.json`:
|
||||
- `offscreen` - Create offscreen documents for background processing
|
||||
- `webRequest` - Intercept HTTP/HTTPS requests
|
||||
- `storage` - Persistent local storage
|
||||
- `activeTab` - Access active tab information
|
||||
- `tabs` - Tab management (create, query, update)
|
||||
- `windows` - Window management (create, track, remove)
|
||||
- `host_permissions: ["<all_urls>"]` - Access all URLs for request interception
|
||||
- `content_scripts` - Inject into all HTTP/HTTPS pages
|
||||
- `web_accessible_resources` - Make content.bundle.js, CSS, and icons accessible to pages
|
||||
- `content_security_policy` - Allow WASM execution (`wasm-unsafe-eval`)
|
||||
|
||||
### TypeScript Configuration
|
||||
|
||||
**tsconfig.json**:
|
||||
- Target: `esnext`
|
||||
- Module: `esnext` with Node resolution
|
||||
- Strict mode enabled
|
||||
- JSX: React (not React 17+ automatic runtime)
|
||||
- Includes: `src/` only
|
||||
- Excludes: `build/`, `node_modules/`
|
||||
- Types: `chrome` (for Chrome extension APIs)
|
||||
|
||||
**Type Declarations**:
|
||||
- `src/global.d.ts` - Declares PNG module types
|
||||
- Uses `@types/chrome`, `@types/webextension-polyfill`, `@types/react`, etc.
|
||||
|
||||
### Styling
|
||||
|
||||
**Tailwind CSS**:
|
||||
- Configuration: `tailwind.config.js`
|
||||
- Content: Scans all `src/**/*.{js,jsx,ts,tsx}`
|
||||
- Custom theme: Primary color `#243f5f`
|
||||
- PostCSS pipeline with `postcss-preset-env`
|
||||
|
||||
**SCSS**:
|
||||
- FontAwesome integration (all icon sets: brands, solid, regular)
|
||||
- Custom utility classes: `.button`, `.input`, `.select`, `.textarea`
|
||||
- BEM-style modifiers: `.button--primary`
|
||||
- Tailwind @apply directives mixed with custom styles
|
||||
|
||||
**Popup Dimensions**:
|
||||
- Default: 480x600px (set in index.scss body styles)
|
||||
- Customizable via inline styles or props
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Initial Setup**:
|
||||
```bash
|
||||
npm install # Requires Node.js >= 18
|
||||
```
|
||||
|
||||
2. **Development Mode**:
|
||||
```bash
|
||||
npm run dev # Starts webpack-dev-server on port 3000
|
||||
```
|
||||
- Hot module replacement enabled
|
||||
- Files written to `build/` directory
|
||||
- Source maps: `cheap-module-source-map`
|
||||
|
||||
3. **Load Extension in Chrome**:
|
||||
- Navigate to `chrome://extensions/`
|
||||
- Enable "Developer mode" toggle
|
||||
- Click "Load unpacked"
|
||||
- Select the `build/` folder
|
||||
- Extension auto-reloads on file changes (requires manual refresh for manifest changes)
|
||||
|
||||
4. **Testing TLSN Functionality**:
|
||||
- Trigger `TLSN_CONTENT_TO_EXTENSION` message from a page using `window.extensionAPI.sendMessage()`
|
||||
- Background script opens popup window to x.com
|
||||
- All requests in that window are intercepted and displayed in overlay
|
||||
|
||||
5. **Production Build**:
|
||||
```bash
|
||||
NODE_ENV=production npm run build # Creates build/ and zip/
|
||||
```
|
||||
- Minified output with Terser
|
||||
- No source maps
|
||||
- Creates versioned zip file for Chrome Web Store submission
|
||||
|
||||
6. **Linting**:
|
||||
```bash
|
||||
npm run lint # Check for issues
|
||||
npm run lint:fix # Auto-fix issues
|
||||
```
|
||||
|
||||
## Known Issues & Legacy Code
|
||||
|
||||
⚠️ **Legacy Code Warning**: `src/entries/utils.ts` contains imports from non-existent files:
|
||||
- `Background/rpc.ts` (removed in refactor)
|
||||
- `SidePanel/types.ts` (removed in refactor)
|
||||
- Functions: `pushToRedux()`, `openSidePanel()`, `waitForEvent()`
|
||||
- **Status**: Dead code, not used by current entry points
|
||||
- **Action**: Remove this file or refactor if functionality needed
|
||||
|
||||
## Websockify Integration
|
||||
|
||||
Used for WebSocket proxying of TLS connections:
|
||||
|
||||
**Build Websockify Docker Image**:
|
||||
```bash
|
||||
git clone https://github.com/novnc/websockify && cd websockify
|
||||
./docker/build.sh
|
||||
```
|
||||
|
||||
**Run Websockify**:
|
||||
```bash
|
||||
# For x.com (Twitter)
|
||||
docker run -it --rm -p 55688:80 novnc/websockify 80 api.x.com:443
|
||||
|
||||
# For Twitter (alternative)
|
||||
docker run -it --rm -p 55688:80 novnc/websockify 80 api.twitter.com:443
|
||||
```
|
||||
|
||||
Purpose: Proxies HTTPS connections through WebSocket for browser-based TLS operations.
|
||||
|
||||
## Code Quality
|
||||
|
||||
**ESLint Configuration** (`.eslintrc`):
|
||||
- Extends: `prettier`, `@typescript-eslint/recommended`
|
||||
- Parser: `@typescript-eslint/parser`
|
||||
- Rules:
|
||||
- `prettier/prettier`: error
|
||||
- `@typescript-eslint/no-explicit-any`: warning
|
||||
- `@typescript-eslint/no-var-requires`: off (allows require in webpack config)
|
||||
- `@typescript-eslint/ban-ts-comment`: off
|
||||
- `no-undef`: error
|
||||
- `padding-line-between-statements`: error
|
||||
- Environment: `webextensions`, `browser`, `node`, `es6`
|
||||
- Ignores: `node_modules`, `zip`, `build`, `wasm`, `tlsn`, `webpack.config.js`
|
||||
|
||||
**Prettier Configuration** (`.prettierrc.json`):
|
||||
- Single quotes, trailing commas, 2-space indentation
|
||||
- Ignore: `.prettierignore` (not in repo, likely default ignores)
|
||||
|
||||
## Publishing
|
||||
|
||||
After building:
|
||||
1. Test extension thoroughly in Chrome
|
||||
2. Create production build: `NODE_ENV=production npm run build`
|
||||
3. Upload `zip/tlsn-extension-{version}.zip` to Chrome Web Store
|
||||
4. Follow [Chrome Web Store publishing guide](https://developer.chrome.com/webstore/publish)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Webpack Documentation](https://webpack.js.org/concepts/)
|
||||
- [Chrome Extension Docs](https://developer.chrome.com/docs/extensions/)
|
||||
- [Manifest V3 Migration Guide](https://developer.chrome.com/docs/extensions/mv3/intro/)
|
||||
- [webextension-polyfill](https://github.com/mozilla/webextension-polyfill)
|
||||
17369
packages/extension/package-lock.json
generated
Normal file
94
packages/extension/package.json
Executable file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "extension",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tlsnotary/tlsn-extension.git",
|
||||
"directory": "packages/extension"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production node utils/build.js",
|
||||
"build:webpack": "NODE_ENV=production webpack --config webpack.config.js",
|
||||
"dev": "NODE_ENV=development node utils/webserver.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"serve:test": "python3 -m http.server 8081 --directory ./tests/integration"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"classnames": "^2.3.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.2",
|
||||
"react-router": "^6.15.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"redux": "^4.2.1",
|
||||
"redux-logger": "^3.0.6",
|
||||
"redux-thunk": "^2.4.2",
|
||||
"tailwindcss": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.12",
|
||||
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-react": "^7.18.6",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
|
||||
"@types/chrome": "^0.0.202",
|
||||
"@types/node": "^20.4.10",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.10.7",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "^9.1.2",
|
||||
"babel-preset-react-app": "^10.0.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.7.3",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-flowtype": "^8.0.3",
|
||||
"eslint-plugin-import": "^2.27.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^11.1.0",
|
||||
"happy-dom": "^19.0.1",
|
||||
"html-loader": "^4.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"postcss-loader": "^7.3.3",
|
||||
"postcss-preset-env": "^9.1.1",
|
||||
"prettier": "^3.0.2",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-refresh-typescript": "^2.0.7",
|
||||
"sass": "^1.57.1",
|
||||
"sass-loader": "^13.2.0",
|
||||
"source-map-loader": "^3.0.1",
|
||||
"style-loader": "^3.3.1",
|
||||
"terser-webpack-plugin": "^5.3.6",
|
||||
"ts-loader": "^9.4.2",
|
||||
"type-fest": "^3.5.2",
|
||||
"typescript": "^4.9.4",
|
||||
"uuid": "^13.0.0",
|
||||
"vitest": "^3.2.4",
|
||||
"vitest-chrome": "^0.1.0",
|
||||
"webextension-polyfill": "^0.10.0",
|
||||
"webpack": "^5.75.0",
|
||||
"webpack-cli": "^4.10.0",
|
||||
"webpack-dev-server": "^4.11.1",
|
||||
"webpack-ext-reloader": "^1.1.12",
|
||||
"zip-webpack-plugin": "^4.0.1"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 621 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 896 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
452
packages/extension/src/background/WindowManager.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* WindowManager - Multi-window management for TLSNotary extension
|
||||
*
|
||||
* Manages multiple browser windows with request interception and overlay display.
|
||||
* Each window maintains its own state, request history, and overlay visibility.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import browser from 'webextension-polyfill';
|
||||
import type {
|
||||
WindowRegistration,
|
||||
InterceptedRequest,
|
||||
ManagedWindow,
|
||||
IWindowManager,
|
||||
} from '../types/window-manager';
|
||||
import {
|
||||
MAX_MANAGED_WINDOWS,
|
||||
MAX_REQUESTS_PER_WINDOW,
|
||||
OVERLAY_RETRY_DELAY_MS,
|
||||
MAX_OVERLAY_RETRY_ATTEMPTS,
|
||||
} from '../constants/limits';
|
||||
|
||||
/**
|
||||
* WindowManager implementation
|
||||
*
|
||||
* Provides centralized management for multiple browser windows with:
|
||||
* - Window lifecycle tracking (create, lookup, close)
|
||||
* - Request interception per window
|
||||
* - Overlay visibility control
|
||||
* - Automatic cleanup of closed windows
|
||||
*/
|
||||
export class WindowManager implements IWindowManager {
|
||||
/**
|
||||
* Internal storage for managed windows
|
||||
* Key: Chrome window ID
|
||||
* Value: ManagedWindow object
|
||||
*/
|
||||
private windows: Map<number, ManagedWindow> = new Map();
|
||||
|
||||
/**
|
||||
* Register a new window with the manager
|
||||
*
|
||||
* Creates a ManagedWindow object with UUID, initializes request tracking,
|
||||
* and optionally shows the TLSN overlay.
|
||||
*
|
||||
* @param config - Window registration configuration
|
||||
* @returns Promise resolving to the created ManagedWindow
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const window = await windowManager.registerWindow({
|
||||
* id: 123,
|
||||
* tabId: 456,
|
||||
* url: 'https://example.com',
|
||||
* showOverlay: true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async registerWindow(config: WindowRegistration): Promise<ManagedWindow> {
|
||||
// Check maximum window limit
|
||||
if (this.windows.size >= MAX_MANAGED_WINDOWS) {
|
||||
const error = `Maximum window limit reached (${MAX_MANAGED_WINDOWS}). Currently managing ${this.windows.size} windows. Please close some windows before opening new ones.`;
|
||||
console.error(`[WindowManager] ${error}`);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const managedWindow: ManagedWindow = {
|
||||
id: config.id,
|
||||
uuid: uuidv4(),
|
||||
tabId: config.tabId,
|
||||
url: config.url,
|
||||
createdAt: new Date(),
|
||||
requests: [],
|
||||
overlayVisible: false,
|
||||
showOverlayWhenReady: config.showOverlay !== false, // Default: true
|
||||
};
|
||||
|
||||
this.windows.set(config.id, managedWindow);
|
||||
|
||||
console.log(
|
||||
`[WindowManager] Window registered: ${managedWindow.uuid} (ID: ${managedWindow.id}, Tab: ${managedWindow.tabId}, showOverlayWhenReady: ${managedWindow.showOverlayWhenReady}) [${this.windows.size}/${MAX_MANAGED_WINDOWS}]`,
|
||||
);
|
||||
|
||||
return managedWindow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup a window
|
||||
*
|
||||
* Hides the overlay if visible and removes the window from tracking.
|
||||
* Does nothing if the window is not found.
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await windowManager.closeWindow(123);
|
||||
* ```
|
||||
*/
|
||||
async closeWindow(windowId: number): Promise<void> {
|
||||
const window = this.windows.get(windowId);
|
||||
if (!window) {
|
||||
console.warn(
|
||||
`[WindowManager] Attempted to close non-existent window: ${windowId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide overlay before closing
|
||||
if (window.overlayVisible) {
|
||||
await this.hideOverlay(windowId).catch((error) => {
|
||||
console.warn(
|
||||
`[WindowManager] Failed to hide overlay for window ${windowId}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from tracking
|
||||
this.windows.delete(windowId);
|
||||
|
||||
console.log(
|
||||
`[WindowManager] Window closed: ${window.uuid} (ID: ${window.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a managed window by ID
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
* @returns The ManagedWindow or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const window = windowManager.getWindow(123);
|
||||
* if (window) {
|
||||
* console.log(`Window has ${window.requests.length} requests`);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getWindow(windowId: number): ManagedWindow | undefined {
|
||||
return this.windows.get(windowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a managed window by tab ID
|
||||
*
|
||||
* Searches through all windows to find one containing the specified tab.
|
||||
* Useful for webRequest listeners that only provide tab IDs.
|
||||
*
|
||||
* @param tabId - Chrome tab ID
|
||||
* @returns The ManagedWindow or undefined if not found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const window = windowManager.getWindowByTabId(456);
|
||||
* if (window) {
|
||||
* windowManager.addRequest(window.id, request);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getWindowByTabId(tabId: number): ManagedWindow | undefined {
|
||||
for (const window of this.windows.values()) {
|
||||
if (window.tabId === tabId) {
|
||||
return window;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all managed windows
|
||||
*
|
||||
* @returns Map of window IDs to ManagedWindow objects (copy)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const allWindows = windowManager.getAllWindows();
|
||||
* console.log(`Managing ${allWindows.size} windows`);
|
||||
* ```
|
||||
*/
|
||||
getAllWindows(): Map<number, ManagedWindow> {
|
||||
return new Map(this.windows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an intercepted request to a window
|
||||
*
|
||||
* Appends the request to the window's request array and updates the overlay
|
||||
* if it's currently visible. Logs an error if the window is not found.
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
* @param request - The intercepted request to add
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* windowManager.addRequest(123, {
|
||||
* id: 'req-456',
|
||||
* method: 'GET',
|
||||
* url: 'https://example.com/api/data',
|
||||
* timestamp: Date.now(),
|
||||
* tabId: 456
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
addRequest(windowId: number, request: InterceptedRequest): void {
|
||||
const window = this.windows.get(windowId);
|
||||
if (!window) {
|
||||
console.error(
|
||||
`[WindowManager] Cannot add request to non-existent window: ${windowId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add timestamp if not provided
|
||||
if (!request.timestamp) {
|
||||
request.timestamp = Date.now();
|
||||
}
|
||||
|
||||
window.requests.push(request);
|
||||
|
||||
// Enforce request limit per window to prevent unbounded memory growth
|
||||
if (window.requests.length > MAX_REQUESTS_PER_WINDOW) {
|
||||
const removed = window.requests.length - MAX_REQUESTS_PER_WINDOW;
|
||||
window.requests.splice(0, removed);
|
||||
console.warn(
|
||||
`[WindowManager] Request limit reached for window ${windowId}. Removed ${removed} oldest request(s). Current: ${window.requests.length}/${MAX_REQUESTS_PER_WINDOW}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[WindowManager] Request added to window ${windowId}: ${request.method} ${request.url}`,
|
||||
);
|
||||
|
||||
// Update overlay if visible
|
||||
if (window.overlayVisible) {
|
||||
this.updateOverlay(windowId).catch((error) => {
|
||||
console.warn(
|
||||
`[WindowManager] Failed to update overlay for window ${windowId}:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all requests for a window
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
* @returns Array of intercepted requests (empty array if window not found)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const requests = windowManager.getWindowRequests(123);
|
||||
* console.log(`Window has ${requests.length} requests`);
|
||||
* ```
|
||||
*/
|
||||
getWindowRequests(windowId: number): InterceptedRequest[] {
|
||||
const window = this.windows.get(windowId);
|
||||
return window?.requests || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the TLSN overlay in a window
|
||||
*
|
||||
* Sends a message to the content script to display the overlay with
|
||||
* the current list of intercepted requests. Catches and logs errors
|
||||
* if the content script is not ready.
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await windowManager.showOverlay(123);
|
||||
* ```
|
||||
*/
|
||||
async showOverlay(windowId: number, retryCount: number = 0): Promise<void> {
|
||||
const window = this.windows.get(windowId);
|
||||
if (!window) {
|
||||
console.error(
|
||||
`[WindowManager] Cannot show overlay for non-existent window: ${windowId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.tabs.sendMessage(window.tabId, {
|
||||
type: 'SHOW_TLSN_OVERLAY',
|
||||
requests: window.requests,
|
||||
});
|
||||
|
||||
window.overlayVisible = true;
|
||||
window.showOverlayWhenReady = false; // Clear the pending flag
|
||||
console.log(`[WindowManager] Overlay shown for window ${windowId}`);
|
||||
} catch (error) {
|
||||
// Retry if content script not ready
|
||||
if (retryCount < MAX_OVERLAY_RETRY_ATTEMPTS) {
|
||||
console.log(
|
||||
`[WindowManager] Overlay display failed for window ${windowId}, retry ${retryCount + 1}/${MAX_OVERLAY_RETRY_ATTEMPTS} in ${OVERLAY_RETRY_DELAY_MS}ms`,
|
||||
);
|
||||
|
||||
// Wait and retry
|
||||
await new Promise((resolve) => setTimeout(resolve, OVERLAY_RETRY_DELAY_MS));
|
||||
|
||||
// Check if window still exists before retrying
|
||||
if (this.windows.has(windowId)) {
|
||||
return this.showOverlay(windowId, retryCount + 1);
|
||||
} else {
|
||||
console.warn(
|
||||
`[WindowManager] Window ${windowId} closed during retry, aborting overlay display`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[WindowManager] Failed to show overlay for window ${windowId} after ${MAX_OVERLAY_RETRY_ATTEMPTS} attempts:`,
|
||||
error,
|
||||
);
|
||||
// Keep showOverlayWhenReady=true so tabs.onUpdated can try again
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the TLSN overlay in a window
|
||||
*
|
||||
* Sends a message to the content script to remove the overlay.
|
||||
* Catches and logs errors if the content script is not available.
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await windowManager.hideOverlay(123);
|
||||
* ```
|
||||
*/
|
||||
async hideOverlay(windowId: number): Promise<void> {
|
||||
const window = this.windows.get(windowId);
|
||||
if (!window) {
|
||||
console.error(
|
||||
`[WindowManager] Cannot hide overlay for non-existent window: ${windowId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.tabs.sendMessage(window.tabId, {
|
||||
type: 'HIDE_TLSN_OVERLAY',
|
||||
});
|
||||
|
||||
window.overlayVisible = false;
|
||||
console.log(`[WindowManager] Overlay hidden for window ${windowId}`);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WindowManager] Failed to hide overlay for window ${windowId}:`,
|
||||
error,
|
||||
);
|
||||
// Don't throw - window may already be closed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if overlay is visible in a window
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
* @returns true if overlay is visible, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (windowManager.isOverlayVisible(123)) {
|
||||
* console.log('Overlay is currently displayed');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
isOverlayVisible(windowId: number): boolean {
|
||||
const window = this.windows.get(windowId);
|
||||
return window?.overlayVisible || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update overlay with current requests (private helper)
|
||||
*
|
||||
* Sends an UPDATE_TLSN_REQUESTS message to the content script.
|
||||
*
|
||||
* @param windowId - Chrome window ID
|
||||
*/
|
||||
private async updateOverlay(windowId: number): Promise<void> {
|
||||
const window = this.windows.get(windowId);
|
||||
if (!window || !window.overlayVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.tabs.sendMessage(window.tabId, {
|
||||
type: 'UPDATE_TLSN_REQUESTS',
|
||||
requests: window.requests,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[WindowManager] Overlay updated for window ${windowId} with ${window.requests.length} requests`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WindowManager] Failed to update overlay for window ${windowId}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup windows that are no longer valid
|
||||
*
|
||||
* Iterates through all tracked windows and removes any that have been
|
||||
* closed in the browser. This prevents memory leaks and stale state.
|
||||
*
|
||||
* Should be called periodically (e.g., every minute) or when handling
|
||||
* window events.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Run cleanup every minute
|
||||
* setInterval(() => {
|
||||
* windowManager.cleanupInvalidWindows();
|
||||
* }, 60000);
|
||||
* ```
|
||||
*/
|
||||
async cleanupInvalidWindows(): Promise<void> {
|
||||
const windowIds = Array.from(this.windows.keys());
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const windowId of windowIds) {
|
||||
try {
|
||||
// Check if window still exists in browser
|
||||
await browser.windows.get(windowId);
|
||||
} catch (error) {
|
||||
// Window no longer exists, clean it up
|
||||
const window = this.windows.get(windowId);
|
||||
this.windows.delete(windowId);
|
||||
cleanedCount++;
|
||||
|
||||
console.log(
|
||||
`[WindowManager] Cleaned up invalid window: ${window?.uuid} (ID: ${windowId})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(
|
||||
`[WindowManager] Cleanup complete: ${cleanedCount} window(s) removed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
packages/extension/src/constants/limits.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Resource limits and constraints for the TLSN extension
|
||||
*
|
||||
* These limits prevent resource exhaustion and ensure good performance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Maximum number of managed windows that can be open simultaneously
|
||||
*
|
||||
* This prevents memory exhaustion from opening too many windows.
|
||||
* Each window tracks its own requests and overlay state.
|
||||
*
|
||||
* @default 10
|
||||
*/
|
||||
export const MAX_MANAGED_WINDOWS = 10;
|
||||
|
||||
/**
|
||||
* Maximum number of requests to store per window
|
||||
*
|
||||
* Prevents unbounded memory growth from high-traffic sites.
|
||||
* Older requests are removed when limit is reached.
|
||||
*
|
||||
* @default 1000
|
||||
*/
|
||||
export const MAX_REQUESTS_PER_WINDOW = 1000;
|
||||
|
||||
/**
|
||||
* Timeout for overlay display attempts (milliseconds)
|
||||
*
|
||||
* If overlay cannot be shown within this timeout, stop retrying.
|
||||
* This prevents infinite retry loops if content script never loads.
|
||||
*
|
||||
* @default 5000 (5 seconds)
|
||||
*/
|
||||
export const OVERLAY_DISPLAY_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Retry delay for overlay display (milliseconds)
|
||||
*
|
||||
* Time to wait between retry attempts when content script isn't ready.
|
||||
*
|
||||
* @default 500 (0.5 seconds)
|
||||
*/
|
||||
export const OVERLAY_RETRY_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* Maximum number of retry attempts for overlay display
|
||||
*
|
||||
* Calculated as OVERLAY_DISPLAY_TIMEOUT_MS / OVERLAY_RETRY_DELAY_MS
|
||||
*
|
||||
* @default 10 (5000ms / 500ms)
|
||||
*/
|
||||
export const MAX_OVERLAY_RETRY_ATTEMPTS = Math.floor(
|
||||
OVERLAY_DISPLAY_TIMEOUT_MS / OVERLAY_RETRY_DELAY_MS,
|
||||
);
|
||||
|
||||
/**
|
||||
* Interval for periodic cleanup of invalid windows (milliseconds)
|
||||
*
|
||||
* WindowManager periodically checks for windows that have been closed
|
||||
* and removes them from tracking.
|
||||
*
|
||||
* @default 300000 (5 minutes)
|
||||
*/
|
||||
export const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
||||
139
packages/extension/src/constants/messages.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Message type constants for extension communication
|
||||
*
|
||||
* Defines all message types used for communication between:
|
||||
* - Page scripts → Content scripts → Background script
|
||||
* - Background script → Content scripts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Legacy message types (from existing implementation)
|
||||
*/
|
||||
export const PING = 'PING';
|
||||
export const PONG = 'PONG';
|
||||
export const CONTENT_SCRIPT_READY = 'CONTENT_SCRIPT_READY';
|
||||
export const GET_PAGE_INFO = 'GET_PAGE_INFO';
|
||||
|
||||
/**
|
||||
* TLSN Content Script Messages (legacy)
|
||||
*/
|
||||
export const TLSN_CONTENT_SCRIPT_MESSAGE = 'TLSN_CONTENT_SCRIPT_MESSAGE';
|
||||
export const TLSN_CONTENT_TO_EXTENSION = 'TLSN_CONTENT_TO_EXTENSION';
|
||||
|
||||
/**
|
||||
* Window Management Messages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sent from content script to background to request opening a new window
|
||||
*
|
||||
* Payload: { url: string, width?: number, height?: number, showOverlay?: boolean }
|
||||
*/
|
||||
export const OPEN_WINDOW = 'OPEN_WINDOW';
|
||||
|
||||
/**
|
||||
* Response from background when window is successfully opened
|
||||
*
|
||||
* Payload: { windowId: number, uuid: string, tabId: number }
|
||||
*/
|
||||
export const WINDOW_OPENED = 'WINDOW_OPENED';
|
||||
|
||||
/**
|
||||
* Response from background when window opening fails
|
||||
*
|
||||
* Payload: { error: string, details?: string }
|
||||
*/
|
||||
export const WINDOW_ERROR = 'WINDOW_ERROR';
|
||||
|
||||
/**
|
||||
* Overlay Control Messages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sent from background to content script to show TLSN overlay
|
||||
*
|
||||
* Payload: { requests: InterceptedRequest[] }
|
||||
*/
|
||||
export const SHOW_TLSN_OVERLAY = 'SHOW_TLSN_OVERLAY';
|
||||
|
||||
/**
|
||||
* Sent from background to content script to update overlay with new requests
|
||||
*
|
||||
* Payload: { requests: InterceptedRequest[] }
|
||||
*/
|
||||
export const UPDATE_TLSN_REQUESTS = 'UPDATE_TLSN_REQUESTS';
|
||||
|
||||
/**
|
||||
* Sent from background to content script to hide TLSN overlay
|
||||
*
|
||||
* Payload: none
|
||||
*/
|
||||
export const HIDE_TLSN_OVERLAY = 'HIDE_TLSN_OVERLAY';
|
||||
|
||||
/**
|
||||
* Type definitions for message payloads
|
||||
*/
|
||||
|
||||
export interface OpenWindowPayload {
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
export interface WindowOpenedPayload {
|
||||
windowId: number;
|
||||
uuid: string;
|
||||
tabId: number;
|
||||
}
|
||||
|
||||
export interface WindowErrorPayload {
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface OverlayRequestsPayload {
|
||||
requests: Array<{
|
||||
id: string;
|
||||
method: string;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
tabId: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message wrapper types
|
||||
*/
|
||||
|
||||
export interface OpenWindowMessage {
|
||||
type: typeof OPEN_WINDOW;
|
||||
url: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
export interface WindowOpenedMessage {
|
||||
type: typeof WINDOW_OPENED;
|
||||
payload: WindowOpenedPayload;
|
||||
}
|
||||
|
||||
export interface WindowErrorMessage {
|
||||
type: typeof WINDOW_ERROR;
|
||||
payload: WindowErrorPayload;
|
||||
}
|
||||
|
||||
export interface ShowOverlayMessage {
|
||||
type: typeof SHOW_TLSN_OVERLAY;
|
||||
requests: OverlayRequestsPayload['requests'];
|
||||
}
|
||||
|
||||
export interface UpdateOverlayMessage {
|
||||
type: typeof UPDATE_TLSN_REQUESTS;
|
||||
requests: OverlayRequestsPayload['requests'];
|
||||
}
|
||||
|
||||
export interface HideOverlayMessage {
|
||||
type: typeof HIDE_TLSN_OVERLAY;
|
||||
}
|
||||
279
packages/extension/src/entries/Background/index.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import { WindowManager } from '../../background/WindowManager';
|
||||
import type { InterceptedRequest } from '../../types/window-manager';
|
||||
import { validateUrl } from '../../utils/url-validator';
|
||||
|
||||
const chrome = global.chrome as any;
|
||||
// Basic background script setup
|
||||
console.log('Background script loaded');
|
||||
|
||||
// Initialize WindowManager for multi-window support
|
||||
const windowManager = new WindowManager();
|
||||
|
||||
// Handle extension install/update
|
||||
browser.runtime.onInstalled.addListener((details) => {
|
||||
console.log('Extension installed/updated:', details.reason);
|
||||
});
|
||||
|
||||
// Set up webRequest listener to intercept all requests
|
||||
browser.webRequest.onBeforeRequest.addListener(
|
||||
(details) => {
|
||||
// Check if this tab belongs to a managed window
|
||||
const managedWindow = windowManager.getWindowByTabId(details.tabId);
|
||||
|
||||
if (managedWindow && details.tabId !== undefined) {
|
||||
const request: InterceptedRequest = {
|
||||
id: `${details.requestId}`,
|
||||
method: details.method,
|
||||
url: details.url,
|
||||
timestamp: Date.now(),
|
||||
tabId: details.tabId,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`[Background] Request intercepted for window ${managedWindow.id}:`,
|
||||
details.method,
|
||||
details.url,
|
||||
);
|
||||
|
||||
// Add request to window's request history
|
||||
windowManager.addRequest(managedWindow.id, request);
|
||||
}
|
||||
},
|
||||
{ urls: ['<all_urls>'] },
|
||||
['requestBody'],
|
||||
);
|
||||
|
||||
// Listen for window removal
|
||||
browser.windows.onRemoved.addListener(async (windowId) => {
|
||||
const managedWindow = windowManager.getWindow(windowId);
|
||||
if (managedWindow) {
|
||||
console.log(
|
||||
`[Background] Managed window closed: ${managedWindow.uuid} (ID: ${windowId})`,
|
||||
);
|
||||
await windowManager.closeWindow(windowId);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for tab updates to show overlay when tab is ready (Task 3.4)
|
||||
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
|
||||
// Only act when tab becomes complete
|
||||
if (changeInfo.status !== 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this tab belongs to a managed window
|
||||
const managedWindow = windowManager.getWindowByTabId(tabId);
|
||||
if (!managedWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If overlay should be shown but isn't visible yet, show it now
|
||||
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
|
||||
console.log(
|
||||
`[Background] Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
|
||||
);
|
||||
await windowManager.showOverlay(managedWindow.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Basic message handler
|
||||
browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
|
||||
console.log('[Background] Message received:', request.type);
|
||||
|
||||
// Example response
|
||||
if (request.type === 'PING') {
|
||||
sendResponse({ type: 'PONG' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backward compatibility: Handle legacy TLSN_CONTENT_TO_EXTENSION message (Task 3.5)
|
||||
// This maintains compatibility with existing code that uses the old API
|
||||
if (request.type === 'TLSN_CONTENT_TO_EXTENSION') {
|
||||
console.log(
|
||||
'[Background] Legacy TLSN_CONTENT_TO_EXTENSION received, opening x.com window',
|
||||
);
|
||||
|
||||
// Open x.com window using the new WindowManager system
|
||||
browser.windows
|
||||
.create({
|
||||
url: 'https://x.com',
|
||||
type: 'popup',
|
||||
width: 900,
|
||||
height: 700,
|
||||
})
|
||||
.then(async (window) => {
|
||||
if (
|
||||
!window.id ||
|
||||
!window.tabs ||
|
||||
!window.tabs[0] ||
|
||||
!window.tabs[0].id
|
||||
) {
|
||||
throw new Error('Failed to create window or get tab ID');
|
||||
}
|
||||
|
||||
const windowId = window.id;
|
||||
const tabId = window.tabs[0].id;
|
||||
|
||||
console.log(
|
||||
`[Background] Legacy window created: ${windowId}, Tab: ${tabId}`,
|
||||
);
|
||||
|
||||
// Register with WindowManager (overlay will be shown when tab loads)
|
||||
await windowManager.registerWindow({
|
||||
id: windowId,
|
||||
tabId: tabId,
|
||||
url: 'https://x.com',
|
||||
showOverlay: true,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Background] Error creating legacy window:', error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle OPEN_WINDOW requests from content scripts
|
||||
if (request.type === 'OPEN_WINDOW') {
|
||||
console.log('[Background] OPEN_WINDOW request received:', request.url);
|
||||
|
||||
// Validate URL using comprehensive validator
|
||||
const urlValidation = validateUrl(request.url);
|
||||
if (!urlValidation.valid) {
|
||||
console.error('[Background] URL validation failed:', urlValidation.error);
|
||||
sendResponse({
|
||||
type: 'WINDOW_ERROR',
|
||||
payload: {
|
||||
error: 'Invalid URL',
|
||||
details: urlValidation.error || 'URL validation failed',
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Open a new window with the requested URL
|
||||
browser.windows
|
||||
.create({
|
||||
url: request.url,
|
||||
type: 'popup',
|
||||
width: request.width || 900,
|
||||
height: request.height || 700,
|
||||
})
|
||||
.then(async (window) => {
|
||||
if (
|
||||
!window.id ||
|
||||
!window.tabs ||
|
||||
!window.tabs[0] ||
|
||||
!window.tabs[0].id
|
||||
) {
|
||||
throw new Error('Failed to create window or get tab ID');
|
||||
}
|
||||
|
||||
const windowId = window.id;
|
||||
const tabId = window.tabs[0].id;
|
||||
|
||||
console.log(`[Background] Window created: ${windowId}, Tab: ${tabId}`);
|
||||
|
||||
try {
|
||||
// Register window with WindowManager
|
||||
const managedWindow = await windowManager.registerWindow({
|
||||
id: windowId,
|
||||
tabId: tabId,
|
||||
url: request.url,
|
||||
showOverlay: request.showOverlay !== false, // Default to true
|
||||
});
|
||||
|
||||
console.log(`[Background] Window registered: ${managedWindow.uuid}`);
|
||||
|
||||
// Send success response
|
||||
sendResponse({
|
||||
type: 'WINDOW_OPENED',
|
||||
payload: {
|
||||
windowId: managedWindow.id,
|
||||
uuid: managedWindow.uuid,
|
||||
tabId: managedWindow.tabId,
|
||||
},
|
||||
});
|
||||
} catch (registrationError) {
|
||||
// Registration failed (e.g., window limit exceeded)
|
||||
// Close the window we just created
|
||||
console.error('[Background] Window registration failed:', registrationError);
|
||||
await browser.windows.remove(windowId).catch(() => {
|
||||
// Ignore errors if window already closed
|
||||
});
|
||||
|
||||
sendResponse({
|
||||
type: 'WINDOW_ERROR',
|
||||
payload: {
|
||||
error: 'Window registration failed',
|
||||
details: String(registrationError),
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Background] Error creating window:', error);
|
||||
sendResponse({
|
||||
type: 'WINDOW_ERROR',
|
||||
payload: {
|
||||
error: 'Failed to create window',
|
||||
details: String(error),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return true; // Keep message channel open for async response
|
||||
}
|
||||
|
||||
return true; // Keep message channel open for async response
|
||||
});
|
||||
|
||||
// Create offscreen document if needed (Chrome 109+)
|
||||
async function createOffscreenDocument() {
|
||||
// Check if we're in a Chrome environment that supports offscreen documents
|
||||
if (!chrome?.offscreen) {
|
||||
console.log('Offscreen API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const offscreenUrl = browser.runtime.getURL('offscreen.html');
|
||||
|
||||
// Check if offscreen document already exists
|
||||
const existingContexts = await chrome.runtime.getContexts({
|
||||
contextTypes: ['OFFSCREEN_DOCUMENT'],
|
||||
documentUrls: [offscreenUrl],
|
||||
});
|
||||
|
||||
if (existingContexts.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create offscreen document
|
||||
await chrome.offscreen.createDocument({
|
||||
url: 'offscreen.html',
|
||||
reasons: ['DOM_SCRAPING'],
|
||||
justification: 'Offscreen document for background processing',
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize offscreen document
|
||||
createOffscreenDocument().catch(console.error);
|
||||
|
||||
// Periodic cleanup of invalid windows (every 5 minutes)
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
setInterval(() => {
|
||||
console.log('[Background] Running periodic window cleanup...');
|
||||
windowManager.cleanupInvalidWindows().catch((error) => {
|
||||
console.error('[Background] Error during cleanup:', error);
|
||||
});
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Run initial cleanup after 10 seconds
|
||||
setTimeout(() => {
|
||||
windowManager.cleanupInvalidWindows().catch((error) => {
|
||||
console.error('[Background] Error during initial cleanup:', error);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
export {};
|
||||
92
packages/extension/src/entries/Content/content.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
console.log('Page script injected');
|
||||
|
||||
/**
|
||||
* ExtensionAPI - Public API exposed to web pages via window.tlsn
|
||||
*
|
||||
* Provides methods for web pages to interact with the TLSN extension,
|
||||
* including opening new windows for notarization.
|
||||
*/
|
||||
class ExtensionAPI {
|
||||
/**
|
||||
* Legacy sendMessage method
|
||||
* @deprecated Use specific methods like open() instead
|
||||
*/
|
||||
sendMessage(data: any) {
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'TLSN_CONTENT_SCRIPT_MESSAGE',
|
||||
payload: data,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a new browser window with the specified URL
|
||||
*
|
||||
* The window will have request interception enabled and display
|
||||
* the TLSN overlay showing all captured HTTP requests.
|
||||
*
|
||||
* @param url - The URL to open in the new window
|
||||
* @param options - Optional window configuration
|
||||
* @param options.width - Window width in pixels (default: 900)
|
||||
* @param options.height - Window height in pixels (default: 700)
|
||||
* @param options.showOverlay - Whether to show the TLSN overlay (default: true)
|
||||
* @returns Promise that resolves when the window is opened
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* // Open Twitter in a new window
|
||||
* await window.tlsn.open('https://twitter.com');
|
||||
*
|
||||
* // Open with custom dimensions
|
||||
* await window.tlsn.open('https://example.com', {
|
||||
* width: 1200,
|
||||
* height: 800,
|
||||
* showOverlay: true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async open(
|
||||
url: string,
|
||||
options?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
showOverlay?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error('URL must be a non-empty string');
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
// Send message to content script
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: {
|
||||
url,
|
||||
width: options?.width,
|
||||
height: options?.height,
|
||||
showOverlay: options?.showOverlay,
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
// Return immediately - actual window opening is async
|
||||
// Future enhancement: Could return a Promise that resolves with window info
|
||||
}
|
||||
}
|
||||
|
||||
// Expose API to the page
|
||||
(window as any).tlsn = new ExtensionAPI();
|
||||
|
||||
// Dispatch event to notify page that extension is loaded
|
||||
window.dispatchEvent(new CustomEvent('extension_loaded'));
|
||||
288
packages/extension/src/entries/Content/index.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
console.log('Content script loaded on:', window.location.href);
|
||||
|
||||
// Inject a script into the page if needed
|
||||
function injectScript() {
|
||||
const script = document.createElement('script');
|
||||
script.src = browser.runtime.getURL('content.bundle.js');
|
||||
script.type = 'text/javascript';
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
script.onload = () => script.remove();
|
||||
}
|
||||
|
||||
// Store for intercepted requests
|
||||
let currentRequests: any[] = [];
|
||||
|
||||
// Function to create and show the TLSN overlay
|
||||
function createTLSNOverlay(initialRequests?: any[]) {
|
||||
if (initialRequests) {
|
||||
currentRequests = initialRequests;
|
||||
}
|
||||
|
||||
// Remove any existing overlay
|
||||
const existingOverlay = document.getElementById('tlsn-overlay');
|
||||
if (existingOverlay) {
|
||||
existingOverlay.remove();
|
||||
}
|
||||
|
||||
// Create overlay container
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'tlsn-overlay';
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
`;
|
||||
|
||||
// Create message box
|
||||
const messageBox = document.createElement('div');
|
||||
messageBox.id = 'tlsn-message-box';
|
||||
messageBox.style.cssText = `
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2a2a3e 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
animation: fadeInScale 0.3s ease-out;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
// Build request list HTML
|
||||
let requestsHTML = '';
|
||||
if (currentRequests.length > 0) {
|
||||
requestsHTML = currentRequests
|
||||
.map(
|
||||
(req, index) => `
|
||||
<div style="
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
border-left: 3px solid #667eea;
|
||||
">
|
||||
<span style="
|
||||
color: #ffd700;
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
">${req.method}</span>
|
||||
<span style="
|
||||
color: #e0e0e0;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
">${req.url}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('');
|
||||
} else {
|
||||
requestsHTML = `
|
||||
<div style="
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
">
|
||||
No requests intercepted yet...
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
messageBox.innerHTML = `
|
||||
<div style="
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
">
|
||||
TLSN Plugin In Progress
|
||||
</div>
|
||||
<div style="
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 20px;
|
||||
">
|
||||
Intercepting network requests from this window
|
||||
</div>
|
||||
<div style="
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
max-height: 400px;
|
||||
flex: 1;
|
||||
">
|
||||
<div style="
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #667eea;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
">
|
||||
Intercepted Requests (${currentRequests.length})
|
||||
</div>
|
||||
${requestsHTML}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add CSS animation
|
||||
const existingStyle = document.getElementById('tlsn-styles');
|
||||
if (!existingStyle) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'tlsn-styles';
|
||||
style.textContent = `
|
||||
@keyframes fadeInScale {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
#tlsn-message-box::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#tlsn-message-box::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#tlsn-message-box::-webkit-scrollbar-thumb {
|
||||
background: rgba(102, 126, 234, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#tlsn-message-box::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(102, 126, 234, 0.7);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
overlay.appendChild(messageBox);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Function to update the overlay with new requests
|
||||
function updateTLSNOverlay(requests: any[]) {
|
||||
currentRequests = requests;
|
||||
const overlay = document.getElementById('tlsn-overlay');
|
||||
if (overlay) {
|
||||
createTLSNOverlay(requests);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for messages from the extension
|
||||
browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
|
||||
console.log('Content script received message:', request);
|
||||
|
||||
if (request.type === 'GET_PAGE_INFO') {
|
||||
// Example: Get page information
|
||||
sendResponse({
|
||||
title: document.title,
|
||||
url: window.location.href,
|
||||
domain: window.location.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.type === 'SHOW_TLSN_OVERLAY') {
|
||||
createTLSNOverlay(request.requests || []);
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
if (request.type === 'UPDATE_TLSN_REQUESTS') {
|
||||
console.log('updateTLSNOverlay', request.requests);
|
||||
updateTLSNOverlay(request.requests || []);
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
if (request.type === 'HIDE_TLSN_OVERLAY') {
|
||||
const overlay = document.getElementById('tlsn-overlay');
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
}
|
||||
currentRequests = [];
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
return true; // Keep the message channel open
|
||||
});
|
||||
|
||||
// Send a message to background script when ready
|
||||
browser.runtime
|
||||
.sendMessage({
|
||||
type: 'CONTENT_SCRIPT_READY',
|
||||
url: window.location.href,
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
// Listen for messages from the page
|
||||
window.addEventListener('message', (event) => {
|
||||
// Only accept messages from the same origin
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
// Handle TLSN window.tlsn.open() calls
|
||||
if (event.data?.type === 'TLSN_OPEN_WINDOW') {
|
||||
console.log(
|
||||
'[Content Script] Received TLSN_OPEN_WINDOW request:',
|
||||
event.data.payload,
|
||||
);
|
||||
|
||||
// Forward to background script with OPEN_WINDOW type
|
||||
browser.runtime
|
||||
.sendMessage({
|
||||
type: 'OPEN_WINDOW',
|
||||
url: event.data.payload.url,
|
||||
width: event.data.payload.width,
|
||||
height: event.data.payload.height,
|
||||
showOverlay: event.data.payload.showOverlay,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'[Content Script] Failed to send OPEN_WINDOW message:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy TLSN_CONTENT_SCRIPT_MESSAGE
|
||||
if (event.data?.type === 'TLSN_CONTENT_SCRIPT_MESSAGE') {
|
||||
// Forward to content script/extension
|
||||
browser.runtime.sendMessage({
|
||||
type: 'TLSN_CONTENT_TO_EXTENSION',
|
||||
payload: event.data.payload,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Inject script if document is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectScript);
|
||||
} else {
|
||||
injectScript();
|
||||
}
|
||||
|
||||
export {};
|
||||
32
packages/extension/src/entries/Offscreen/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
const OffscreenApp: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
console.log('Offscreen document loaded');
|
||||
|
||||
// Listen for messages from background script
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
console.log('Offscreen received message:', request);
|
||||
|
||||
// Example message handling
|
||||
if (request.type === 'PROCESS_DATA') {
|
||||
// Process data in offscreen context
|
||||
sendResponse({ success: true, data: 'Processed in offscreen' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="offscreen-container">
|
||||
<h1>Offscreen Document</h1>
|
||||
<p>This document runs in the background for processing tasks.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const container = document.getElementById('app-container');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<OffscreenApp />);
|
||||
}
|
||||
33
packages/extension/src/entries/Popup/Popup.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../../reducers';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
const Popup: React.FC = () => {
|
||||
const message = useSelector((state: RootState) => state.app.message);
|
||||
|
||||
const handleClick = async () => {
|
||||
// Send message to background script
|
||||
const response = await browser.runtime.sendMessage({ type: 'PING' });
|
||||
console.log('Response from background:', response);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] h-[300px] bg-white p-8">
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-4">Hello World!</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{message || 'Chrome Extension Boilerplate'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Test Background Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popup;
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
|
||||
|
||||
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
|
||||
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/brands";
|
||||
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/solid";
|
||||
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/regular";
|
||||
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
|
||||
@import "~@fortawesome/fontawesome-free/scss/brands";
|
||||
@import "~@fortawesome/fontawesome-free/scss/solid";
|
||||
@import "~@fortawesome/fontawesome-free/scss/regular";
|
||||
|
||||
body {
|
||||
width: 480px;
|
||||
16
packages/extension/src/entries/Popup/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import Popup from './Popup';
|
||||
import './index.scss';
|
||||
import store from '../../utils/store';
|
||||
|
||||
const container = document.getElementById('app-container');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<Popup />
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "TLSN Extension",
|
||||
"description": "A chrome extension for TLSN",
|
||||
"options_page": "options.html",
|
||||
"name": "Extension Boilerplate",
|
||||
"description": "A minimal Chrome extension boilerplate",
|
||||
"background": {
|
||||
"service_worker": "background.bundle.js"
|
||||
},
|
||||
@@ -10,9 +9,6 @@
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": "icon-34.png"
|
||||
},
|
||||
"side_panel": {
|
||||
"default_path": "sidePanel.html"
|
||||
},
|
||||
"icons": {
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
@@ -28,16 +24,17 @@
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "discord_dm.wasm", "twitter_profile.wasm"],
|
||||
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js"],
|
||||
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
|
||||
}
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"permissions": [
|
||||
"offscreen",
|
||||
"storage",
|
||||
"webRequest",
|
||||
"storage",
|
||||
"activeTab",
|
||||
"sidePanel"
|
||||
"tabs",
|
||||
"windows"
|
||||
]
|
||||
}
|
||||
}
|
||||
47
packages/extension/src/reducers/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
// Basic app reducer
|
||||
interface AppState {
|
||||
message: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const initialAppState: AppState = {
|
||||
message: 'Welcome to the extension!',
|
||||
count: 0,
|
||||
};
|
||||
|
||||
// Action types
|
||||
const SET_MESSAGE = 'SET_MESSAGE';
|
||||
const INCREMENT_COUNT = 'INCREMENT_COUNT';
|
||||
|
||||
// Action creators
|
||||
export const setMessage = (message: string) => ({
|
||||
type: SET_MESSAGE,
|
||||
payload: message,
|
||||
});
|
||||
|
||||
export const incrementCount = () => ({
|
||||
type: INCREMENT_COUNT,
|
||||
});
|
||||
|
||||
// App reducer
|
||||
const appReducer = (state = initialAppState, action: any): AppState => {
|
||||
switch (action.type) {
|
||||
case SET_MESSAGE:
|
||||
return { ...state, message: action.payload };
|
||||
case INCREMENT_COUNT:
|
||||
return { ...state, count: state.count + 1 };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Root reducer
|
||||
const rootReducer = combineReducers({
|
||||
app: appReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
export type AppRootState = RootState; // For backward compatibility
|
||||
export default rootReducer;
|
||||
149
packages/extension/src/types/window-manager.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Type definitions for WindowManager
|
||||
*
|
||||
* These types define the core data structures for managing multiple
|
||||
* browser windows with request interception and TLSN overlay functionality.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Configuration for registering a new window with the WindowManager
|
||||
*/
|
||||
export interface WindowRegistration {
|
||||
/** Chrome window ID */
|
||||
id: number;
|
||||
|
||||
/** Primary tab ID within the window */
|
||||
tabId: number;
|
||||
|
||||
/** Target URL for the window */
|
||||
url: string;
|
||||
|
||||
/** Whether to show the TLSN overlay on creation (default: true) */
|
||||
showOverlay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An intercepted HTTP request captured by the webRequest API
|
||||
*/
|
||||
export interface InterceptedRequest {
|
||||
/** Unique request ID from webRequest API */
|
||||
id: string;
|
||||
|
||||
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
|
||||
method: string;
|
||||
|
||||
/** Full request URL */
|
||||
url: string;
|
||||
|
||||
/** Unix timestamp (milliseconds) when request was intercepted */
|
||||
timestamp: number;
|
||||
|
||||
/** Tab ID where the request originated */
|
||||
tabId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A managed browser window tracked by WindowManager
|
||||
*/
|
||||
export interface ManagedWindow {
|
||||
/** Chrome window ID */
|
||||
id: number;
|
||||
|
||||
/** Internal unique identifier (UUID v4) */
|
||||
uuid: string;
|
||||
|
||||
/** Primary tab ID */
|
||||
tabId: number;
|
||||
|
||||
/** Current or initial URL */
|
||||
url: string;
|
||||
|
||||
/** Creation timestamp */
|
||||
createdAt: Date;
|
||||
|
||||
/** Array of intercepted HTTP requests for this window */
|
||||
requests: InterceptedRequest[];
|
||||
|
||||
/** Whether the TLSN overlay is currently visible */
|
||||
overlayVisible: boolean;
|
||||
|
||||
/** Whether to show overlay when tab becomes ready (complete status) */
|
||||
showOverlayWhenReady: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WindowManager interface defining all window management operations
|
||||
*/
|
||||
export interface IWindowManager {
|
||||
/**
|
||||
* Register a new window with the manager
|
||||
* @param config - Window registration configuration
|
||||
* @returns The created ManagedWindow object
|
||||
*/
|
||||
registerWindow(config: WindowRegistration): Promise<ManagedWindow>;
|
||||
|
||||
/**
|
||||
* Close and cleanup a window
|
||||
* @param windowId - Chrome window ID
|
||||
*/
|
||||
closeWindow(windowId: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a managed window by ID
|
||||
* @param windowId - Chrome window ID
|
||||
* @returns The ManagedWindow or undefined if not found
|
||||
*/
|
||||
getWindow(windowId: number): ManagedWindow | undefined;
|
||||
|
||||
/**
|
||||
* Get a managed window by tab ID
|
||||
* @param tabId - Chrome tab ID
|
||||
* @returns The ManagedWindow or undefined if not found
|
||||
*/
|
||||
getWindowByTabId(tabId: number): ManagedWindow | undefined;
|
||||
|
||||
/**
|
||||
* Get all managed windows
|
||||
* @returns Map of window IDs to ManagedWindow objects
|
||||
*/
|
||||
getAllWindows(): Map<number, ManagedWindow>;
|
||||
|
||||
/**
|
||||
* Add an intercepted request to a window
|
||||
* @param windowId - Chrome window ID
|
||||
* @param request - The intercepted request to add
|
||||
*/
|
||||
addRequest(windowId: number, request: InterceptedRequest): void;
|
||||
|
||||
/**
|
||||
* Get all requests for a window
|
||||
* @param windowId - Chrome window ID
|
||||
* @returns Array of intercepted requests
|
||||
*/
|
||||
getWindowRequests(windowId: number): InterceptedRequest[];
|
||||
|
||||
/**
|
||||
* Show the TLSN overlay in a window
|
||||
* @param windowId - Chrome window ID
|
||||
*/
|
||||
showOverlay(windowId: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Hide the TLSN overlay in a window
|
||||
* @param windowId - Chrome window ID
|
||||
*/
|
||||
hideOverlay(windowId: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if overlay is visible in a window
|
||||
* @param windowId - Chrome window ID
|
||||
* @returns true if overlay is visible, false otherwise
|
||||
*/
|
||||
isOverlayVisible(windowId: number): boolean;
|
||||
|
||||
/**
|
||||
* Cleanup windows that are no longer valid
|
||||
* Removes windows from tracking if they've been closed in the browser
|
||||
*/
|
||||
cleanupInvalidWindows(): Promise<void>;
|
||||
}
|
||||
175
packages/extension/src/utils/url-validator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* URL validation utilities for TLSN extension
|
||||
*
|
||||
* Provides robust URL validation to prevent security issues
|
||||
* and ensure only valid HTTP/HTTPS URLs are opened.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Allowed URL protocols for window opening
|
||||
*/
|
||||
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
|
||||
|
||||
/**
|
||||
* Dangerous protocols that should be rejected
|
||||
*/
|
||||
const DANGEROUS_PROTOCOLS = ['javascript:', 'data:', 'file:', 'blob:', 'about:'];
|
||||
|
||||
/**
|
||||
* Result of URL validation
|
||||
*/
|
||||
export interface UrlValidationResult {
|
||||
/** Whether the URL is valid and safe to use */
|
||||
valid: boolean;
|
||||
/** Error message if validation failed */
|
||||
error?: string;
|
||||
/** Parsed URL object if valid */
|
||||
url?: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL for use with window.tlsn.open()
|
||||
*
|
||||
* Checks that the URL:
|
||||
* - Is a non-empty string
|
||||
* - Can be parsed as a valid URL
|
||||
* - Uses http: or https: protocol only
|
||||
* - Does not use dangerous protocols
|
||||
*
|
||||
* @param urlString - The URL string to validate
|
||||
* @returns Validation result with parsed URL or error message
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = validateUrl('https://example.com');
|
||||
* if (result.valid) {
|
||||
* console.log('URL is safe:', result.url.href);
|
||||
* } else {
|
||||
* console.error('Invalid URL:', result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function validateUrl(urlString: unknown): UrlValidationResult {
|
||||
// Check if URL is a non-empty string
|
||||
if (!urlString || typeof urlString !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'URL must be a non-empty string',
|
||||
};
|
||||
}
|
||||
|
||||
const trimmedUrl = urlString.trim();
|
||||
|
||||
if (trimmedUrl.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'URL cannot be empty or whitespace only',
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse URL
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmedUrl);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid URL format: ${trimmedUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for dangerous protocols first
|
||||
if (DANGEROUS_PROTOCOLS.includes(parsedUrl.protocol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Dangerous protocol rejected: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for allowed protocols
|
||||
if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Invalid protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Additional security checks
|
||||
if (!parsedUrl.hostname || parsedUrl.hostname.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'URL must include a valid hostname',
|
||||
};
|
||||
}
|
||||
|
||||
// URL is valid and safe
|
||||
return {
|
||||
valid: true,
|
||||
url: parsedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a URL by removing potentially dangerous components
|
||||
*
|
||||
* This function:
|
||||
* - Trims whitespace
|
||||
* - Removes URL fragments that could be used for XSS
|
||||
* - Normalizes the URL
|
||||
*
|
||||
* @param urlString - The URL to sanitize
|
||||
* @returns Sanitized URL string or null if invalid
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const sanitized = sanitizeUrl(' https://example.com#dangerous ');
|
||||
* // Returns: 'https://example.com/'
|
||||
* ```
|
||||
*/
|
||||
export function sanitizeUrl(urlString: string): string | null {
|
||||
const validation = validateUrl(urlString);
|
||||
|
||||
if (!validation.valid || !validation.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the normalized URL without fragment
|
||||
const sanitized = new URL(validation.url.href);
|
||||
// Keep the fragment for now - it might be needed for single-page apps
|
||||
// If security concerns arise, uncomment: sanitized.hash = '';
|
||||
|
||||
return sanitized.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an HTTP or HTTPS URL
|
||||
*
|
||||
* This is a convenience function for quick protocol checks.
|
||||
*
|
||||
* @param urlString - The URL to check
|
||||
* @returns true if URL is HTTP or HTTPS
|
||||
*/
|
||||
export function isHttpUrl(urlString: string): boolean {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return ALLOWED_PROTOCOLS.includes(url.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for URL validation failures
|
||||
*
|
||||
* @param urlString - The URL that failed validation
|
||||
* @returns User-friendly error message
|
||||
*/
|
||||
export function getUrlErrorMessage(urlString: unknown): string {
|
||||
const result = validateUrl(urlString);
|
||||
|
||||
if (result.valid) {
|
||||
return 'URL is valid';
|
||||
}
|
||||
|
||||
return result.error || 'Unknown URL validation error';
|
||||
}
|
||||
556
packages/extension/tests/background/WindowManager.test.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* WindowManager unit tests
|
||||
*
|
||||
* Tests all WindowManager functionality including window lifecycle,
|
||||
* request tracking, overlay management, and cleanup.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { WindowManager } from '../../src/background/WindowManager';
|
||||
import type {
|
||||
WindowRegistration,
|
||||
InterceptedRequest,
|
||||
} from '../../src/types/window-manager';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
describe('WindowManager', () => {
|
||||
let windowManager: WindowManager;
|
||||
|
||||
beforeEach(() => {
|
||||
windowManager = new WindowManager();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Window Registration', () => {
|
||||
it('should register a new window', async () => {
|
||||
const config: WindowRegistration = {
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false, // Don't trigger overlay in test
|
||||
};
|
||||
|
||||
const window = await windowManager.registerWindow(config);
|
||||
|
||||
expect(window.id).toBe(123);
|
||||
expect(window.tabId).toBe(456);
|
||||
expect(window.url).toBe('https://example.com');
|
||||
expect(window.uuid).toBeDefined();
|
||||
expect(window.uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
expect(window.createdAt).toBeInstanceOf(Date);
|
||||
expect(window.requests).toEqual([]);
|
||||
expect(window.overlayVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should generate unique UUIDs for each window', async () => {
|
||||
const window1 = await windowManager.registerWindow({
|
||||
id: 1,
|
||||
tabId: 10,
|
||||
url: 'https://example1.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
const window2 = await windowManager.registerWindow({
|
||||
id: 2,
|
||||
tabId: 20,
|
||||
url: 'https://example2.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
expect(window1.uuid).not.toBe(window2.uuid);
|
||||
});
|
||||
|
||||
it('should set showOverlayWhenReady by default when showOverlay not specified', async () => {
|
||||
const config: WindowRegistration = {
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const window = await windowManager.registerWindow(config);
|
||||
|
||||
expect(window.showOverlayWhenReady).toBe(true);
|
||||
expect(window.overlayVisible).toBe(false);
|
||||
// Overlay will be shown by tabs.onUpdated listener when tab becomes 'complete'
|
||||
});
|
||||
|
||||
it('should not set showOverlayWhenReady when showOverlay is false', async () => {
|
||||
const config: WindowRegistration = {
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
};
|
||||
|
||||
const window = await windowManager.registerWindow(config);
|
||||
|
||||
expect(window.showOverlayWhenReady).toBe(false);
|
||||
expect(window.overlayVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window Lookup', () => {
|
||||
beforeEach(async () => {
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should retrieve window by ID', () => {
|
||||
const window = windowManager.getWindow(123);
|
||||
|
||||
expect(window).toBeDefined();
|
||||
expect(window!.id).toBe(123);
|
||||
expect(window!.tabId).toBe(456);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent window ID', () => {
|
||||
const window = windowManager.getWindow(999);
|
||||
|
||||
expect(window).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should retrieve window by tab ID', () => {
|
||||
const window = windowManager.getWindowByTabId(456);
|
||||
|
||||
expect(window).toBeDefined();
|
||||
expect(window!.id).toBe(123);
|
||||
expect(window!.tabId).toBe(456);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent tab ID', () => {
|
||||
const window = windowManager.getWindowByTabId(999);
|
||||
|
||||
expect(window).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should retrieve all windows', async () => {
|
||||
await windowManager.registerWindow({
|
||||
id: 456,
|
||||
tabId: 789,
|
||||
url: 'https://example2.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
const allWindows = windowManager.getAllWindows();
|
||||
|
||||
expect(allWindows.size).toBe(2);
|
||||
expect(allWindows.has(123)).toBe(true);
|
||||
expect(allWindows.has(456)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return a copy of windows map', async () => {
|
||||
const windows1 = windowManager.getAllWindows();
|
||||
const windows2 = windowManager.getAllWindows();
|
||||
|
||||
expect(windows1).not.toBe(windows2);
|
||||
expect(windows1.size).toBe(windows2.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window Closing', () => {
|
||||
beforeEach(async () => {
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should close and remove window', async () => {
|
||||
await windowManager.closeWindow(123);
|
||||
|
||||
const window = windowManager.getWindow(123);
|
||||
expect(window).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should hide overlay before closing if visible', async () => {
|
||||
await windowManager.showOverlay(123);
|
||||
vi.clearAllMocks();
|
||||
|
||||
await windowManager.closeWindow(123);
|
||||
|
||||
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
456,
|
||||
expect.objectContaining({
|
||||
type: 'HIDE_TLSN_OVERLAY',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle closing non-existent window gracefully', async () => {
|
||||
await expect(windowManager.closeWindow(999)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Tracking', () => {
|
||||
beforeEach(async () => {
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should add request to window', () => {
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api/data',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
windowManager.addRequest(123, request);
|
||||
|
||||
const requests = windowManager.getWindowRequests(123);
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toEqual(request);
|
||||
});
|
||||
|
||||
it('should add timestamp if not provided', () => {
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api/data',
|
||||
timestamp: 0, // Will be replaced
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
const beforeTime = Date.now();
|
||||
windowManager.addRequest(123, request);
|
||||
const afterTime = Date.now();
|
||||
|
||||
const requests = windowManager.getWindowRequests(123);
|
||||
expect(requests[0].timestamp).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(requests[0].timestamp).toBeLessThanOrEqual(afterTime);
|
||||
});
|
||||
|
||||
it('should handle multiple requests in order', () => {
|
||||
const request1: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/page1',
|
||||
timestamp: 1000,
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
const request2: InterceptedRequest = {
|
||||
id: 'req-2',
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: 2000,
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
windowManager.addRequest(123, request1);
|
||||
windowManager.addRequest(123, request2);
|
||||
|
||||
const requests = windowManager.getWindowRequests(123);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0].id).toBe('req-1');
|
||||
expect(requests[1].id).toBe('req-2');
|
||||
});
|
||||
|
||||
it('should log error when adding request to non-existent window', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 999,
|
||||
};
|
||||
|
||||
windowManager.addRequest(999, request);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Cannot add request to non-existent window'),
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent window requests', () => {
|
||||
const requests = windowManager.getWindowRequests(999);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update overlay when request added to visible overlay', async () => {
|
||||
await windowManager.showOverlay(123);
|
||||
vi.clearAllMocks();
|
||||
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
windowManager.addRequest(123, request);
|
||||
|
||||
// Give async updateOverlay time to execute
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
456,
|
||||
expect.objectContaining({
|
||||
type: 'UPDATE_TLSN_REQUESTS',
|
||||
requests: expect.arrayContaining([request]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Overlay Management', () => {
|
||||
beforeEach(async () => {
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show overlay', async () => {
|
||||
await windowManager.showOverlay(123);
|
||||
|
||||
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
456,
|
||||
expect.objectContaining({
|
||||
type: 'SHOW_TLSN_OVERLAY',
|
||||
requests: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(windowManager.isOverlayVisible(123)).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide overlay', async () => {
|
||||
await windowManager.showOverlay(123);
|
||||
vi.clearAllMocks();
|
||||
|
||||
await windowManager.hideOverlay(123);
|
||||
|
||||
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
456,
|
||||
expect.objectContaining({
|
||||
type: 'HIDE_TLSN_OVERLAY',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(windowManager.isOverlayVisible(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should include requests when showing overlay', async () => {
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
windowManager.addRequest(123, request);
|
||||
await windowManager.showOverlay(123);
|
||||
|
||||
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
|
||||
456,
|
||||
expect.objectContaining({
|
||||
type: 'SHOW_TLSN_OVERLAY',
|
||||
requests: expect.arrayContaining([request]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for non-existent window overlay visibility', () => {
|
||||
expect(windowManager.isOverlayVisible(999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle overlay show error gracefully', async () => {
|
||||
// Mock sendMessage to fail for all retry attempts
|
||||
vi.mocked(browser.tabs.sendMessage).mockRejectedValue(
|
||||
new Error('Tab not found'),
|
||||
);
|
||||
|
||||
// Start showOverlay (which will retry with delays)
|
||||
const showPromise = windowManager.showOverlay(123);
|
||||
|
||||
// Advance timers through all retry delays (10 retries × 500ms = 5000ms)
|
||||
await vi.advanceTimersByTimeAsync(5500);
|
||||
|
||||
await expect(showPromise).resolves.not.toThrow();
|
||||
expect(windowManager.isOverlayVisible(123)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle overlay hide error gracefully', async () => {
|
||||
await windowManager.showOverlay(123);
|
||||
vi.mocked(browser.tabs.sendMessage).mockRejectedValueOnce(
|
||||
new Error('Tab not found'),
|
||||
);
|
||||
|
||||
await expect(windowManager.hideOverlay(123)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should remove invalid windows during cleanup', async () => {
|
||||
// Register multiple windows
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example1.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
await windowManager.registerWindow({
|
||||
id: 456,
|
||||
tabId: 789,
|
||||
url: 'https://example2.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
// Mock window 123 still exists, window 456 is closed
|
||||
vi.mocked(browser.windows.get).mockImplementation((windowId) => {
|
||||
if (windowId === 123) {
|
||||
return Promise.resolve({ id: 123 } as any);
|
||||
}
|
||||
return Promise.reject(new Error('Window not found'));
|
||||
});
|
||||
|
||||
await windowManager.cleanupInvalidWindows();
|
||||
|
||||
// Window 123 should still exist
|
||||
expect(windowManager.getWindow(123)).toBeDefined();
|
||||
|
||||
// Window 456 should be cleaned up
|
||||
expect(windowManager.getWindow(456)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle cleanup with no invalid windows', async () => {
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
vi.mocked(browser.windows.get).mockResolvedValue({ id: 123 } as any);
|
||||
|
||||
await expect(
|
||||
windowManager.cleanupInvalidWindows(),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(windowManager.getWindow(123)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle cleanup with no windows', async () => {
|
||||
await expect(
|
||||
windowManager.cleanupInvalidWindows(),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Scenarios', () => {
|
||||
it('should handle complete window lifecycle', async () => {
|
||||
// Register window
|
||||
const window = await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
expect(window.uuid).toBeDefined();
|
||||
|
||||
// Add requests
|
||||
windowManager.addRequest(123, {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/page',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
});
|
||||
|
||||
windowManager.addRequest(123, {
|
||||
id: 'req-2',
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
});
|
||||
|
||||
expect(windowManager.getWindowRequests(123)).toHaveLength(2);
|
||||
|
||||
// Show overlay
|
||||
await windowManager.showOverlay(123);
|
||||
expect(windowManager.isOverlayVisible(123)).toBe(true);
|
||||
|
||||
// Close window
|
||||
await windowManager.closeWindow(123);
|
||||
expect(windowManager.getWindow(123)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple windows independently', async () => {
|
||||
// Register two windows
|
||||
await windowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example1.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
await windowManager.registerWindow({
|
||||
id: 789,
|
||||
tabId: 1011,
|
||||
url: 'https://example2.com',
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
// Add requests to different windows
|
||||
windowManager.addRequest(123, {
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example1.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
});
|
||||
|
||||
windowManager.addRequest(789, {
|
||||
id: 'req-2',
|
||||
method: 'POST',
|
||||
url: 'https://example2.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 1011,
|
||||
});
|
||||
|
||||
// Each window should have its own requests
|
||||
expect(windowManager.getWindowRequests(123)).toHaveLength(1);
|
||||
expect(windowManager.getWindowRequests(789)).toHaveLength(1);
|
||||
expect(windowManager.getWindowRequests(123)[0].id).toBe('req-1');
|
||||
expect(windowManager.getWindowRequests(789)[0].id).toBe('req-2');
|
||||
|
||||
// Show overlay on one window
|
||||
await windowManager.showOverlay(123);
|
||||
expect(windowManager.isOverlayVisible(123)).toBe(true);
|
||||
expect(windowManager.isOverlayVisible(789)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
266
packages/extension/tests/entries/content-api.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Tests for Content Script Client API (window.tlsn)
|
||||
*
|
||||
* Tests the public API exposed to web pages for interacting
|
||||
* with the TLSN extension.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
describe('Content Script Client API', () => {
|
||||
let postMessageSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock window.postMessage
|
||||
postMessageSpy = vi.spyOn(window, 'postMessage');
|
||||
});
|
||||
|
||||
describe('window.tlsn.open()', () => {
|
||||
// Simulate the injected script's ExtensionAPI class
|
||||
class ExtensionAPI {
|
||||
async open(
|
||||
url: string,
|
||||
options?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
showOverlay?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error('URL must be a non-empty string');
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
// Send message to content script
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: {
|
||||
url,
|
||||
width: options?.width,
|
||||
height: options?.height,
|
||||
showOverlay: options?.showOverlay,
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tlsn: ExtensionAPI;
|
||||
|
||||
beforeEach(() => {
|
||||
tlsn = new ExtensionAPI();
|
||||
});
|
||||
|
||||
it('should post message with valid URL', async () => {
|
||||
await tlsn.open('https://example.com');
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: {
|
||||
url: 'https://example.com',
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
showOverlay: undefined,
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include width and height options', async () => {
|
||||
await tlsn.open('https://example.com', {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: expect.objectContaining({
|
||||
url: 'https://example.com',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
}),
|
||||
}),
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include showOverlay option', async () => {
|
||||
await tlsn.open('https://example.com', {
|
||||
showOverlay: false,
|
||||
});
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: expect.objectContaining({
|
||||
url: 'https://example.com',
|
||||
showOverlay: false,
|
||||
}),
|
||||
}),
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject empty URL', async () => {
|
||||
await expect(tlsn.open('')).rejects.toThrow(
|
||||
'URL must be a non-empty string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject non-string URL', async () => {
|
||||
await expect(tlsn.open(null as any)).rejects.toThrow(
|
||||
'URL must be a non-empty string',
|
||||
);
|
||||
await expect(tlsn.open(undefined as any)).rejects.toThrow(
|
||||
'URL must be a non-empty string',
|
||||
);
|
||||
await expect(tlsn.open(123 as any)).rejects.toThrow(
|
||||
'URL must be a non-empty string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid URL format', async () => {
|
||||
await expect(tlsn.open('not-a-url')).rejects.toThrow('Invalid URL');
|
||||
await expect(tlsn.open('ftp://example.com')).resolves.not.toThrow(); // Valid URL, will be validated by background
|
||||
});
|
||||
|
||||
it('should accept http URLs', async () => {
|
||||
await expect(tlsn.open('http://example.com')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept https URLs', async () => {
|
||||
await expect(tlsn.open('https://example.com')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URLs with paths', async () => {
|
||||
await expect(
|
||||
tlsn.open('https://example.com/path/to/page'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URLs with query parameters', async () => {
|
||||
await expect(
|
||||
tlsn.open('https://example.com/search?q=test&lang=en'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept URLs with fragments', async () => {
|
||||
await expect(
|
||||
tlsn.open('https://example.com/page#section'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should post message to correct origin', async () => {
|
||||
await tlsn.open('https://example.com');
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
window.location.origin,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Type Constants', () => {
|
||||
it('should define all required message types', async () => {
|
||||
const {
|
||||
OPEN_WINDOW,
|
||||
WINDOW_OPENED,
|
||||
WINDOW_ERROR,
|
||||
SHOW_TLSN_OVERLAY,
|
||||
UPDATE_TLSN_REQUESTS,
|
||||
HIDE_TLSN_OVERLAY,
|
||||
} = await import('../../src/constants/messages');
|
||||
|
||||
expect(OPEN_WINDOW).toBe('OPEN_WINDOW');
|
||||
expect(WINDOW_OPENED).toBe('WINDOW_OPENED');
|
||||
expect(WINDOW_ERROR).toBe('WINDOW_ERROR');
|
||||
expect(SHOW_TLSN_OVERLAY).toBe('SHOW_TLSN_OVERLAY');
|
||||
expect(UPDATE_TLSN_REQUESTS).toBe('UPDATE_TLSN_REQUESTS');
|
||||
expect(HIDE_TLSN_OVERLAY).toBe('HIDE_TLSN_OVERLAY');
|
||||
});
|
||||
|
||||
it('should export type definitions', async () => {
|
||||
const messages = await import('../../src/constants/messages');
|
||||
|
||||
// Check that types are exported (TypeScript compilation will verify this)
|
||||
expect(messages).toHaveProperty('OPEN_WINDOW');
|
||||
expect(messages).toHaveProperty('WINDOW_OPENED');
|
||||
expect(messages).toHaveProperty('WINDOW_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Script Message Forwarding', () => {
|
||||
it('should forward TLSN_OPEN_WINDOW to background as OPEN_WINDOW', () => {
|
||||
// This test verifies the message transformation logic
|
||||
const pageMessage = {
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: {
|
||||
url: 'https://example.com',
|
||||
width: 1000,
|
||||
height: 800,
|
||||
showOverlay: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Expected background message format
|
||||
const expectedBackgroundMessage = {
|
||||
type: 'OPEN_WINDOW',
|
||||
url: 'https://example.com',
|
||||
width: 1000,
|
||||
height: 800,
|
||||
showOverlay: true,
|
||||
};
|
||||
|
||||
// Verify transformation logic
|
||||
expect(pageMessage.payload).toEqual({
|
||||
url: expectedBackgroundMessage.url,
|
||||
width: expectedBackgroundMessage.width,
|
||||
height: expectedBackgroundMessage.height,
|
||||
showOverlay: expectedBackgroundMessage.showOverlay,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle optional parameters correctly', () => {
|
||||
const pageMessage = {
|
||||
type: 'TLSN_OPEN_WINDOW',
|
||||
payload: {
|
||||
url: 'https://example.com',
|
||||
},
|
||||
};
|
||||
|
||||
// width, height, showOverlay should be undefined
|
||||
expect(pageMessage.payload.width).toBeUndefined();
|
||||
expect(pageMessage.payload.height).toBeUndefined();
|
||||
expect((pageMessage.payload as any).showOverlay).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Origin Validation', () => {
|
||||
it('should only accept messages from same origin', () => {
|
||||
const currentOrigin = window.location.origin;
|
||||
|
||||
// Valid origins
|
||||
expect(currentOrigin).toBe(window.location.origin);
|
||||
|
||||
// Example of what content script should check
|
||||
const isValidOrigin = (eventOrigin: string) => {
|
||||
return eventOrigin === window.location.origin;
|
||||
};
|
||||
|
||||
expect(isValidOrigin(currentOrigin)).toBe(true);
|
||||
expect(isValidOrigin('https://evil.com')).toBe(false);
|
||||
expect(isValidOrigin('http://different.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/extension/tests/example.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Example test file demonstrating Vitest setup
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('Example Test Suite', () => {
|
||||
it('should perform basic arithmetic', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle string operations', () => {
|
||||
const greeting = 'Hello, TLSNotary!';
|
||||
expect(greeting).toContain('TLSNotary');
|
||||
expect(greeting.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should work with arrays', () => {
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
expect(arr).toHaveLength(5);
|
||||
expect(arr).toContain(3);
|
||||
});
|
||||
|
||||
it('should handle async operations', async () => {
|
||||
const asyncFunc = async () => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve('done'), 100);
|
||||
});
|
||||
};
|
||||
|
||||
const result = await asyncFunc();
|
||||
expect(result).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Validation Example', () => {
|
||||
it('should validate http/https URLs', () => {
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Valid URLs
|
||||
expect(isValidUrl('https://example.com')).toBe(true);
|
||||
expect(isValidUrl('http://test.org')).toBe(true);
|
||||
|
||||
// Invalid URLs
|
||||
expect(isValidUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isValidUrl('not-a-url')).toBe(false);
|
||||
expect(isValidUrl('file:///etc/passwd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser API Mocking Example', () => {
|
||||
it('should have chrome global available', () => {
|
||||
expect(globalThis.chrome).toBeDefined();
|
||||
expect(globalThis.chrome.runtime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should mock webextension-polyfill', async () => {
|
||||
// This demonstrates that our setup.ts mock is working
|
||||
const browser = await import('webextension-polyfill');
|
||||
|
||||
expect(browser.default.runtime.id).toBe('test-extension-id');
|
||||
expect(browser.default.runtime.sendMessage).toBeDefined();
|
||||
expect(browser.default.windows.create).toBeDefined();
|
||||
});
|
||||
});
|
||||
421
packages/extension/tests/integration/MANUAL_TESTING_CHECKLIST.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Manual Testing Checklist - Multi-Window Management
|
||||
|
||||
This checklist covers comprehensive manual testing of the TLSN extension's multi-window management feature.
|
||||
|
||||
## Pre-Testing Setup
|
||||
|
||||
- [ ] Build extension: `npm run build`
|
||||
- [ ] Load unpacked extension in Chrome from `build/` directory
|
||||
- [ ] Open test page: `tests/integration/test-page.html`
|
||||
- [ ] Open Chrome DevTools Console (F12)
|
||||
- [ ] Verify extension icon appears in toolbar
|
||||
|
||||
## Test Environment
|
||||
|
||||
**Browser**: Chrome (version: ________)
|
||||
**Extension Version**: 0.1.0
|
||||
**Test Date**: ___________
|
||||
**Tester**: ___________
|
||||
|
||||
---
|
||||
|
||||
## 1. Basic Window Opening
|
||||
|
||||
### 1.1 Single Window Test
|
||||
- [ ] Click "Open example.com" button
|
||||
- [ ] **Expected**: New popup window opens with example.com
|
||||
- [ ] **Expected**: TLSN overlay appears with dark background
|
||||
- [ ] **Expected**: Overlay shows "TLSN Plugin In Progress" title
|
||||
- [ ] **Expected**: Request list updates as page loads
|
||||
- [ ] **Expected**: Console shows successful window creation logs
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 1.2 Different URLs
|
||||
- [ ] Open httpbin.org
|
||||
- [ ] Open jsonplaceholder.typicode.com
|
||||
- [ ] **Expected**: Each window opens independently
|
||||
- [ ] **Expected**: Each overlay tracks its own requests
|
||||
- [ ] **Expected**: No cross-contamination between windows
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 2. Custom URL Testing
|
||||
|
||||
### 2.1 Valid HTTP URL
|
||||
- [ ] Enter `http://example.com` in custom URL field
|
||||
- [ ] Click "Open URL"
|
||||
- [ ] **Expected**: Window opens successfully
|
||||
- [ ] **Expected**: Overlay displays correctly
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 2.2 Valid HTTPS URL
|
||||
- [ ] Enter `https://github.com` in custom URL field
|
||||
- [ ] Click "Open URL"
|
||||
- [ ] **Expected**: Window opens successfully
|
||||
- [ ] **Expected**: Multiple requests appear in overlay
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 2.3 URL with Path
|
||||
- [ ] Enter `https://example.com/path/to/page`
|
||||
- [ ] Click "Open URL"
|
||||
- [ ] **Expected**: Full URL loads correctly
|
||||
- [ ] **Expected**: Requests tracked properly
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 2.4 URL with Query Parameters
|
||||
- [ ] Enter `https://example.com/search?q=test&lang=en`
|
||||
- [ ] Click "Open URL"
|
||||
- [ ] **Expected**: Query parameters preserved
|
||||
- [ ] **Expected**: Window opens normally
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 3. Window Options
|
||||
|
||||
### 3.1 Custom Dimensions
|
||||
- [ ] Set width to 1200, height to 800
|
||||
- [ ] Keep "Show TLSN Overlay" checked
|
||||
- [ ] Click "Open with Custom Options"
|
||||
- [ ] **Expected**: Window opens with specified dimensions
|
||||
- [ ] **Expected**: Overlay visible
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 3.2 Small Window
|
||||
- [ ] Set width to 600, height to 400
|
||||
- [ ] Click "Open with Custom Options"
|
||||
- [ ] **Expected**: Small window opens
|
||||
- [ ] **Expected**: Overlay scales appropriately
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 3.3 Overlay Disabled
|
||||
- [ ] Uncheck "Show TLSN Overlay"
|
||||
- [ ] Click "Open with Custom Options"
|
||||
- [ ] **Expected**: Window opens WITHOUT overlay
|
||||
- [ ] **Expected**: Requests still tracked (check background console)
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 4. Multiple Windows
|
||||
|
||||
### 4.1 Three Windows
|
||||
- [ ] Click "Open 3 Windows"
|
||||
- [ ] **Expected**: 3 windows open sequentially
|
||||
- [ ] **Expected**: Each has its own overlay
|
||||
- [ ] **Expected**: Window count updates to 3
|
||||
- [ ] **Expected**: No errors in console
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 4.2 Five Windows
|
||||
- [ ] Click "Open 5 Windows"
|
||||
- [ ] **Expected**: 5 windows open
|
||||
- [ ] **Expected**: All overlays functional
|
||||
- [ ] **Expected**: Window count updates correctly
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 4.3 Ten Windows (Stress Test)
|
||||
- [ ] Click "Open 10 Windows"
|
||||
- [ ] **Expected**: All 10 windows open
|
||||
- [ ] **Expected**: System remains responsive
|
||||
- [ ] **Expected**: Each window tracks requests independently
|
||||
- [ ] **Monitor**: Chrome memory usage (should not spike excessively)
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 5. Request Interception
|
||||
|
||||
### 5.1 Request Display
|
||||
- [ ] Open any website with multiple resources (e.g., news site)
|
||||
- [ ] Observe overlay during page load
|
||||
- [ ] **Expected**: Requests appear in real-time
|
||||
- [ ] **Expected**: Each request shows method (GET/POST) and URL
|
||||
- [ ] **Expected**: Request count increases as page loads
|
||||
- [ ] **Expected**: Requests are ordered chronologically
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 5.2 Different Request Types
|
||||
- [ ] Open https://httpbin.org/forms/post
|
||||
- [ ] Submit the form
|
||||
- [ ] **Expected**: POST request appears in overlay
|
||||
- [ ] **Expected**: Both GET and POST requests tracked
|
||||
- [ ] **Expected**: Request method clearly labeled
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 5.3 Multiple Tabs in Single Window
|
||||
- [ ] Open a managed window
|
||||
- [ ] Open a new tab within that window (Ctrl+T)
|
||||
- [ ] Navigate to different URL in new tab
|
||||
- [ ] **Expected**: Only first tab's requests tracked
|
||||
- [ ] **Expected**: New tab's requests not added to overlay
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
### 6.1 Invalid URL
|
||||
- [ ] Click "Invalid URL" button
|
||||
- [ ] **Expected**: Error message appears in test page
|
||||
- [ ] **Expected**: No window opens
|
||||
- [ ] **Expected**: Console shows validation error
|
||||
- [ ] **Expected**: Error count increments
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 6.2 Empty URL
|
||||
- [ ] Click "Empty URL" button
|
||||
- [ ] **Expected**: Error message shows
|
||||
- [ ] **Expected**: No window opens
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 6.3 JavaScript URL
|
||||
- [ ] Click "JavaScript URL" button
|
||||
- [ ] **Expected**: Client-side validation accepts (URL is valid)
|
||||
- [ ] **Expected**: Background script rejects with protocol error
|
||||
- [ ] **Expected**: No window opens
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 6.4 FTP URL
|
||||
- [ ] Click "FTP URL" button
|
||||
- [ ] **Expected**: Background rejects FTP protocol
|
||||
- [ ] **Expected**: Error message indicates only HTTP/HTTPS allowed
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 7. Window Cleanup
|
||||
|
||||
### 7.1 Manual Close
|
||||
- [ ] Open 3 windows using test page
|
||||
- [ ] Manually close one window
|
||||
- [ ] **Expected**: Window closes normally
|
||||
- [ ] **Expected**: Background console shows cleanup log
|
||||
- [ ] **Expected**: No errors in console
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 7.2 Close All Windows
|
||||
- [ ] Open 5 windows
|
||||
- [ ] Close all windows manually
|
||||
- [ ] **Expected**: All windows close cleanly
|
||||
- [ ] **Expected**: Background shows cleanup for each
|
||||
- [ ] **Expected**: Memory usage drops (check Task Manager)
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 7.3 Rapid Open/Close
|
||||
- [ ] Open 3 windows quickly
|
||||
- [ ] Close them immediately in rapid succession
|
||||
- [ ] **Expected**: No race conditions or errors
|
||||
- [ ] **Expected**: All cleanup logs appear
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 8. Backward Compatibility
|
||||
|
||||
### 8.1 Legacy sendMessage API
|
||||
- [ ] Click "Test Legacy sendMessage API" button
|
||||
- [ ] **Expected**: Window opens to https://x.com
|
||||
- [ ] **Expected**: Overlay appears
|
||||
- [ ] **Expected**: Requests tracked
|
||||
- [ ] **Expected**: Console shows legacy API handler used
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 9. Overlay Functionality
|
||||
|
||||
### 9.1 Overlay Visibility
|
||||
- [ ] Open any window
|
||||
- [ ] **Expected**: Overlay appears after page loads (status: complete)
|
||||
- [ ] **Expected**: Overlay covers entire window
|
||||
- [ ] **Expected**: Dark semi-transparent background
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 9.2 Overlay Content
|
||||
- [ ] Check overlay text and styling
|
||||
- [ ] **Expected**: Title: "TLSN Plugin In Progress"
|
||||
- [ ] **Expected**: Subtitle: "Intercepting network requests from this window"
|
||||
- [ ] **Expected**: Request count displayed
|
||||
- [ ] **Expected**: Scrollable request list
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 9.3 Real-time Updates
|
||||
- [ ] Open a news website or social media
|
||||
- [ ] Watch overlay during page load
|
||||
- [ ] **Expected**: Request list updates dynamically
|
||||
- [ ] **Expected**: No flickering or UI glitches
|
||||
- [ ] **Expected**: Smooth scrolling in request list
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 10. Cross-Browser Compatibility (Optional)
|
||||
|
||||
### 10.1 Firefox (with webextension-polyfill)
|
||||
- [ ] Load extension in Firefox
|
||||
- [ ] Run basic window opening tests
|
||||
- [ ] **Expected**: Similar behavior to Chrome
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail ☐ N/A
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 11. Edge Cases
|
||||
|
||||
### 11.1 Network Offline
|
||||
- [ ] Disconnect internet
|
||||
- [ ] Try opening a window
|
||||
- [ ] **Expected**: Window opens but page doesn't load
|
||||
- [ ] **Expected**: Overlay still appears
|
||||
- [ ] **Expected**: Minimal requests captured
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 11.2 Redirect URLs
|
||||
- [ ] Open `http://example.com` (redirects to HTTPS)
|
||||
- [ ] **Expected**: Redirect request captured
|
||||
- [ ] **Expected**: Final HTTPS page loads
|
||||
- [ ] **Expected**: Both requests in overlay
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 11.3 Very Long URL
|
||||
- [ ] Create URL with 1000+ character path
|
||||
- [ ] Open in window
|
||||
- [ ] **Expected**: URL handled correctly
|
||||
- [ ] **Expected**: Overlay truncates long URLs appropriately
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 11.4 Page with 100+ Requests
|
||||
- [ ] Open a complex site (e.g., CNN, BBC News)
|
||||
- [ ] **Expected**: All requests tracked
|
||||
- [ ] **Expected**: Overlay remains responsive
|
||||
- [ ] **Expected**: Scrolling works in request list
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## 12. Console Logs Verification
|
||||
|
||||
### 12.1 Background Script Logs
|
||||
- [ ] Open background service worker console
|
||||
- [ ] Perform various operations
|
||||
- [ ] **Expected**: Clear log messages for each operation
|
||||
- [ ] **Expected**: Window registration logs with UUIDs
|
||||
- [ ] **Expected**: Request interception logs
|
||||
- [ ] **Expected**: Cleanup logs when windows close
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
### 12.2 Content Script Logs
|
||||
- [ ] Open DevTools in managed window
|
||||
- [ ] **Expected**: Content script loaded message
|
||||
- [ ] **Expected**: Overlay show/hide messages
|
||||
- [ ] **Expected**: Request update messages
|
||||
|
||||
**Result**: ☐ Pass ☐ Fail
|
||||
**Notes**: _________________________________
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Total Tests**: ________
|
||||
**Passed**: ________
|
||||
**Failed**: ________
|
||||
**Pass Rate**: ________%
|
||||
|
||||
## Critical Issues Found
|
||||
|
||||
1. ________________________________________________
|
||||
2. ________________________________________________
|
||||
3. ________________________________________________
|
||||
|
||||
## Minor Issues Found
|
||||
|
||||
1. ________________________________________________
|
||||
2. ________________________________________________
|
||||
3. ________________________________________________
|
||||
|
||||
## Recommendations
|
||||
|
||||
________________________________________________
|
||||
________________________________________________
|
||||
________________________________________________
|
||||
|
||||
## Sign-off
|
||||
|
||||
**Tester**: ___________________
|
||||
**Date**: ___________________
|
||||
**Signature**: ___________________
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Performance Observations
|
||||
|
||||
**Browser Memory Usage Before Tests**: ________ MB
|
||||
**Browser Memory Usage After Tests**: ________ MB
|
||||
**CPU Usage During Tests**: ________%
|
||||
**Any Performance Concerns**: ___________________
|
||||
567
packages/extension/tests/integration/PERFORMANCE_TESTING.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# Performance Testing Guidelines
|
||||
|
||||
This document outlines performance testing procedures for the TLSN extension's multi-window management feature.
|
||||
|
||||
## Objectives
|
||||
|
||||
1. Verify extension performance with multiple concurrent windows
|
||||
2. Identify memory leaks
|
||||
3. Ensure responsive UI under load
|
||||
4. Measure request tracking overhead
|
||||
5. Validate cleanup efficiency
|
||||
|
||||
---
|
||||
|
||||
## Test Environment Setup
|
||||
|
||||
### Required Tools
|
||||
|
||||
1. **Chrome Task Manager**
|
||||
- Access via: Menu → More Tools → Task Manager (Shift+Esc)
|
||||
- Shows per-process memory and CPU usage
|
||||
|
||||
2. **Chrome DevTools Performance Panel**
|
||||
- Open DevTools (F12)
|
||||
- Navigate to Performance tab
|
||||
- Record and analyze performance profiles
|
||||
|
||||
3. **Chrome Memory Profiler**
|
||||
- DevTools → Memory tab
|
||||
- Take heap snapshots before/after tests
|
||||
|
||||
4. **System Monitor**
|
||||
- Windows: Task Manager
|
||||
- macOS: Activity Monitor
|
||||
- Linux: System Monitor / htop
|
||||
|
||||
### Baseline Metrics
|
||||
|
||||
Before running performance tests, establish baseline metrics:
|
||||
|
||||
```
|
||||
Extension with NO windows open:
|
||||
- Memory usage: ________ MB
|
||||
- CPU usage: ________%
|
||||
- Service worker memory: ________ MB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 1: Multiple Windows Load Test
|
||||
|
||||
### Objective
|
||||
Verify extension handles 5-10 concurrent windows efficiently.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Setup**
|
||||
- Close all browser windows except test page
|
||||
- Clear browser cache
|
||||
- Restart browser
|
||||
- Take initial memory snapshot
|
||||
|
||||
2. **Test Execution**
|
||||
- Open test page: `tests/integration/test-page.html`
|
||||
- Click "Open 5 Windows" button
|
||||
- Wait for all windows to fully load
|
||||
- Let windows remain idle for 2 minutes
|
||||
- Take memory snapshot
|
||||
- Open Chrome Task Manager
|
||||
|
||||
3. **Measurements**
|
||||
- Memory per window: ________ MB
|
||||
- Total extension memory: ________ MB
|
||||
- Service worker memory: ________ MB
|
||||
- CPU usage during load: ________%
|
||||
- CPU usage at idle: ________%
|
||||
|
||||
4. **Repeat with 10 Windows**
|
||||
- Click "Open 10 Windows"
|
||||
- Record same metrics
|
||||
- Compare growth rate
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Memory per window**: < 50 MB
|
||||
- **Total extension memory**: < 200 MB for 10 windows
|
||||
- **CPU at idle**: < 1%
|
||||
- **UI responsiveness**: No lag in overlay updates
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ Memory usage scales linearly (not exponentially)
|
||||
☐ No memory growth during idle period
|
||||
☐ CPU usage returns to baseline after page loads
|
||||
☐ All overlays remain functional
|
||||
☐ No console errors
|
||||
|
||||
---
|
||||
|
||||
## Test 2: Memory Leak Detection
|
||||
|
||||
### Objective
|
||||
Ensure no memory leaks when windows are opened and closed repeatedly.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Baseline**
|
||||
- Take heap snapshot (DevTools → Memory → Heap Snapshot)
|
||||
- Note initial memory usage
|
||||
|
||||
2. **Open/Close Cycle** (Repeat 10 times)
|
||||
- Open 5 windows
|
||||
- Wait 10 seconds
|
||||
- Close all 5 windows
|
||||
- Wait 10 seconds
|
||||
- Force garbage collection (DevTools → Memory → Collect garbage)
|
||||
|
||||
3. **Final Measurement**
|
||||
- Take heap snapshot
|
||||
- Compare with baseline
|
||||
- Analyze retained objects
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Memory after 10 cycles**: Within 10% of baseline
|
||||
- **Detached DOM nodes**: 0
|
||||
- **Event listeners**: All cleaned up
|
||||
- **WindowManager map**: Empty after all windows closed
|
||||
|
||||
### Red Flags
|
||||
|
||||
⚠️ Memory continuously increasing after each cycle
|
||||
⚠️ Detached DOM nodes accumulating
|
||||
⚠️ Event listeners not being removed
|
||||
⚠️ WindowManager retaining closed windows
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ Memory returns to baseline (±10%)
|
||||
☐ No detached DOM nodes
|
||||
☐ WindowManager.getAllWindows() returns empty map
|
||||
☐ Garbage collection clears temporary objects
|
||||
|
||||
---
|
||||
|
||||
## Test 3: High-Traffic Site Performance
|
||||
|
||||
### Objective
|
||||
Test performance with sites that generate many HTTP requests.
|
||||
|
||||
### Test Sites
|
||||
|
||||
1. **News Sites** (100+ requests)
|
||||
- https://cnn.com
|
||||
- https://bbc.com
|
||||
- https://nytimes.com
|
||||
|
||||
2. **Social Media** (continuous requests)
|
||||
- https://twitter.com
|
||||
- https://reddit.com
|
||||
|
||||
3. **E-commerce** (images/scripts)
|
||||
- https://amazon.com
|
||||
- https://ebay.com
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Open each site in managed window
|
||||
2. Let page fully load
|
||||
3. Scroll through entire page
|
||||
4. Measure:
|
||||
- Total requests captured: ________
|
||||
- Memory per window: ________ MB
|
||||
- Page load time vs. without extension: ________ seconds
|
||||
- Overlay update latency: ________ ms
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Requests tracked**: All HTTP/HTTPS requests
|
||||
- **Overhead per request**: < 1 KB
|
||||
- **Overlay update latency**: < 100ms
|
||||
- **Page load overhead**: < 10%
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ All requests captured accurately
|
||||
☐ No requests dropped
|
||||
☐ Overlay scrolling remains smooth
|
||||
☐ Page performance not significantly impacted
|
||||
☐ Memory usage stays within bounds
|
||||
|
||||
---
|
||||
|
||||
## Test 4: Request Tracking Overhead
|
||||
|
||||
### Objective
|
||||
Measure the overhead of request interception and tracking.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Without Extension**
|
||||
- Disable TLSN extension
|
||||
- Open https://httpbin.org/get
|
||||
- Record page load time (DevTools → Network → Load time)
|
||||
- Run 10 times, calculate average
|
||||
|
||||
2. **With Extension (Unmanaged Window)**
|
||||
- Enable extension
|
||||
- Open https://httpbin.org/get in regular browser window
|
||||
- Record page load time
|
||||
- Run 10 times, calculate average
|
||||
|
||||
3. **With Extension (Managed Window)**
|
||||
- Use `window.tlsn.open('https://httpbin.org/get')`
|
||||
- Record page load time
|
||||
- Run 10 times, calculate average
|
||||
|
||||
### Measurements
|
||||
|
||||
| Scenario | Avg Load Time | Overhead |
|
||||
|----------|---------------|----------|
|
||||
| No extension | ________ ms | 0% |
|
||||
| Extension (regular) | ________ ms | ________% |
|
||||
| Extension (managed) | ________ ms | ________% |
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Overhead (regular window)**: < 5%
|
||||
- **Overhead (managed window)**: < 15%
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ Request interception overhead is minimal
|
||||
☐ User-perceivable page load time not significantly affected
|
||||
☐ No network errors introduced by interception
|
||||
|
||||
---
|
||||
|
||||
## Test 5: Cleanup Efficiency
|
||||
|
||||
### Objective
|
||||
Verify window cleanup is fast and thorough.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. **Setup**
|
||||
- Open 10 managed windows
|
||||
- Take memory snapshot
|
||||
|
||||
2. **Close All Windows**
|
||||
- Close all 10 windows
|
||||
- Immediately check background console logs
|
||||
- Wait 5 seconds
|
||||
- Force garbage collection
|
||||
- Take memory snapshot
|
||||
|
||||
3. **Verify Cleanup**
|
||||
- Check `windowManager.getAllWindows().size` (should be 0)
|
||||
- Check for orphaned event listeners
|
||||
- Check for remaining overlay DOM elements
|
||||
|
||||
### Measurements
|
||||
|
||||
- **Cleanup time per window**: ________ ms
|
||||
- **Total cleanup time**: ________ ms
|
||||
- **Memory released**: ________ MB
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Cleanup time per window**: < 50ms
|
||||
- **Memory released**: > 80% of managed window memory
|
||||
- **All resources cleaned up**: Yes
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ All windows removed from WindowManager
|
||||
☐ No orphaned DOM elements
|
||||
☐ No remaining event listeners
|
||||
☐ Memory released to OS
|
||||
☐ No errors during cleanup
|
||||
|
||||
---
|
||||
|
||||
## Test 6: Concurrent Request Handling
|
||||
|
||||
### Objective
|
||||
Verify extension handles simultaneous requests from multiple windows.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Open 5 windows to different sites simultaneously
|
||||
2. All sites should load at same time
|
||||
3. Monitor:
|
||||
- Request interception in each window
|
||||
- Overlay updates
|
||||
- Console for errors
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **All requests tracked**: Yes, in correct windows
|
||||
- **No cross-window contamination**: Requests don't leak between windows
|
||||
- **Overlay updates**: All overlays update correctly
|
||||
- **Performance**: No significant slowdown
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ Each window tracks only its own requests
|
||||
☐ No race conditions in WindowManager
|
||||
☐ All overlays functional
|
||||
☐ No console errors
|
||||
|
||||
---
|
||||
|
||||
## Test 7: Long-Running Window Test
|
||||
|
||||
### Objective
|
||||
Verify no memory leaks or performance degradation over time.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Open 3 managed windows to high-traffic sites
|
||||
2. Let run for 30 minutes
|
||||
3. Periodically refresh pages (every 5 minutes)
|
||||
4. Monitor memory usage every 5 minutes
|
||||
|
||||
### Memory Tracking Table
|
||||
|
||||
| Time | Window 1 | Window 2 | Window 3 | Total | Service Worker |
|
||||
|------|----------|----------|----------|-------|----------------|
|
||||
| 0 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
| 5 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
| 10 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
| 15 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
| 20 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
| 25 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
| 30 min | ___ MB | ___ MB | ___ MB | ___ MB | ___ MB |
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Memory growth**: < 20% over 30 minutes
|
||||
- **Request array size**: Bounded (not growing infinitely)
|
||||
- **Performance**: Consistent throughout test
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ Memory usage remains stable
|
||||
☐ No continuous memory growth trend
|
||||
☐ Overlays remain responsive
|
||||
☐ Request tracking still accurate
|
||||
|
||||
---
|
||||
|
||||
## Test 8: Periodic Cleanup Verification
|
||||
|
||||
### Objective
|
||||
Verify the periodic cleanup (5-minute interval) works correctly.
|
||||
|
||||
### Procedure
|
||||
|
||||
1. Open 3 managed windows
|
||||
2. Manually close browser windows (not via extension)
|
||||
3. Wait 6 minutes
|
||||
4. Check background console for cleanup logs
|
||||
5. Verify WindowManager state
|
||||
|
||||
### Expected Results
|
||||
|
||||
- **Cleanup runs**: After ~5 minutes
|
||||
- **Invalid windows detected**: Yes
|
||||
- **Cleanup successful**: All closed windows removed
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
☐ Periodic cleanup timer fires
|
||||
☐ Invalid windows detected and removed
|
||||
☐ Cleanup logs appear in console
|
||||
☐ No errors during automated cleanup
|
||||
|
||||
---
|
||||
|
||||
## Baseline Performance Targets
|
||||
|
||||
### Memory Usage
|
||||
|
||||
| Scenario | Target | Maximum |
|
||||
|----------|--------|---------|
|
||||
| Extension installed (idle) | < 10 MB | 20 MB |
|
||||
| 1 managed window | < 30 MB | 50 MB |
|
||||
| 5 managed windows | < 120 MB | 200 MB |
|
||||
| 10 managed windows | < 220 MB | 350 MB |
|
||||
|
||||
### CPU Usage
|
||||
|
||||
| Scenario | Target | Maximum |
|
||||
|----------|--------|---------|
|
||||
| Idle | < 0.1% | 1% |
|
||||
| During page load | < 5% | 15% |
|
||||
| Overlay update | < 1% | 3% |
|
||||
|
||||
### Request Processing
|
||||
|
||||
| Metric | Target | Maximum |
|
||||
|--------|--------|---------|
|
||||
| Request interception overhead | < 1ms | 5ms |
|
||||
| Overlay update latency | < 50ms | 200ms |
|
||||
| Memory per request | < 500 bytes | 2 KB |
|
||||
|
||||
### Cleanup Performance
|
||||
|
||||
| Metric | Target | Maximum |
|
||||
|--------|--------|---------|
|
||||
| Window cleanup time | < 20ms | 100ms |
|
||||
| Memory release after cleanup | > 90% | > 70% |
|
||||
|
||||
---
|
||||
|
||||
## Performance Issues to Watch For
|
||||
|
||||
### Critical Issues
|
||||
|
||||
⛔ **Memory leaks** - Memory continuously growing
|
||||
⛔ **High CPU usage** - > 10% when idle
|
||||
⛔ **UI freezing** - Overlay becomes unresponsive
|
||||
⛔ **Request drops** - Not all requests captured
|
||||
⛔ **Crash or hang** - Extension becomes unresponsive
|
||||
|
||||
### Warning Signs
|
||||
|
||||
⚠️ Memory growth > 20% over 30 minutes
|
||||
⚠️ Cleanup takes > 100ms per window
|
||||
⚠️ Page load overhead > 20%
|
||||
⚠️ Overlay update latency > 200ms
|
||||
|
||||
---
|
||||
|
||||
## Tools and Commands
|
||||
|
||||
### Chrome Task Manager
|
||||
```
|
||||
Shift+Esc (Windows/Linux)
|
||||
Cmd+Opt+Esc (macOS)
|
||||
```
|
||||
|
||||
### Force Garbage Collection (DevTools Console)
|
||||
```javascript
|
||||
// Run in DevTools Console
|
||||
performance.memory; // Check current memory
|
||||
```
|
||||
|
||||
### Check WindowManager State (Background Console)
|
||||
```javascript
|
||||
// Access background service worker console
|
||||
// chrome://extensions → TLSN Extension → Service worker "Inspect"
|
||||
|
||||
// Check managed windows
|
||||
windowManager.getAllWindows();
|
||||
|
||||
// Check specific window
|
||||
windowManager.getWindow(windowId);
|
||||
```
|
||||
|
||||
### Monitor Extension Memory
|
||||
```bash
|
||||
# Chrome flags for debugging
|
||||
chrome --enable-precise-memory-info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reporting Format
|
||||
|
||||
### Performance Test Report Template
|
||||
|
||||
```
|
||||
TLSN Extension - Performance Test Report
|
||||
Date: ___________
|
||||
Tester: ___________
|
||||
Chrome Version: ___________
|
||||
OS: ___________
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
✅ Passed Tests: _____ / _____
|
||||
❌ Failed Tests: _____ / _____
|
||||
|
||||
## Memory Usage
|
||||
|
||||
- Baseline: _____ MB
|
||||
- With 5 windows: _____ MB (_____ MB/window)
|
||||
- With 10 windows: _____ MB (_____ MB/window)
|
||||
- After cleanup: _____ MB
|
||||
|
||||
## CPU Usage
|
||||
|
||||
- Idle: _____%
|
||||
- During load: _____%
|
||||
- Average: _____%
|
||||
|
||||
## Critical Issues
|
||||
|
||||
1. ___________________________________
|
||||
2. ___________________________________
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
1. ___________________________________
|
||||
2. ___________________________________
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. ___________________________________
|
||||
2. ___________________________________
|
||||
|
||||
## Conclusion
|
||||
|
||||
☐ Performance meets all targets
|
||||
☐ Performance meets most targets with minor issues
|
||||
☐ Performance issues require optimization
|
||||
☐ Critical performance problems found
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Continuous Performance Monitoring
|
||||
|
||||
### Automated Metrics (Future)
|
||||
|
||||
Consider adding automated performance tests:
|
||||
|
||||
1. **Unit test performance assertions**
|
||||
```javascript
|
||||
it('should register window in < 50ms', async () => {
|
||||
const start = performance.now();
|
||||
await windowManager.registerWindow(config);
|
||||
const duration = performance.now() - start;
|
||||
expect(duration).toBeLessThan(50);
|
||||
});
|
||||
```
|
||||
|
||||
2. **Memory leak detection in CI/CD**
|
||||
- Run open/close cycles
|
||||
- Assert memory returns to baseline
|
||||
|
||||
3. **Bundle size monitoring**
|
||||
- Track extension build size
|
||||
- Alert on significant increases
|
||||
|
||||
---
|
||||
|
||||
## Performance Optimization Checklist
|
||||
|
||||
If performance issues are found:
|
||||
|
||||
☐ Profile code with Chrome DevTools Performance panel
|
||||
☐ Check for unnecessary re-renders in overlays
|
||||
☐ Verify event listeners are properly cleaned up
|
||||
☐ Look for memory retention in closures
|
||||
☐ Consider implementing request limits per window
|
||||
☐ Optimize request storage (e.g., use fixed-size buffer)
|
||||
☐ Review webRequest listener efficiency
|
||||
☐ Consider debouncing overlay updates
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Regular performance testing ensures the TLSN extension remains fast and efficient as features are added. Use this document as a guide for both manual and automated performance validation.
|
||||
261
packages/extension/tests/integration/README.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Integration Testing Suite
|
||||
|
||||
This directory contains comprehensive integration and performance testing tools for the TLSN extension's multi-window management feature.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Run Integration Tests
|
||||
|
||||
```bash
|
||||
# Build the extension
|
||||
npm run build
|
||||
|
||||
# Load extension in Chrome
|
||||
# 1. Navigate to chrome://extensions/
|
||||
# 2. Enable Developer Mode
|
||||
# 3. Click "Load unpacked"
|
||||
# 4. Select the build/ directory
|
||||
|
||||
# Open test page
|
||||
open tests/integration/test-page.html
|
||||
# Or navigate to: file:///path/to/tlsn-extension/tests/integration/test-page.html
|
||||
```
|
||||
|
||||
### 2. Run Manual Tests
|
||||
|
||||
Follow the checklist in `MANUAL_TESTING_CHECKLIST.md`:
|
||||
- Print or open the checklist
|
||||
- Work through each test category
|
||||
- Check off completed tests
|
||||
- Document any issues found
|
||||
|
||||
### 3. Run Performance Tests
|
||||
|
||||
Follow the procedures in `PERFORMANCE_TESTING.md`:
|
||||
- Establish baseline metrics
|
||||
- Run each performance test
|
||||
- Document results
|
||||
- Compare against target metrics
|
||||
|
||||
## Files
|
||||
|
||||
### `test-page.html`
|
||||
**Interactive HTML test page** for exercising the `window.tlsn.open()` API.
|
||||
|
||||
**Features:**
|
||||
- 6 test sections covering all functionality
|
||||
- Real-time statistics tracking
|
||||
- Custom URL input
|
||||
- Window options configuration
|
||||
- Error handling verification
|
||||
- Multiple window stress testing
|
||||
- Legacy API compatibility test
|
||||
|
||||
**Usage:**
|
||||
1. Open in any browser (works as file:// URL)
|
||||
2. Ensure TLSN extension is installed
|
||||
3. Click buttons to run tests
|
||||
4. Monitor status messages and console logs
|
||||
|
||||
### `MANUAL_TESTING_CHECKLIST.md`
|
||||
**Comprehensive manual testing checklist** with 50+ test cases.
|
||||
|
||||
**Test Categories:**
|
||||
1. Basic Window Opening
|
||||
2. Custom URL Testing
|
||||
3. Window Options
|
||||
4. Multiple Windows
|
||||
5. Request Interception
|
||||
6. Error Handling
|
||||
7. Window Cleanup
|
||||
8. Backward Compatibility
|
||||
9. Overlay Functionality
|
||||
10. Cross-Browser Compatibility (optional)
|
||||
11. Edge Cases
|
||||
12. Console Logs Verification
|
||||
|
||||
**Usage:**
|
||||
1. Print or open in editor
|
||||
2. Follow each test step
|
||||
3. Mark pass/fail for each test
|
||||
4. Document issues in notes section
|
||||
5. Complete summary and sign-off
|
||||
|
||||
### `PERFORMANCE_TESTING.md`
|
||||
**Detailed performance testing guidelines** and procedures.
|
||||
|
||||
**Test Suite:**
|
||||
1. Multiple Windows Load Test
|
||||
2. Memory Leak Detection
|
||||
3. High-Traffic Site Performance
|
||||
4. Request Tracking Overhead
|
||||
5. Cleanup Efficiency
|
||||
6. Concurrent Request Handling
|
||||
7. Long-Running Window Test (30 min)
|
||||
8. Periodic Cleanup Verification
|
||||
|
||||
**Baseline Targets:**
|
||||
- Memory per window: < 50 MB
|
||||
- CPU at idle: < 1%
|
||||
- Request interception overhead: < 1ms
|
||||
- Cleanup time per window: < 20ms
|
||||
|
||||
**Usage:**
|
||||
1. Follow test procedures in order
|
||||
2. Record measurements in provided tables
|
||||
3. Compare against baseline targets
|
||||
4. Document performance issues
|
||||
5. Generate performance test report
|
||||
|
||||
## Test Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 1. Unit Tests (npm test) │
|
||||
│ - WindowManager tests (30 tests) │
|
||||
│ - Type definition tests (11 tests) │
|
||||
│ - Client API tests (17 tests) │
|
||||
│ - UUID tests (7 tests) │
|
||||
│ Result: All 72 tests passing ✅ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 2. Integration Tests (test-page.html) │
|
||||
│ - Open test page in browser │
|
||||
│ - Run automated test scenarios │
|
||||
│ - Verify window.tlsn.open() API │
|
||||
│ - Check overlay functionality │
|
||||
│ Result: Visual inspection + console logs │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 3. Manual Testing (MANUAL_TESTING_CHECKLIST) │
|
||||
│ - Systematic verification of all features │
|
||||
│ - Edge case testing │
|
||||
│ - Cross-browser testing (optional) │
|
||||
│ - Documentation of issues │
|
||||
│ Result: Completed checklist with sign-off │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 4. Performance Testing (PERFORMANCE_TESTING) │
|
||||
│ - Memory usage measurement │
|
||||
│ - CPU usage monitoring │
|
||||
│ - Memory leak detection │
|
||||
│ - Load testing with multiple windows │
|
||||
│ Result: Performance test report │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### Before Testing
|
||||
- [ ] Build extension with latest changes: `npm run build`
|
||||
- [ ] Clear browser cache and restart
|
||||
- [ ] Close unnecessary browser tabs
|
||||
- [ ] Open Chrome Task Manager (Shift+Esc)
|
||||
- [ ] Open background service worker console
|
||||
|
||||
### During Testing
|
||||
- [ ] Monitor console logs (both page and background)
|
||||
- [ ] Check for errors or warnings
|
||||
- [ ] Verify overlay appearance and content
|
||||
- [ ] Test in incognito mode (if applicable)
|
||||
- [ ] Document unexpected behavior
|
||||
|
||||
### After Testing
|
||||
- [ ] Clean up opened windows
|
||||
- [ ] Check for memory leaks
|
||||
- [ ] Verify background cleanup logs
|
||||
- [ ] Document all findings
|
||||
- [ ] Create bug reports for issues
|
||||
|
||||
## Common Issues and Troubleshooting
|
||||
|
||||
### Issue: "window.tlsn not available"
|
||||
**Cause**: Extension not loaded or content script failed to inject
|
||||
**Solution**:
|
||||
- Reload extension in chrome://extensions/
|
||||
- Check extension permissions
|
||||
- Verify content script injected: Check page console for "Content script loaded"
|
||||
|
||||
### Issue: Overlay doesn't appear
|
||||
**Cause**: Content script not ready or showOverlay=false
|
||||
**Solution**:
|
||||
- Check tab status (must be 'complete')
|
||||
- Verify content script console logs
|
||||
- Check showOverlay parameter in test
|
||||
|
||||
### Issue: Requests not tracked
|
||||
**Cause**: Window not managed or webRequest listener issue
|
||||
**Solution**:
|
||||
- Verify window opened via window.tlsn.open()
|
||||
- Check background console for registration log
|
||||
- Ensure URL is HTTP/HTTPS (not file://)
|
||||
|
||||
### Issue: High memory usage
|
||||
**Cause**: Memory leak or too many requests stored
|
||||
**Solution**:
|
||||
- Run memory leak detection test
|
||||
- Check WindowManager for orphaned windows
|
||||
- Consider implementing request limit per window
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting issues found during testing, include:
|
||||
|
||||
1. **Test Details**
|
||||
- Which test was running
|
||||
- Step-by-step reproduction
|
||||
- Expected vs actual behavior
|
||||
|
||||
2. **Environment**
|
||||
- Chrome version
|
||||
- OS version
|
||||
- Extension version
|
||||
|
||||
3. **Evidence**
|
||||
- Console logs (both page and background)
|
||||
- Screenshots of issues
|
||||
- Performance metrics (if applicable)
|
||||
|
||||
4. **Severity**
|
||||
- Critical: Blocks core functionality
|
||||
- Major: Significant feature broken
|
||||
- Minor: Edge case or cosmetic issue
|
||||
|
||||
## Continuous Integration (Future)
|
||||
|
||||
Consider automating tests in CI/CD:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions workflow
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Build extension
|
||||
run: npm run build
|
||||
|
||||
- name: Run integration tests (headless)
|
||||
run: npm run test:integration
|
||||
# Would use Puppeteer or Playwright
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Chrome Extension Documentation](https://developer.chrome.com/docs/extensions/)
|
||||
- [webextension-polyfill](https://github.com/mozilla/webextension-polyfill)
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/)
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new tests:
|
||||
1. Add test cases to appropriate checklist
|
||||
2. Update performance baselines if needed
|
||||
3. Document any new test procedures
|
||||
4. Update this README if adding new files
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Check the main [CLAUDE.md](../../CLAUDE.md) for architecture documentation.
|
||||
566
packages/extension/tests/integration/test-page.html
Normal file
@@ -0,0 +1,566 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TLSN Extension - Integration Test Page</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.section p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
#status {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#status.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
color: #856404;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
margin-left: 20px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.instructions li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f4f4f4;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #d63384;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-group label:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔒 TLSN Extension Integration Tests</h1>
|
||||
<p class="subtitle">Test the window.tlsn.open() API with various scenarios</p>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>📋 Setup Instructions</h3>
|
||||
<ol>
|
||||
<li>Load the TLSN extension in Chrome (Developer Mode)</li>
|
||||
<li>Navigate to this test page (or open as file://)</li>
|
||||
<li>Click the buttons below to test different scenarios</li>
|
||||
<li>Check the browser console for detailed logs</li>
|
||||
<li>Verify overlays appear in opened windows</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Basic Tests -->
|
||||
<div class="section">
|
||||
<h2>1. Basic Window Opening</h2>
|
||||
<p>Test opening single windows with different URLs</p>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="openWindow('https://example.com')">
|
||||
Open example.com
|
||||
</button>
|
||||
<button onclick="openWindow('https://httpbin.org/get')">
|
||||
Open httpbin.org
|
||||
</button>
|
||||
<button onclick="openWindow('https://jsonplaceholder.typicode.com/posts')">
|
||||
Open JSON Placeholder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom URL Test -->
|
||||
<div class="section">
|
||||
<h2>2. Custom URL Test</h2>
|
||||
<p>Enter any URL to open in a new window</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="customUrl"
|
||||
placeholder="https://example.com"
|
||||
value="https://example.com"
|
||||
>
|
||||
<button onclick="openCustomUrl()">Open URL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Window Options Test -->
|
||||
<div class="section">
|
||||
<h2>3. Window Options</h2>
|
||||
<p>Test window.tlsn.open() with custom dimensions and overlay options</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="number" id="width" placeholder="Width" value="1200">
|
||||
<input type="number" id="height" placeholder="Height" value="800">
|
||||
</div>
|
||||
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="showOverlay" checked>
|
||||
<span>Show TLSN Overlay</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button onclick="openWithOptions()">
|
||||
Open with Custom Options
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Windows Test -->
|
||||
<div class="section">
|
||||
<h2>4. Multiple Windows</h2>
|
||||
<p>Test opening multiple windows simultaneously</p>
|
||||
|
||||
<div class="button-group">
|
||||
<button onclick="openMultipleWindows(3)">
|
||||
Open 3 Windows
|
||||
</button>
|
||||
<button onclick="openMultipleWindows(5)">
|
||||
Open 5 Windows
|
||||
</button>
|
||||
<button onclick="openMultipleWindows(10)">
|
||||
Open 10 Windows
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="windowCount">0</span>
|
||||
<span class="stat-label">Windows Opened</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value" id="errorCount">0</span>
|
||||
<span class="stat-label">Errors</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Handling Tests -->
|
||||
<div class="section">
|
||||
<h2>5. Error Handling</h2>
|
||||
<p>Test invalid URLs and error cases</p>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="danger" onclick="testInvalidUrl('not-a-url')">
|
||||
Invalid URL
|
||||
</button>
|
||||
<button class="danger" onclick="testInvalidUrl('')">
|
||||
Empty URL
|
||||
</button>
|
||||
<button class="danger" onclick="testInvalidUrl('javascript:alert(1)')">
|
||||
JavaScript URL
|
||||
</button>
|
||||
<button class="danger" onclick="testInvalidUrl('ftp://example.com')">
|
||||
FTP URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy API Test -->
|
||||
<div class="section">
|
||||
<h2>6. Backward Compatibility</h2>
|
||||
<p>Test legacy API (TLSN_CONTENT_TO_EXTENSION)</p>
|
||||
|
||||
<button class="secondary" onclick="testLegacyAPI()">
|
||||
Test Legacy sendMessage API
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Display -->
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let windowCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Check if window.tlsn is available
|
||||
window.addEventListener('extension_loaded', () => {
|
||||
showStatus('TLSN Extension loaded and ready!', 'success');
|
||||
});
|
||||
|
||||
// Wait for extension to load
|
||||
setTimeout(() => {
|
||||
if (!window.tlsn) {
|
||||
showStatus('⚠️ TLSN Extension not detected. Please install and enable the extension.', 'error');
|
||||
} else {
|
||||
showStatus('✅ TLSN Extension detected! You can now run tests.', 'success');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Basic window opening
|
||||
async function openWindow(url) {
|
||||
console.log(`[Test] Opening window: ${url}`);
|
||||
|
||||
if (!window.tlsn) {
|
||||
showStatus('Error: window.tlsn not available. Is the extension installed?', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.tlsn.open(url);
|
||||
windowCount++;
|
||||
updateStats();
|
||||
showStatus(`✅ Successfully opened: ${url}`, 'success');
|
||||
console.log(`[Test] Window opened successfully`);
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
updateStats();
|
||||
showStatus(`❌ Error opening window: ${error.message}`, 'error');
|
||||
console.error('[Test] Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom URL test
|
||||
async function openCustomUrl() {
|
||||
const url = document.getElementById('customUrl').value.trim();
|
||||
|
||||
if (!url) {
|
||||
showStatus('Please enter a URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await openWindow(url);
|
||||
}
|
||||
|
||||
// Window with options
|
||||
async function openWithOptions() {
|
||||
const url = document.getElementById('customUrl').value.trim() || 'https://example.com';
|
||||
const width = parseInt(document.getElementById('width').value) || 900;
|
||||
const height = parseInt(document.getElementById('height').value) || 700;
|
||||
const showOverlay = document.getElementById('showOverlay').checked;
|
||||
|
||||
console.log(`[Test] Opening window with options:`, { url, width, height, showOverlay });
|
||||
|
||||
if (!window.tlsn) {
|
||||
showStatus('Error: window.tlsn not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.tlsn.open(url, { width, height, showOverlay });
|
||||
windowCount++;
|
||||
updateStats();
|
||||
showStatus(`✅ Opened ${url} (${width}x${height}, overlay: ${showOverlay})`, 'success');
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
updateStats();
|
||||
showStatus(`❌ Error: ${error.message}`, 'error');
|
||||
console.error('[Test] Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple windows
|
||||
async function openMultipleWindows(count) {
|
||||
console.log(`[Test] Opening ${count} windows...`);
|
||||
showStatus(`Opening ${count} windows...`, 'info');
|
||||
|
||||
const urls = [
|
||||
'https://example.com',
|
||||
'https://httpbin.org/get',
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
'https://api.github.com',
|
||||
'https://www.wikipedia.org',
|
||||
'https://developer.mozilla.org',
|
||||
'https://stackoverflow.com',
|
||||
'https://news.ycombinator.com',
|
||||
'https://reddit.com',
|
||||
'https://twitter.com',
|
||||
];
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const url = urls[i % urls.length];
|
||||
try {
|
||||
await window.tlsn.open(url);
|
||||
successCount++;
|
||||
windowCount++;
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
errorCount++;
|
||||
console.error(`[Test] Failed to open window ${i + 1}:`, error);
|
||||
}
|
||||
// Small delay between windows
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
updateStats();
|
||||
showStatus(`✅ Opened ${successCount}/${count} windows (${failCount} failed)`, successCount === count ? 'success' : 'error');
|
||||
}
|
||||
|
||||
// Test invalid URLs
|
||||
async function testInvalidUrl(url) {
|
||||
console.log(`[Test] Testing invalid URL: ${url}`);
|
||||
|
||||
if (!window.tlsn) {
|
||||
showStatus('Error: window.tlsn not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.tlsn.open(url);
|
||||
errorCount++;
|
||||
updateStats();
|
||||
showStatus(`❌ Expected error but succeeded for: ${url}`, 'error');
|
||||
} catch (error) {
|
||||
showStatus(`✅ Correctly rejected invalid URL: ${error.message}`, 'success');
|
||||
console.log('[Test] Correctly caught error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test legacy API
|
||||
function testLegacyAPI() {
|
||||
console.log('[Test] Testing legacy sendMessage API');
|
||||
|
||||
if (!window.tlsn) {
|
||||
showStatus('Error: window.tlsn not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.tlsn.sendMessage !== 'function') {
|
||||
showStatus('⚠️ Legacy sendMessage method not available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.tlsn.sendMessage({ test: 'legacy API' });
|
||||
showStatus('✅ Legacy API call sent (check x.com window opens)', 'success');
|
||||
} catch (error) {
|
||||
showStatus(`❌ Legacy API error: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Update statistics
|
||||
function updateStats() {
|
||||
document.getElementById('windowCount').textContent = windowCount;
|
||||
document.getElementById('errorCount').textContent = errorCount;
|
||||
}
|
||||
|
||||
// Show status message
|
||||
function showStatus(message, type) {
|
||||
const statusEl = document.getElementById('status');
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = type;
|
||||
|
||||
// Auto-hide after 5 seconds for success messages
|
||||
if (type === 'success') {
|
||||
setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Log to console when page loads
|
||||
console.log('[Test Page] TLSN Integration Test Page Loaded');
|
||||
console.log('[Test Page] Waiting for window.tlsn API...');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
136
packages/extension/tests/setup.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Vitest test setup file
|
||||
*
|
||||
* This file runs before all tests to set up the testing environment,
|
||||
* including mocking browser APIs for Chrome extension testing.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Create a mock chrome object with runtime.id (required for webextension-polyfill)
|
||||
const chromeMock = {
|
||||
runtime: {
|
||||
id: 'test-extension-id',
|
||||
sendMessage: vi.fn(),
|
||||
onMessage: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
hasListener: vi.fn(),
|
||||
},
|
||||
getURL: vi.fn((path: string) => `chrome-extension://test-id/${path}`),
|
||||
onInstalled: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
getContexts: vi.fn(),
|
||||
},
|
||||
windows: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
update: vi.fn(),
|
||||
onRemoved: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
sendMessage: vi.fn(),
|
||||
query: vi.fn(),
|
||||
get: vi.fn(),
|
||||
onUpdated: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
webRequest: {
|
||||
onBeforeRequest: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
onBeforeSendHeaders: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
sync: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
},
|
||||
offscreen: {
|
||||
createDocument: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Set up chrome global for webextension-polyfill
|
||||
globalThis.chrome = chromeMock as any;
|
||||
|
||||
// Mock webextension-polyfill
|
||||
vi.mock('webextension-polyfill', () => ({
|
||||
default: {
|
||||
runtime: {
|
||||
id: 'test-extension-id',
|
||||
sendMessage: vi.fn(),
|
||||
onMessage: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
getURL: vi.fn((path: string) => `chrome-extension://test-id/${path}`),
|
||||
},
|
||||
windows: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
onRemoved: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
sendMessage: vi.fn(),
|
||||
onUpdated: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
query: vi.fn(),
|
||||
},
|
||||
webRequest: {
|
||||
onBeforeRequest: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
onBeforeSendHeaders: {
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
sync: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
280
packages/extension/tests/types/window-manager.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Type safety tests for WindowManager types
|
||||
*
|
||||
* These tests verify that the type definitions are correctly structured
|
||||
* and can be used as expected throughout the codebase.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type {
|
||||
WindowRegistration,
|
||||
InterceptedRequest,
|
||||
ManagedWindow,
|
||||
IWindowManager,
|
||||
} from '../../src/types/window-manager';
|
||||
|
||||
describe('WindowManager Type Definitions', () => {
|
||||
describe('WindowRegistration', () => {
|
||||
it('should accept valid window registration config', () => {
|
||||
const config: WindowRegistration = {
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
showOverlay: true,
|
||||
};
|
||||
|
||||
expect(config.id).toBe(123);
|
||||
expect(config.tabId).toBe(456);
|
||||
expect(config.url).toBe('https://example.com');
|
||||
expect(config.showOverlay).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow showOverlay to be optional', () => {
|
||||
const config: WindowRegistration = {
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(config.showOverlay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enforce required fields', () => {
|
||||
// @ts-expect-error - missing required fields
|
||||
const invalid: WindowRegistration = {
|
||||
id: 123,
|
||||
};
|
||||
|
||||
expect(invalid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InterceptedRequest', () => {
|
||||
it('should accept valid intercepted request', () => {
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-123',
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/data',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
expect(request.method).toBe('GET');
|
||||
expect(request.url).toContain('api.example.com');
|
||||
expect(request.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should support different HTTP methods', () => {
|
||||
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
||||
|
||||
methods.forEach((method) => {
|
||||
const request: InterceptedRequest = {
|
||||
id: `req-${method}`,
|
||||
method,
|
||||
url: 'https://example.com',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
expect(request.method).toBe(method);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManagedWindow', () => {
|
||||
it('should accept valid managed window', () => {
|
||||
const window: ManagedWindow = {
|
||||
id: 123,
|
||||
uuid: '550e8400-e29b-41d4-a716-446655440000',
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
createdAt: new Date(),
|
||||
requests: [],
|
||||
overlayVisible: false,
|
||||
showOverlayWhenReady: true,
|
||||
};
|
||||
|
||||
expect(window.id).toBe(123);
|
||||
expect(window.uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(window.requests).toEqual([]);
|
||||
expect(window.overlayVisible).toBe(false);
|
||||
expect(window.showOverlayWhenReady).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow requests array to contain InterceptedRequests', () => {
|
||||
const window: ManagedWindow = {
|
||||
id: 123,
|
||||
uuid: '550e8400-e29b-41d4-a716-446655440000',
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
createdAt: new Date(),
|
||||
requests: [
|
||||
{
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
},
|
||||
],
|
||||
overlayVisible: true,
|
||||
showOverlayWhenReady: false,
|
||||
};
|
||||
|
||||
expect(window.requests).toHaveLength(1);
|
||||
expect(window.requests[0].method).toBe('GET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IWindowManager', () => {
|
||||
it('should define all required methods', () => {
|
||||
// This test verifies that the interface shape is correct
|
||||
// by creating a mock implementation
|
||||
const mockWindowManager: IWindowManager = {
|
||||
registerWindow: async (config: WindowRegistration) => ({
|
||||
id: config.id,
|
||||
uuid: 'test-uuid',
|
||||
tabId: config.tabId,
|
||||
url: config.url,
|
||||
createdAt: new Date(),
|
||||
requests: [],
|
||||
overlayVisible: false,
|
||||
showOverlayWhenReady: config.showOverlay !== false,
|
||||
}),
|
||||
closeWindow: async (windowId: number) => {},
|
||||
getWindow: (windowId: number) => undefined,
|
||||
getWindowByTabId: (tabId: number) => undefined,
|
||||
getAllWindows: () => new Map(),
|
||||
addRequest: (windowId: number, request: InterceptedRequest) => {},
|
||||
getWindowRequests: (windowId: number) => [],
|
||||
showOverlay: async (windowId: number) => {},
|
||||
hideOverlay: async (windowId: number) => {},
|
||||
isOverlayVisible: (windowId: number) => false,
|
||||
cleanupInvalidWindows: async () => {},
|
||||
};
|
||||
|
||||
expect(mockWindowManager.registerWindow).toBeDefined();
|
||||
expect(mockWindowManager.closeWindow).toBeDefined();
|
||||
expect(mockWindowManager.getWindow).toBeDefined();
|
||||
expect(mockWindowManager.getWindowByTabId).toBeDefined();
|
||||
expect(mockWindowManager.getAllWindows).toBeDefined();
|
||||
expect(mockWindowManager.addRequest).toBeDefined();
|
||||
expect(mockWindowManager.getWindowRequests).toBeDefined();
|
||||
expect(mockWindowManager.showOverlay).toBeDefined();
|
||||
expect(mockWindowManager.hideOverlay).toBeDefined();
|
||||
expect(mockWindowManager.isOverlayVisible).toBeDefined();
|
||||
expect(mockWindowManager.cleanupInvalidWindows).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have correct method signatures', async () => {
|
||||
const mockWindowManager: IWindowManager = {
|
||||
registerWindow: async (config) => ({
|
||||
id: config.id,
|
||||
uuid: 'test-uuid',
|
||||
tabId: config.tabId,
|
||||
url: config.url,
|
||||
createdAt: new Date(),
|
||||
requests: [],
|
||||
overlayVisible: false,
|
||||
showOverlayWhenReady: config.showOverlay !== false,
|
||||
}),
|
||||
closeWindow: async (windowId) => {},
|
||||
getWindow: (windowId) => undefined,
|
||||
getWindowByTabId: (tabId) => undefined,
|
||||
getAllWindows: () => new Map(),
|
||||
addRequest: (windowId, request) => {},
|
||||
getWindowRequests: (windowId) => [],
|
||||
showOverlay: async (windowId) => {},
|
||||
hideOverlay: async (windowId) => {},
|
||||
isOverlayVisible: (windowId) => false,
|
||||
cleanupInvalidWindows: async () => {},
|
||||
};
|
||||
|
||||
// Test registerWindow returns Promise<ManagedWindow>
|
||||
const result = await mockWindowManager.registerWindow({
|
||||
id: 123,
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('id');
|
||||
expect(result).toHaveProperty('uuid');
|
||||
expect(result).toHaveProperty('tabId');
|
||||
expect(result).toHaveProperty('url');
|
||||
expect(result).toHaveProperty('createdAt');
|
||||
expect(result).toHaveProperty('requests');
|
||||
expect(result).toHaveProperty('overlayVisible');
|
||||
expect(result).toHaveProperty('showOverlayWhenReady');
|
||||
|
||||
// Test getWindowRequests returns array
|
||||
const requests = mockWindowManager.getWindowRequests(123);
|
||||
expect(Array.isArray(requests)).toBe(true);
|
||||
|
||||
// Test isOverlayVisible returns boolean
|
||||
const visible = mockWindowManager.isOverlayVisible(123);
|
||||
expect(typeof visible).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Integration', () => {
|
||||
it('should allow requests to be added to windows', () => {
|
||||
const window: ManagedWindow = {
|
||||
id: 123,
|
||||
uuid: 'test-uuid',
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
createdAt: new Date(),
|
||||
requests: [],
|
||||
overlayVisible: false,
|
||||
showOverlayWhenReady: false,
|
||||
};
|
||||
|
||||
const request: InterceptedRequest = {
|
||||
id: 'req-1',
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
};
|
||||
|
||||
window.requests.push(request);
|
||||
|
||||
expect(window.requests).toHaveLength(1);
|
||||
expect(window.requests[0]).toBe(request);
|
||||
});
|
||||
|
||||
it('should support multiple requests in a window', () => {
|
||||
const window: ManagedWindow = {
|
||||
id: 123,
|
||||
uuid: 'test-uuid',
|
||||
tabId: 456,
|
||||
url: 'https://example.com',
|
||||
createdAt: new Date(),
|
||||
requests: [
|
||||
{
|
||||
id: 'req-1',
|
||||
method: 'GET',
|
||||
url: 'https://example.com/page',
|
||||
timestamp: Date.now(),
|
||||
tabId: 456,
|
||||
},
|
||||
{
|
||||
id: 'req-2',
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api',
|
||||
timestamp: Date.now() + 1000,
|
||||
tabId: 456,
|
||||
},
|
||||
],
|
||||
overlayVisible: true,
|
||||
showOverlayWhenReady: false,
|
||||
};
|
||||
|
||||
expect(window.requests).toHaveLength(2);
|
||||
expect(window.requests[0].method).toBe('GET');
|
||||
expect(window.requests[1].method).toBe('POST');
|
||||
});
|
||||
});
|
||||
});
|
||||
326
packages/extension/tests/utils/url-validator.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Tests for URL validation utilities
|
||||
*
|
||||
* Ensures robust URL validation for security and reliability.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateUrl,
|
||||
sanitizeUrl,
|
||||
isHttpUrl,
|
||||
getUrlErrorMessage,
|
||||
} from '../../src/utils/url-validator';
|
||||
|
||||
describe('URL Validator', () => {
|
||||
describe('validateUrl', () => {
|
||||
describe('Valid URLs', () => {
|
||||
it('should accept valid HTTP URL', () => {
|
||||
const result = validateUrl('http://example.com');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.url).toBeDefined();
|
||||
expect(result.url?.protocol).toBe('http:');
|
||||
});
|
||||
|
||||
it('should accept valid HTTPS URL', () => {
|
||||
const result = validateUrl('https://example.com');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.protocol).toBe('https:');
|
||||
});
|
||||
|
||||
it('should accept URL with path', () => {
|
||||
const result = validateUrl('https://example.com/path/to/page');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.pathname).toBe('/path/to/page');
|
||||
});
|
||||
|
||||
it('should accept URL with query parameters', () => {
|
||||
const result = validateUrl('https://example.com/search?q=test&lang=en');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.search).toBe('?q=test&lang=en');
|
||||
});
|
||||
|
||||
it('should accept URL with fragment', () => {
|
||||
const result = validateUrl('https://example.com/page#section');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.hash).toBe('#section');
|
||||
});
|
||||
|
||||
it('should accept URL with port', () => {
|
||||
const result = validateUrl('https://example.com:8080/path');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.port).toBe('8080');
|
||||
});
|
||||
|
||||
it('should accept URL with subdomain', () => {
|
||||
const result = validateUrl('https://api.example.com');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.hostname).toBe('api.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid URLs - Empty/Null', () => {
|
||||
it('should reject empty string', () => {
|
||||
const result = validateUrl('');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('non-empty string');
|
||||
});
|
||||
|
||||
it('should reject whitespace only', () => {
|
||||
const result = validateUrl(' ');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('whitespace');
|
||||
});
|
||||
|
||||
it('should reject null', () => {
|
||||
const result = validateUrl(null);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('non-empty string');
|
||||
});
|
||||
|
||||
it('should reject undefined', () => {
|
||||
const result = validateUrl(undefined);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('non-empty string');
|
||||
});
|
||||
|
||||
it('should reject number', () => {
|
||||
const result = validateUrl(123);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('non-empty string');
|
||||
});
|
||||
|
||||
it('should reject object', () => {
|
||||
const result = validateUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid URLs - Malformed', () => {
|
||||
it('should reject invalid URL format', () => {
|
||||
const result = validateUrl('not-a-url');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid URL format');
|
||||
});
|
||||
|
||||
it('should reject URL without protocol', () => {
|
||||
const result = validateUrl('example.com');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid URL format');
|
||||
});
|
||||
|
||||
it('should reject URL without hostname', () => {
|
||||
const result = validateUrl('https://');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid URL format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid URLs - Dangerous Protocols', () => {
|
||||
it('should reject javascript: protocol', () => {
|
||||
const result = validateUrl('javascript:alert(1)');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Dangerous protocol');
|
||||
expect(result.error).toContain('javascript:');
|
||||
});
|
||||
|
||||
it('should reject data: protocol', () => {
|
||||
const result = validateUrl('data:text/html,<h1>Test</h1>');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Dangerous protocol');
|
||||
expect(result.error).toContain('data:');
|
||||
});
|
||||
|
||||
it('should reject file: protocol', () => {
|
||||
const result = validateUrl('file:///etc/passwd');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Dangerous protocol');
|
||||
expect(result.error).toContain('file:');
|
||||
});
|
||||
|
||||
it('should reject blob: protocol', () => {
|
||||
const result = validateUrl('blob:https://example.com/uuid');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Dangerous protocol');
|
||||
expect(result.error).toContain('blob:');
|
||||
});
|
||||
|
||||
it('should reject about: protocol', () => {
|
||||
const result = validateUrl('about:blank');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Dangerous protocol');
|
||||
expect(result.error).toContain('about:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid URLs - Invalid Protocols', () => {
|
||||
it('should reject FTP protocol', () => {
|
||||
const result = validateUrl('ftp://example.com');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid protocol');
|
||||
expect(result.error).toContain('ftp:');
|
||||
});
|
||||
|
||||
it('should reject ws: protocol', () => {
|
||||
const result = validateUrl('ws://example.com');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid protocol');
|
||||
});
|
||||
|
||||
it('should reject custom protocol', () => {
|
||||
const result = validateUrl('custom://example.com');
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Invalid protocol');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeUrl', () => {
|
||||
it('should sanitize valid URL', () => {
|
||||
const sanitized = sanitizeUrl(' https://example.com ');
|
||||
|
||||
expect(sanitized).toBe('https://example.com/');
|
||||
});
|
||||
|
||||
it('should preserve query parameters', () => {
|
||||
const sanitized = sanitizeUrl('https://example.com/search?q=test');
|
||||
|
||||
expect(sanitized).toContain('?q=test');
|
||||
});
|
||||
|
||||
it('should preserve fragments', () => {
|
||||
const sanitized = sanitizeUrl('https://example.com#section');
|
||||
|
||||
expect(sanitized).toContain('#section');
|
||||
});
|
||||
|
||||
it('should return null for invalid URL', () => {
|
||||
const sanitized = sanitizeUrl('not-a-url');
|
||||
|
||||
expect(sanitized).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for dangerous protocol', () => {
|
||||
const sanitized = sanitizeUrl('javascript:alert(1)');
|
||||
|
||||
expect(sanitized).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHttpUrl', () => {
|
||||
it('should return true for HTTP URL', () => {
|
||||
expect(isHttpUrl('http://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for HTTPS URL', () => {
|
||||
expect(isHttpUrl('https://example.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for FTP URL', () => {
|
||||
expect(isHttpUrl('ftp://example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for javascript: URL', () => {
|
||||
expect(isHttpUrl('javascript:alert(1)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid URL', () => {
|
||||
expect(isHttpUrl('not-a-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isHttpUrl('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrlErrorMessage', () => {
|
||||
it('should return valid message for valid URL', () => {
|
||||
const message = getUrlErrorMessage('https://example.com');
|
||||
|
||||
expect(message).toBe('URL is valid');
|
||||
});
|
||||
|
||||
it('should return error message for invalid URL', () => {
|
||||
const message = getUrlErrorMessage('javascript:alert(1)');
|
||||
|
||||
expect(message).toContain('Dangerous protocol');
|
||||
});
|
||||
|
||||
it('should return error message for malformed URL', () => {
|
||||
const message = getUrlErrorMessage('not-a-url');
|
||||
|
||||
expect(message).toContain('Invalid URL format');
|
||||
});
|
||||
|
||||
it('should return error message for empty URL', () => {
|
||||
const message = getUrlErrorMessage('');
|
||||
|
||||
expect(message).toContain('non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle URL with Unicode characters', () => {
|
||||
const result = validateUrl('https://例え.com');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle URL with encoded characters', () => {
|
||||
const result = validateUrl('https://example.com/path%20with%20spaces');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle localhost', () => {
|
||||
const result = validateUrl('http://localhost:3000');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle IP address', () => {
|
||||
const result = validateUrl('http://192.168.1.1');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle IPv6 address', () => {
|
||||
const result = validateUrl('http://[::1]:8080');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should trim whitespace from URL', () => {
|
||||
const result = validateUrl(' https://example.com ');
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.url?.href).toBe('https://example.com/');
|
||||
});
|
||||
});
|
||||
});
|
||||
91
packages/extension/tests/utils/uuid.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Tests for UUID generation functionality
|
||||
*
|
||||
* Verifies that the uuid package is correctly installed and
|
||||
* generates valid UUIDs for WindowManager use.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
v4 as uuidv4,
|
||||
validate as uuidValidate,
|
||||
version as uuidVersion,
|
||||
} from 'uuid';
|
||||
|
||||
describe('UUID Generation', () => {
|
||||
it('should generate valid UUID v4', () => {
|
||||
const uuid = uuidv4();
|
||||
|
||||
expect(uuid).toBeDefined();
|
||||
expect(typeof uuid).toBe('string');
|
||||
expect(uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate unique UUIDs', () => {
|
||||
const uuid1 = uuidv4();
|
||||
const uuid2 = uuidv4();
|
||||
const uuid3 = uuidv4();
|
||||
|
||||
expect(uuid1).not.toBe(uuid2);
|
||||
expect(uuid2).not.toBe(uuid3);
|
||||
expect(uuid1).not.toBe(uuid3);
|
||||
});
|
||||
|
||||
it('should validate correct UUIDs', () => {
|
||||
const uuid = uuidv4();
|
||||
|
||||
expect(uuidValidate(uuid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid UUIDs', () => {
|
||||
expect(uuidValidate('not-a-uuid')).toBe(false);
|
||||
expect(uuidValidate('12345')).toBe(false);
|
||||
expect(uuidValidate('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify UUID version', () => {
|
||||
const uuid = uuidv4();
|
||||
|
||||
expect(uuidVersion(uuid)).toBe(4);
|
||||
});
|
||||
|
||||
it('should generate UUIDs suitable for WindowManager', () => {
|
||||
// Simulate what WindowManager will do
|
||||
const windowUUIDs = new Set<string>();
|
||||
|
||||
// Generate 100 UUIDs
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const uuid = uuidv4();
|
||||
|
||||
// Verify it's valid
|
||||
expect(uuidValidate(uuid)).toBe(true);
|
||||
|
||||
// Verify it's unique
|
||||
expect(windowUUIDs.has(uuid)).toBe(false);
|
||||
|
||||
windowUUIDs.add(uuid);
|
||||
}
|
||||
|
||||
expect(windowUUIDs.size).toBe(100);
|
||||
});
|
||||
|
||||
it('should work with ManagedWindow type structure', () => {
|
||||
interface ManagedWindowSimple {
|
||||
id: number;
|
||||
uuid: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const window: ManagedWindowSimple = {
|
||||
id: 123,
|
||||
uuid: uuidv4(),
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(window.uuid).toBeDefined();
|
||||
expect(uuidValidate(window.uuid)).toBe(true);
|
||||
expect(window.uuid.length).toBe(36); // UUID v4 format with dashes
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"noEmit": false,
|
||||
"jsx": "react"
|
||||
},
|
||||
"types": ["chrome"],
|
||||
"include": ["src"],
|
||||
"exclude": ["build", "node_modules"]
|
||||
}
|
||||
49
packages/extension/utils/build.js
Executable file
@@ -0,0 +1,49 @@
|
||||
// Do this as the first thing so that any code reading it knows the right env.
|
||||
process.env.BABEL_ENV = 'production';
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.ASSET_PATH = '/';
|
||||
|
||||
var webpack = require('webpack'),
|
||||
path = require('path'),
|
||||
fs = require('fs'),
|
||||
config = require('../webpack.config'),
|
||||
ZipPlugin = require('zip-webpack-plugin');
|
||||
|
||||
delete config.chromeExtensionBoilerplate;
|
||||
|
||||
config.mode = 'production';
|
||||
|
||||
var packageInfo = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
||||
|
||||
config.plugins = (config.plugins || []).concat(
|
||||
new ZipPlugin({
|
||||
filename: `${packageInfo.name}-${packageInfo.version}.zip`,
|
||||
path: path.join(__dirname, '../', 'zip'),
|
||||
}),
|
||||
);
|
||||
|
||||
webpack(config, function (err, stats) {
|
||||
if (err) {
|
||||
console.error('Webpack error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (stats.hasErrors()) {
|
||||
console.error('Build failed with errors:');
|
||||
const info = stats.toJson();
|
||||
console.error(info.errors.map((e) => e.message).join('\n\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (stats.hasWarnings()) {
|
||||
console.warn('Build completed with warnings:');
|
||||
const info = stats.toJson();
|
||||
console.warn(info.warnings.map((w) => w.message).join('\n\n'));
|
||||
}
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
console.log(`Output: ${path.join(__dirname, '../', 'build')}`);
|
||||
console.log(
|
||||
`Zip: ${path.join(__dirname, '../', 'zip', `${packageInfo.name}-${packageInfo.version}.zip`)}`,
|
||||
);
|
||||
});
|
||||
40
packages/extension/vitest.config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// Environment
|
||||
environment: 'happy-dom',
|
||||
|
||||
// Setup files
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
|
||||
// Globals (optional - enables describe, it, expect without imports)
|
||||
globals: true,
|
||||
|
||||
// Coverage configuration
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'tests/',
|
||||
'build/',
|
||||
'dist/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/mockData/',
|
||||
],
|
||||
},
|
||||
|
||||
// Test patterns
|
||||
include: ['tests/**/*.{test,spec}.{js,ts,tsx}'],
|
||||
exclude: ['node_modules', 'build', 'dist'],
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -7,7 +7,6 @@ var webpack = require("webpack"),
|
||||
TerserPlugin = require("terser-webpack-plugin");
|
||||
var { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||
var ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
|
||||
var ExtReloader = require('webpack-ext-reloader');
|
||||
|
||||
const ASSET_PATH = process.env.ASSET_PATH || "/";
|
||||
|
||||
@@ -44,20 +43,15 @@ var options = {
|
||||
/Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0/,
|
||||
/Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0./,
|
||||
/repetitive deprecation warnings omitted/,
|
||||
/Dart Sass 2.0.0/,
|
||||
],
|
||||
|
||||
entry: {
|
||||
options: path.join(__dirname, "src", "entries", "Options", "index.tsx"),
|
||||
popup: path.join(__dirname, "src", "entries", "Popup", "index.tsx"),
|
||||
background: path.join(__dirname, "src", "entries", "Background", "index.ts"),
|
||||
contentScript: path.join(__dirname, "src", "entries", "Content", "index.ts"),
|
||||
content: path.join(__dirname, "src", "entries", "Content", "content.ts"),
|
||||
offscreen: path.join(__dirname, "src", "entries", "Offscreen", "index.tsx"),
|
||||
sidePanel: path.join(__dirname, "src", "entries", "SidePanel", "index.tsx"),
|
||||
},
|
||||
// chromeExtensionBoilerplate: {
|
||||
// notHotReload: ["background", "contentScript", "devtools"],
|
||||
// },
|
||||
output: {
|
||||
filename: "[name].bundle.js",
|
||||
path: path.resolve(__dirname, "build"),
|
||||
@@ -85,9 +79,6 @@ var options = {
|
||||
loader: "sass-loader",
|
||||
options: {
|
||||
sourceMap: true,
|
||||
sassOptions: {
|
||||
silenceDeprecations: ["legacy-js-api"],
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -96,10 +87,6 @@ var options = {
|
||||
test: new RegExp(".(" + fileExtensions.join("|") + ")$"),
|
||||
type: "asset/resource",
|
||||
exclude: /node_modules/,
|
||||
// loader: 'file-loader',
|
||||
// options: {
|
||||
// name: '[name].[ext]',
|
||||
// },
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
@@ -149,9 +136,6 @@ var options = {
|
||||
new webpack.ProgressPlugin(),
|
||||
// expose and write the allowed env vars on the compiled bundle
|
||||
new webpack.EnvironmentPlugin(["NODE_ENV"]),
|
||||
// new ExtReloader({
|
||||
// manifest: path.resolve(__dirname, "src/manifest.json")
|
||||
// }),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
@@ -198,31 +182,6 @@ var options = {
|
||||
},
|
||||
],
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: "node_modules/tlsn-js/build",
|
||||
to: path.join(__dirname, "build"),
|
||||
force: true,
|
||||
},
|
||||
{
|
||||
from: "src/assets/plugins/discord_dm.wasm",
|
||||
to: path.join(__dirname, "build"),
|
||||
force: true,
|
||||
},
|
||||
{
|
||||
from: "src/assets/plugins/twitter_profile.wasm",
|
||||
to: path.join(__dirname, "build"),
|
||||
force: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.join(__dirname, "src", "entries", "Options", "index.html"),
|
||||
filename: "options.html",
|
||||
chunks: ["options"],
|
||||
cache: false,
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.join(__dirname, "src", "entries", "Popup", "index.html"),
|
||||
filename: "popup.html",
|
||||
@@ -235,29 +194,10 @@ var options = {
|
||||
chunks: ["offscreen"],
|
||||
cache: false,
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.join(__dirname, "src", "entries", "SidePanel", "index.html"),
|
||||
filename: "sidePanel.html",
|
||||
chunks: ["sidePanel"],
|
||||
cache: false,
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
].filter(Boolean),
|
||||
infrastructureLogging: {
|
||||
level: "info",
|
||||
},
|
||||
// Required by wasm-bindgen-rayon, in order to use SharedArrayBuffer on the Web
|
||||
// Ref:
|
||||
// - https://github.com/GoogleChromeLabs/wasm-bindgen-rayon#setting-up
|
||||
// - https://web.dev/i18n/en/coop-coep/
|
||||
devServer: {
|
||||
headers: {
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
@@ -273,4 +213,4 @@ if (env.NODE_ENV === "development") {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = options;
|
||||
module.exports = options;
|
||||
65
packages/plugin-sdk/README.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# @tlsn/plugin-sdk
|
||||
|
||||
SDK for developing and running TLSN WebAssembly plugins using the Component Model.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides:
|
||||
|
||||
- **Host Environment**: Runtime for executing WASM Component Model plugins
|
||||
- **Development Tools**: Utilities for building and testing plugins
|
||||
- **Plugin Demos**: Example plugins demonstrating SDK capabilities
|
||||
- **Type Definitions**: TypeScript types for plugin development
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
plugin-sdk/
|
||||
├── src/ # SDK source code
|
||||
│ ├── host/ # Plugin host runtime
|
||||
│ ├── builder/ # Build utilities
|
||||
│ └── types/ # Type definitions
|
||||
├── examples/ # Example plugins and demos
|
||||
│ ├── hello-world/ # Basic plugin example
|
||||
│ └── http-logger/ # HTTP request logging plugin
|
||||
└── dist/ # Built SDK (generated)
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install @tlsn/plugin-sdk
|
||||
```
|
||||
|
||||
### Creating a Plugin Host
|
||||
|
||||
```typescript
|
||||
import { PluginHost } from '@tlsn/plugin-sdk';
|
||||
|
||||
const host = new PluginHost({
|
||||
console: {
|
||||
log: (msg) => console.log('[Plugin]', msg)
|
||||
}
|
||||
});
|
||||
|
||||
const plugin = await host.loadPlugin({
|
||||
id: 'my-plugin',
|
||||
url: 'path/to/plugin.wasm'
|
||||
});
|
||||
|
||||
await plugin.exports.run();
|
||||
```
|
||||
|
||||
### Developing a Plugin
|
||||
|
||||
See `examples/` directory for complete plugin examples.
|
||||
|
||||
## Development
|
||||
|
||||
_Implementation in progress_
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
40
packages/plugin-sdk/demo/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# WebAssembly Component Model Demo
|
||||
|
||||
A minimal example demonstrating the WebAssembly Component Model workflow:
|
||||
|
||||
## Files
|
||||
|
||||
- **hello.wit** - WebAssembly Interface Types definition
|
||||
- **hello.js** - JavaScript implementation of the component
|
||||
- **index.html** - Browser demo page
|
||||
|
||||
## Build Process
|
||||
|
||||
1. **Componentize**: `hello.js` → `hello.component.wasm`
|
||||
- Uses `jco componentize` to create a WebAssembly Component
|
||||
|
||||
2. **Transpile**: `hello.component.wasm` → `browser/hello.component.js`
|
||||
- Uses `jco transpile` to generate browser-compatible JavaScript
|
||||
|
||||
## Running the Demo
|
||||
|
||||
```bash
|
||||
# From plugin-sdk directory:
|
||||
npm run demo:browser
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Build the component
|
||||
2. Transpile it for browser
|
||||
3. Start a local server at http://localhost:8081/demo/
|
||||
|
||||
## Component Functions
|
||||
|
||||
- `greet(name: string) → string` - Returns a greeting message
|
||||
- `add(a: u32, b: u32) → u32` - Adds two numbers
|
||||
|
||||
## Key Insights
|
||||
|
||||
- WebAssembly Components use the Component Model (version `0d 00 01 00`)
|
||||
- Browsers only support core WebAssembly modules (version `01 00 00 00`)
|
||||
- The transpilation step bridges this gap by creating JavaScript wrappers
|
||||
120
packages/plugin-sdk/demo/browser-loader.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Browser-friendly loader for the WebAssembly Component
|
||||
*
|
||||
* This provides all the WASI stubs needed for the component to run in the browser
|
||||
*/
|
||||
|
||||
// Create minimal WASI stub implementations
|
||||
// Note: The keys must match what the transpiled component expects
|
||||
const wasiStubs = {
|
||||
// Without version suffix (what the transpiled code looks for)
|
||||
'wasi:cli/stderr': {
|
||||
getStderr: () => ({
|
||||
write: (data) => { console.error(new TextDecoder().decode(data)); return data.length; }
|
||||
})
|
||||
},
|
||||
'wasi:cli/stdin': {
|
||||
getStdin: () => ({ read: () => new Uint8Array(0) })
|
||||
},
|
||||
'wasi:cli/stdout': {
|
||||
getStdout: () => ({
|
||||
write: (data) => { console.log(new TextDecoder().decode(data)); return data.length; }
|
||||
})
|
||||
},
|
||||
'wasi:cli/terminal-input': {
|
||||
TerminalInput: class {}
|
||||
},
|
||||
'wasi:cli/terminal-output': {
|
||||
TerminalOutput: class {}
|
||||
},
|
||||
'wasi:cli/terminal-stderr': {
|
||||
getTerminalStderr: () => null
|
||||
},
|
||||
'wasi:cli/terminal-stdin': {
|
||||
getTerminalStdin: () => null
|
||||
},
|
||||
'wasi:cli/terminal-stdout': {
|
||||
getTerminalStdout: () => null
|
||||
},
|
||||
'wasi:clocks/monotonic-clock': {
|
||||
now: () => BigInt(Date.now() * 1000000),
|
||||
resolution: () => BigInt(1000000),
|
||||
subscribeDuration: () => ({ ready: () => true }),
|
||||
subscribeInstant: () => ({ ready: () => true })
|
||||
},
|
||||
'wasi:clocks/wall-clock': {
|
||||
now: () => BigInt(Date.now() * 1000000),
|
||||
resolution: () => BigInt(1000000)
|
||||
},
|
||||
'wasi:filesystem/preopens': {
|
||||
getDirectories: () => []
|
||||
},
|
||||
'wasi:filesystem/types': {
|
||||
Descriptor: class {},
|
||||
filesystemErrorCode: () => 'unsupported'
|
||||
},
|
||||
'wasi:http/outgoing-handler': {
|
||||
handle: () => { throw new Error('HTTP not supported'); }
|
||||
},
|
||||
'wasi:http/types': {
|
||||
Fields: class {},
|
||||
FutureIncomingResponse: class {},
|
||||
IncomingBody: class {},
|
||||
IncomingRequest: class {},
|
||||
IncomingResponse: class {},
|
||||
OutgoingBody: class {},
|
||||
OutgoingRequest: class {},
|
||||
OutgoingResponse: class {},
|
||||
RequestOptions: class {},
|
||||
ResponseOutparam: class {}
|
||||
},
|
||||
'wasi:io/error': {
|
||||
Error: class Error { constructor(msg) { this.message = msg; } }
|
||||
},
|
||||
'wasi:io/poll': {
|
||||
Pollable: class {},
|
||||
poll: () => []
|
||||
},
|
||||
'wasi:io/streams': {
|
||||
InputStream: class {
|
||||
read() { return new Uint8Array(0); }
|
||||
subscribe() { return { ready: () => true }; }
|
||||
},
|
||||
OutputStream: class {
|
||||
write(data) { return data.length; }
|
||||
subscribe() { return { ready: () => true }; }
|
||||
}
|
||||
},
|
||||
'wasi:random/random': {
|
||||
getRandomBytes: (len) => {
|
||||
const bytes = new Uint8Array(len);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
},
|
||||
getRandomU64: () => {
|
||||
const bytes = new Uint8Array(8);
|
||||
crypto.getRandomValues(bytes);
|
||||
return new DataView(bytes.buffer).getBigUint64(0, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load and instantiate the component
|
||||
async function loadComponent() {
|
||||
const { instantiate } = await import('/browser/hello.component.js');
|
||||
|
||||
// Function to load core WASM modules
|
||||
async function getCoreModule(path) {
|
||||
const response = await fetch(`/browser/${path}`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
return WebAssembly.compile(bytes);
|
||||
}
|
||||
|
||||
// Instantiate with WASI stubs
|
||||
const component = await instantiate(getCoreModule, wasiStubs);
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
// Export for use in HTML
|
||||
window.loadWasmComponent = loadComponent;
|
||||
10
packages/plugin-sdk/demo/hello.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Simple WebAssembly Component implementation
|
||||
// This will be compiled to a WASM component using componentize-js
|
||||
|
||||
export function greet(name) {
|
||||
return `Hello, ${name}! This is a WebAssembly Component.`;
|
||||
}
|
||||
|
||||
export function add(a, b) {
|
||||
return a + b;
|
||||
}
|
||||
6
packages/plugin-sdk/demo/hello.wit
Normal file
@@ -0,0 +1,6 @@
|
||||
package demo:hello@0.1.0;
|
||||
|
||||
world hello-world {
|
||||
export greet: func(name: string) -> string;
|
||||
export add: func(a: u32, b: u32) -> u32;
|
||||
}
|
||||
257
packages/plugin-sdk/demo/index.html
Normal file
@@ -0,0 +1,257 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebAssembly Component Model - Simple Demo</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 10px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 10px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
margin-right: 10px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.output {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-height: 60px;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #e8f5e9;
|
||||
border-left: 4px solid #4caf50;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255,255,255,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 WebAssembly Component Model Demo</h1>
|
||||
<p class="subtitle">A minimal example of running a WASM Component in the browser</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>📦 Component Info</h2>
|
||||
<p>This demo loads a WebAssembly Component that was:</p>
|
||||
<ol>
|
||||
<li>Written in JavaScript (<code>hello.js</code>)</li>
|
||||
<li>Componentized using <code>jco componentize</code></li>
|
||||
<li>Transpiled for browser using <code>jco transpile</code></li>
|
||||
</ol>
|
||||
<div id="status" class="status">Component loaded and ready!</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🎯 Test Functions</h2>
|
||||
|
||||
<h3>1. Greet Function</h3>
|
||||
<input type="text" id="nameInput" placeholder="Enter your name" value="World">
|
||||
<button onclick="callGreet()">Call greet()</button>
|
||||
|
||||
<h3>2. Add Function</h3>
|
||||
<input type="number" id="num1" placeholder="Number 1" value="5" style="width: 100px">
|
||||
+
|
||||
<input type="number" id="num2" placeholder="Number 2" value="3" style="width: 100px">
|
||||
<button onclick="callAdd()">Call add()</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📤 Output</h2>
|
||||
<div id="output" class="output">Click a button to execute a component function...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/demo/browser-loader.js"></script>
|
||||
<script type="module">
|
||||
let component = null;
|
||||
const output = document.getElementById('output');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const className = type === 'error' ? 'error' : type === 'success' ? 'success' : 'info';
|
||||
output.innerHTML += `<span class="${className}">[${timestamp}] ${message}</span>\n`;
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
// Load the component using the browser loader
|
||||
async function loadComponent() {
|
||||
try {
|
||||
log('Loading WebAssembly Component...', 'info');
|
||||
|
||||
// Use the browser loader to load the component with WASI stubs
|
||||
component = await window.loadWasmComponent();
|
||||
|
||||
log('✅ Component loaded successfully!', 'success');
|
||||
log('Available exports: ' + Object.keys(component).join(', '), 'info');
|
||||
|
||||
status.classList.add('show');
|
||||
return true;
|
||||
} catch (error) {
|
||||
log('❌ Failed to load component: ' + error.message, 'error');
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Call the greet function
|
||||
window.callGreet = async function() {
|
||||
if (!component) {
|
||||
log('Component not loaded yet!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('nameInput').value || 'World';
|
||||
|
||||
try {
|
||||
log(`Calling greet("${name}")...`, 'info');
|
||||
const result = component.greet(name);
|
||||
log(`Result: "${result}"`, 'success');
|
||||
} catch (error) {
|
||||
log(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Call the add function
|
||||
window.callAdd = async function() {
|
||||
if (!component) {
|
||||
log('Component not loaded yet!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const num1 = parseInt(document.getElementById('num1').value) || 0;
|
||||
const num2 = parseInt(document.getElementById('num2').value) || 0;
|
||||
|
||||
try {
|
||||
log(`Calling add(${num1}, ${num2})...`, 'info');
|
||||
const result = component.add(num1, num2);
|
||||
log(`Result: ${result}`, 'success');
|
||||
} catch (error) {
|
||||
log(`Error: ${error.message}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on page load
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
log('🚀 WebAssembly Component Model Demo', 'info');
|
||||
log('=====================================', 'info');
|
||||
|
||||
// Try to load the component
|
||||
const loaded = await loadComponent();
|
||||
|
||||
if (!loaded) {
|
||||
log('\n⚠️ Make sure to run "npm run demo:build" first!', 'error');
|
||||
log('This will compile and transpile the component.', 'info');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
44
packages/plugin-sdk/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@tlsn/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"description": "SDK for developing and running TLSN WebAssembly plugins",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "echo 'Build not yet implemented'",
|
||||
"test": "echo 'Tests not yet implemented'",
|
||||
"demo:build": "npm run demo:build:component && npm run demo:transpile",
|
||||
"demo:build:component": "npx jco componentize demo/hello.js --wit demo/hello.wit -o hello.component.wasm",
|
||||
"demo:transpile": "npx jco transpile hello.component.wasm -o browser --instantiation",
|
||||
"demo:bundle": "node demo/bundle.js",
|
||||
"demo:browser": "npm run demo:build && npx http-server . -p 8083 -c-1 -o /demo/",
|
||||
"demo:clean": "rm -rf hello.component.wasm browser"
|
||||
},
|
||||
"keywords": [
|
||||
"tlsn",
|
||||
"wasm",
|
||||
"plugin",
|
||||
"component-model",
|
||||
"sdk"
|
||||
],
|
||||
"author": "TLSN Team",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tlsnotary/tlsn-extension.git",
|
||||
"directory": "packages/plugin-sdk"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"examples",
|
||||
"README.md"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@bytecodealliance/jco": "^1.7.2",
|
||||
"@bytecodealliance/componentize-js": "^0.11.3",
|
||||
"http-server": "^14.1.1",
|
||||
"typescript": "^4.9.4"
|
||||
}
|
||||
}
|
||||
8
packages/plugin-sdk/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @tlsn/plugin-sdk
|
||||
*
|
||||
* SDK for developing and running TLSN WebAssembly plugins
|
||||
*/
|
||||
|
||||
// Placeholder - implementation pending
|
||||
export {};
|
||||
@@ -1,70 +0,0 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
useActiveTabUrl,
|
||||
setConnection,
|
||||
useIsConnected,
|
||||
} from '../../reducers/requests';
|
||||
import Modal, { ModalHeader, ModalContent } from '../../components/Modal/Modal';
|
||||
import { deleteConnection, getConnection } from '../../entries/Background/db';
|
||||
|
||||
const ConnectionDetailsModal = (props: {
|
||||
showConnectionDetails: boolean;
|
||||
setShowConnectionDetails: (show: boolean) => void;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const activeTabOrigin = useActiveTabUrl();
|
||||
const connected = useIsConnected();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (activeTabOrigin) {
|
||||
const isConnected: boolean | null = await getConnection(
|
||||
activeTabOrigin.origin,
|
||||
);
|
||||
dispatch(setConnection(!!isConnected));
|
||||
}
|
||||
})();
|
||||
}, [activeTabOrigin, dispatch]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
if (activeTabOrigin?.origin) {
|
||||
await deleteConnection(activeTabOrigin.origin);
|
||||
props.setShowConnectionDetails(false);
|
||||
dispatch(setConnection(false));
|
||||
}
|
||||
}, [activeTabOrigin?.origin, dispatch, props]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => props.setShowConnectionDetails(false)}
|
||||
className="flex flex-col gap-2 items-center text-base cursor-default justify-center mx-4 min-h-24"
|
||||
>
|
||||
<ModalHeader
|
||||
className="w-full rounded-t-lg pb-0 border-b-0"
|
||||
onClose={() => props.setShowConnectionDetails(false)}
|
||||
>
|
||||
<span className="text-lg font-semibold">
|
||||
{activeTabOrigin?.hostname || 'Connections'}
|
||||
</span>
|
||||
</ModalHeader>
|
||||
<ModalContent className="w-full gap-2 flex-grow flex flex-col items-center justify-between px-4 pt-0 pb-4">
|
||||
<div className="flex flex-row gap-2 items-start w-full text-xs font-semibold text-slate-800">
|
||||
{connected
|
||||
? 'TLSN Extension is connected to this site.'
|
||||
: 'TLSN Extension is not connected to this site. To connect to this site, find and click the connect button.'}
|
||||
</div>
|
||||
{connected && (
|
||||
<button
|
||||
className="button disabled:opacity-50 self-end"
|
||||
onClick={handleDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionDetailsModal;
|
||||
@@ -1,26 +0,0 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import Modal, { ModalContent } from '../Modal/Modal';
|
||||
|
||||
export function ErrorModal(props: {
|
||||
onClose: () => void;
|
||||
message: string;
|
||||
}): ReactElement {
|
||||
const { onClose, message } = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="flex flex-col gap-4 items-center text-base cursor-default justify-center !w-auto mx-4 my-[50%] min-h-24 p-4 border border-red-500 !bg-red-100"
|
||||
onClose={onClose}
|
||||
>
|
||||
<ModalContent className="flex justify-center items-center text-red-500">
|
||||
{message || 'Something went wrong :('}
|
||||
</ModalContent>
|
||||
<button
|
||||
className="m-0 w-24 bg-red-200 text-red-400 hover:bg-red-200 hover:text-red-500"
|
||||
onClick={onClose}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.icon {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import './icon.scss';
|
||||
|
||||
type Props = {
|
||||
url?: string;
|
||||
fa?: string;
|
||||
className?: string;
|
||||
size?: number;
|
||||
onClick?: MouseEventHandler;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export default function Icon(props: Props): ReactElement {
|
||||
const { url, size = 1, className = '', fa, onClick, children } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-contain bg-center bg-no-repeat icon',
|
||||
{
|
||||
'cursor-pointer': onClick,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: url ? `url(${url})` : undefined,
|
||||
width: !fa ? `${size}rem` : undefined,
|
||||
height: !fa ? `${size}rem` : undefined,
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!url && !!fa && <i className={fa} style={{ fontSize: `${size}rem` }} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import Icon from '../Icon';
|
||||
import browser from 'webextension-polyfill';
|
||||
import classNames from 'classnames';
|
||||
import { useNavigate } from 'react-router';
|
||||
import PluginUploadInfo from '../PluginInfo';
|
||||
|
||||
export function MenuIcon(): ReactElement {
|
||||
const [opened, setOpen] = useState(false);
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setOpen(!opened);
|
||||
}, [opened]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{opened && (
|
||||
<>
|
||||
<div
|
||||
className="fixed top-0 left-0 w-screen h-screen z-10"
|
||||
onClick={toggleMenu}
|
||||
/>
|
||||
<Menu opened={opened} setOpen={setOpen} />
|
||||
</>
|
||||
)}
|
||||
<Icon
|
||||
fa="fa-solid fa-bars"
|
||||
className="text-slate-500 hover:text-slate-700 active:text-slate-900 cursor-pointer z-20"
|
||||
onClick={toggleMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Menu(props: {
|
||||
opened: boolean;
|
||||
setOpen: (opened: boolean) => void;
|
||||
}): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const openExtensionInPage = () => {
|
||||
props.setOpen(false);
|
||||
browser.tabs.create({
|
||||
url: `chrome-extension://${chrome.runtime.id}/popup.html`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute top-[100%] right-0 rounded-md z-20">
|
||||
<div className="flex flex-col bg-slate-200 w-40 shadow rounded-md py">
|
||||
<MenuRow
|
||||
fa="fa-solid fa-plus"
|
||||
className="relative"
|
||||
onClick={() => {
|
||||
props.setOpen(false);
|
||||
}}
|
||||
>
|
||||
<PluginUploadInfo onPluginInstalled={() => props.setOpen(false)} />
|
||||
<span>Install Plugin</span>
|
||||
</MenuRow>
|
||||
<MenuRow
|
||||
fa="fa-solid fa-toolbox"
|
||||
className="border-b border-slate-300"
|
||||
onClick={() => {
|
||||
props.setOpen(false);
|
||||
navigate('/plugins');
|
||||
}}
|
||||
>
|
||||
Plugins
|
||||
</MenuRow>
|
||||
<MenuRow
|
||||
className="lg:hidden"
|
||||
fa="fa-solid fa-up-right-and-down-left-from-center"
|
||||
onClick={openExtensionInPage}
|
||||
>
|
||||
Expand
|
||||
</MenuRow>
|
||||
<MenuRow
|
||||
fa="fa-solid fa-gear"
|
||||
onClick={() => {
|
||||
props.setOpen(false);
|
||||
navigate('/options');
|
||||
}}
|
||||
>
|
||||
Options
|
||||
</MenuRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuRow(props: {
|
||||
fa: string;
|
||||
children?: ReactNode;
|
||||
onClick?: MouseEventHandler;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-row items-center py-3 px-4 gap-2 hover:bg-slate-300 cursor-pointer text-slate-800 hover:text-slate-900 font-semibold',
|
||||
props.className,
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Icon size={0.875} fa={props.fa} />
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './modal.scss';
|
||||
import Icon from '../Icon';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
onClose: MouseEventHandler;
|
||||
children: ReactNode | ReactNode[];
|
||||
};
|
||||
|
||||
export default function Modal(props: Props): ReactElement {
|
||||
const { className, onClose, children } = props;
|
||||
|
||||
const modalRoot = document.querySelector('#modal-root');
|
||||
|
||||
if (!modalRoot) return <></>;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={classNames('bg-black bg-opacity-80', 'modal__overlay')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose && onClose(e);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(`modal__wrapper bg-white`, className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function ModalHeader(props: HeaderProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'border-b modal__header border-gray-100',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="modal__header__title">{props.children}</div>
|
||||
<div className="modal__header__content">
|
||||
{props.onClose && (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-row items-center justify-center',
|
||||
'p-2 rounded-full opacity-50',
|
||||
'hover:opacity-100 text-black',
|
||||
)}
|
||||
>
|
||||
<Icon fa="fas fa-times" size={1} onClick={props.onClose} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ModalContent(props: ContentProps): ReactElement {
|
||||
return (
|
||||
<div className={classNames('modal__content', props.className)}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ModalFooter(props: FooterProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'border-t modal__footer border-gray-100 w-full',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
|
||||
&__overlay {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
margin: 3rem auto;
|
||||
border-radius: 0.5rem;
|
||||
z-index: 200;
|
||||
overflow: hidden;
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
width: 100vw !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
&__title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1 1 auto;
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow-y: auto;
|
||||
|
||||
p:nth-of-type(1) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.8125rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-content: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
export default function NavigateWithParams(props: {
|
||||
to: string;
|
||||
}): ReactElement {
|
||||
const location = useLocation();
|
||||
return <Navigate to={location.pathname + props.to} />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
.custom-modal {
|
||||
height: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
margin: 0 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.custom-modal-content {
|
||||
flex-grow: 2;
|
||||
overflow-y: auto;
|
||||
max-height: 90%;
|
||||
}
|
||||
|
||||
.modal__overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
Children,
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { makePlugin, getPluginConfig } from '../../utils/misc';
|
||||
import { addPlugin } from '../../utils/rpc';
|
||||
import Modal, {
|
||||
ModalHeader,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from '../../components/Modal/Modal';
|
||||
import type { PluginConfig } from '../../utils/misc';
|
||||
import './index.scss';
|
||||
import logo from '../../assets/img/icon-128.png';
|
||||
import {
|
||||
HostFunctionsDescriptions,
|
||||
MultipleParts,
|
||||
PermissionDescription,
|
||||
} from '../../utils/plugins';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import classNames from 'classnames';
|
||||
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
|
||||
|
||||
export default function PluginUploadInfo({
|
||||
onPluginInstalled,
|
||||
}: {
|
||||
onPluginInstalled?: () => void;
|
||||
}): ReactElement {
|
||||
const [error, showError] = useState('');
|
||||
const [pluginBuffer, setPluginBuffer] = useState<ArrayBuffer | any>(null);
|
||||
const [pluginContent, setPluginContent] = useState<PluginConfig | null>(null);
|
||||
|
||||
const onAddPlugin = useCallback(
|
||||
async (evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
await addPlugin(Buffer.from(pluginBuffer).toString('hex'));
|
||||
setPluginContent(null);
|
||||
onPluginInstalled?.();
|
||||
} catch (e: any) {
|
||||
showError(e?.message || 'Invalid Plugin');
|
||||
}
|
||||
},
|
||||
[pluginContent, pluginBuffer],
|
||||
);
|
||||
|
||||
const onPluginInfo = useCallback(
|
||||
async (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!evt.target.files) return;
|
||||
try {
|
||||
const [file] = evt.target.files;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const plugin = await makePlugin(arrayBuffer);
|
||||
setPluginContent(await getPluginConfig(plugin));
|
||||
setPluginBuffer(arrayBuffer);
|
||||
} catch (e: any) {
|
||||
showError(e?.message || 'Invalid Plugin');
|
||||
} finally {
|
||||
evt.target.value = '';
|
||||
}
|
||||
},
|
||||
[setPluginContent, setPluginBuffer],
|
||||
);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setPluginContent(null);
|
||||
setPluginBuffer(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="opacity-0 absolute top-0 right-0 h-full w-full cursor-pointer"
|
||||
type="file"
|
||||
onChange={onPluginInfo}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
{error && <ErrorModal onClose={() => showError('')} message={error} />}
|
||||
{pluginContent && (
|
||||
<PluginInfoModal
|
||||
pluginContent={pluginContent}
|
||||
onClose={onClose}
|
||||
onAddPlugin={onAddPlugin}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginInfoModalHeader(props: {
|
||||
className?: string;
|
||||
children: ReactNode | ReactNode[];
|
||||
}) {
|
||||
return <div className={props.className}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PluginInfoModalContent(props: {
|
||||
className?: string;
|
||||
children: ReactNode | ReactNode[];
|
||||
}) {
|
||||
return <div className={props.className}>{props.children}</div>;
|
||||
}
|
||||
|
||||
export function PluginInfoModal(props: {
|
||||
pluginContent: PluginConfig;
|
||||
onClose: () => void;
|
||||
onAddPlugin?: MouseEventHandler;
|
||||
children?: ReactNode | ReactNode[];
|
||||
}) {
|
||||
const { pluginContent, onClose, onAddPlugin, children } = props;
|
||||
|
||||
const header = Children.toArray(children).filter(
|
||||
(c: any) => c.type.name === 'PluginInfoModalHeader',
|
||||
)[0];
|
||||
|
||||
const content = Children.toArray(children).filter(
|
||||
(c: any) => c.type.name === 'PluginInfoModalContent',
|
||||
)[0];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
className="custom-modal !rounded-none flex items-center justify-center gap-4 cursor-default"
|
||||
>
|
||||
<ModalHeader className="w-full p-2 border-gray-200 text-gray-500">
|
||||
{header || (
|
||||
<div className="flex flex-row items-end justify-start gap-2">
|
||||
<img className="h-5" src={logo || DefaultPluginIcon} alt="logo" />
|
||||
<span className="font-semibold">{`Installing ${pluginContent.title}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</ModalHeader>
|
||||
<ModalContent className="flex flex-col flex-grow-0 flex-shrink-0 items-center px-8 py-2 gap-2 w-full max-h-none">
|
||||
{content || (
|
||||
<>
|
||||
<img
|
||||
className="w-12 h-12"
|
||||
src={pluginContent.icon || DefaultPluginIcon}
|
||||
alt="Plugin Icon"
|
||||
/>
|
||||
<span className="text-3xl text-center">
|
||||
<span>
|
||||
<span className="text-blue-600 font-semibold">
|
||||
{pluginContent.title}
|
||||
</span>{' '}
|
||||
wants access to your browser
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
<div className="flex-grow flex-shrink overflow-y-auto w-full px-8">
|
||||
<PluginPermissions pluginContent={pluginContent} />
|
||||
</div>
|
||||
<ModalFooter className="flex justify-end gap-2 p-4">
|
||||
<button className="button" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
{onAddPlugin && (
|
||||
<button className="button button--primary" onClick={onAddPlugin}>
|
||||
Allow
|
||||
</button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginPermissions({
|
||||
pluginContent,
|
||||
className,
|
||||
}: {
|
||||
pluginContent: PluginConfig;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames('flex flex-col p-2 gap-5', className)}>
|
||||
{pluginContent.hostFunctions?.map((hostFunction: string) => {
|
||||
const HFComponent = HostFunctionsDescriptions[hostFunction];
|
||||
return <HFComponent key={hostFunction} {...pluginContent} />;
|
||||
})}
|
||||
{pluginContent.cookies && (
|
||||
<PermissionDescription fa="fa-solid fa-cookie-bite">
|
||||
<span className="cursor-default">
|
||||
<span className="mr-1">Access cookies from</span>
|
||||
<MultipleParts parts={pluginContent.cookies} />
|
||||
</span>
|
||||
</PermissionDescription>
|
||||
)}
|
||||
{pluginContent.headers && (
|
||||
<PermissionDescription fa="fa-solid fa-envelope">
|
||||
<span className="cursor-default">
|
||||
<span className="mr-1">Access headers from</span>
|
||||
<MultipleParts parts={pluginContent.headers} />
|
||||
</span>
|
||||
</PermissionDescription>
|
||||
)}
|
||||
{pluginContent.localStorage && (
|
||||
<PermissionDescription fa="fa-solid fa-database">
|
||||
<span className="cursor-default">
|
||||
<span className="mr-1">Access local storage storage from</span>
|
||||
<MultipleParts parts={pluginContent.localStorage} />
|
||||
</span>
|
||||
</PermissionDescription>
|
||||
)}
|
||||
{pluginContent.sessionStorage && (
|
||||
<PermissionDescription fa="fa-solid fa-database">
|
||||
<span className="cursor-default">
|
||||
<span className="mr-1">Access session storage from</span>
|
||||
<MultipleParts parts={pluginContent.sessionStorage} />
|
||||
</span>
|
||||
</PermissionDescription>
|
||||
)}
|
||||
{pluginContent.requests && (
|
||||
<PermissionDescription fa="fa-solid fa-globe">
|
||||
<span className="cursor-default">
|
||||
<span className="mr-1">Submit network requests to</span>
|
||||
<MultipleParts
|
||||
parts={pluginContent?.requests.map(({ url }) => url)}
|
||||
/>
|
||||
</span>
|
||||
</PermissionDescription>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
.plugin-box {
|
||||
&__remove-icon {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition: 200ms opacity;
|
||||
transition-delay: 200ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.plugin-box__remove-icon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
padding: .5rem;
|
||||
opacity: .5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.custom-modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
margin: 1rem auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.custom-modal-content {
|
||||
flex-grow: 2;
|
||||
overflow-y: auto;
|
||||
max-height: 90%;
|
||||
}
|
||||
|
||||
.modal__overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { fetchPluginHashes, removePlugin, runPlugin } from '../../utils/rpc';
|
||||
import { usePluginHashes } from '../../reducers/plugins';
|
||||
import {
|
||||
getPluginConfig,
|
||||
hexToArrayBuffer,
|
||||
PluginConfig,
|
||||
} from '../../utils/misc';
|
||||
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '../Icon';
|
||||
import './index.scss';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { ErrorModal } from '../ErrorModal';
|
||||
import {
|
||||
PluginInfoModal,
|
||||
PluginInfoModalContent,
|
||||
PluginInfoModalHeader,
|
||||
} from '../PluginInfo';
|
||||
import { getPluginConfigByHash } from '../../entries/Background/db';
|
||||
import { SidePanelActionTypes } from '../../entries/SidePanel/types';
|
||||
import { openSidePanel } from '../../entries/utils';
|
||||
|
||||
export function PluginList({
|
||||
className,
|
||||
unremovable,
|
||||
onClick,
|
||||
}: {
|
||||
className?: string;
|
||||
unremovable?: boolean;
|
||||
onClick?: (hash: string) => void;
|
||||
}): ReactElement {
|
||||
const hashes = usePluginHashes();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPluginHashes();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames('flex flex-col flex-nowrap gap-1', className)}>
|
||||
{!hashes.length && (
|
||||
<div className="flex flex-col items-center justify-center text-slate-400 cursor-default select-none">
|
||||
<div>No available plugins</div>
|
||||
</div>
|
||||
)}
|
||||
{hashes.map((hash) => (
|
||||
<Plugin
|
||||
key={hash}
|
||||
hash={hash}
|
||||
unremovable={unremovable}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Plugin({
|
||||
hash,
|
||||
hex,
|
||||
unremovable,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
hash: string;
|
||||
hex?: string;
|
||||
className?: string;
|
||||
onClick?: (hash: string) => void;
|
||||
unremovable?: boolean;
|
||||
}): ReactElement {
|
||||
const [error, showError] = useState('');
|
||||
const [config, setConfig] = useState<PluginConfig | null>(null);
|
||||
const [pluginInfo, showPluginInfo] = useState(false);
|
||||
const [remove, showRemove] = useState(false);
|
||||
|
||||
const onRunPlugin = useCallback(async () => {
|
||||
if (!config || remove) return;
|
||||
|
||||
if (onClick) {
|
||||
onClick(hash);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await openSidePanel();
|
||||
|
||||
browser.runtime.sendMessage({
|
||||
type: SidePanelActionTypes.execute_plugin_request,
|
||||
data: {
|
||||
pluginHash: hash,
|
||||
},
|
||||
});
|
||||
|
||||
await runPlugin(hash, 'start');
|
||||
|
||||
window.close();
|
||||
} catch (e: any) {
|
||||
showError(e.message);
|
||||
}
|
||||
}, [hash, config, remove, onClick]);
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (hex) {
|
||||
setConfig(await getPluginConfig(hexToArrayBuffer(hex)));
|
||||
} else {
|
||||
setConfig(await getPluginConfigByHash(hash));
|
||||
}
|
||||
})();
|
||||
}, [hash, hex]);
|
||||
|
||||
const onRemove: MouseEventHandler = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
removePlugin(hash);
|
||||
showRemove(false);
|
||||
},
|
||||
[hash, remove],
|
||||
);
|
||||
|
||||
const onConfirmRemove: MouseEventHandler = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
showRemove(true);
|
||||
},
|
||||
[hash, remove],
|
||||
);
|
||||
|
||||
const onPluginInfo: MouseEventHandler = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
showPluginInfo(true);
|
||||
},
|
||||
[hash, pluginInfo],
|
||||
);
|
||||
|
||||
if (!config) return <></>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-row justify-center border rounded border-slate-300 p-2 gap-2 plugin-box',
|
||||
'cursor-pointer hover:bg-slate-100 hover:border-slate-400 active:bg-slate-200',
|
||||
className,
|
||||
)}
|
||||
onClick={onRunPlugin}
|
||||
>
|
||||
{!!error && <ErrorModal onClose={() => showError('')} message={error} />}
|
||||
{!remove ? (
|
||||
<div className="flex flex-row w-full gap-2">
|
||||
<img className="w-12 h-12" src={config.icon || DefaultPluginIcon} />
|
||||
<div className="flex flex-col w-full items-start">
|
||||
<div className="font-bold flex flex-row h-6 items-center justify-between w-full">
|
||||
{config.title}
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Icon
|
||||
fa="fa-solid fa-circle-info"
|
||||
className="flex flex-row items-center justify-center cursor-pointer plugin-box__remove-icon"
|
||||
onClick={onPluginInfo}
|
||||
/>
|
||||
{!unremovable && (
|
||||
<Icon
|
||||
fa="fa-solid fa-xmark"
|
||||
className="flex flex-row items-center justify-center cursor-pointer text-red-500 bg-red-200 rounded-full plugin-box__remove-icon"
|
||||
onClick={onConfirmRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>{config.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RemovePlugin
|
||||
onRemove={onRemove}
|
||||
showRemove={showRemove}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
{pluginInfo && (
|
||||
<PluginInfoModal
|
||||
pluginContent={config}
|
||||
onClose={() => showPluginInfo(false)}
|
||||
>
|
||||
<PluginInfoModalHeader>
|
||||
<div className="flex flex-row items-end justify-start gap-2">
|
||||
<Icon
|
||||
className="text-slate-500 hover:text-slate-700 cursor-pointer"
|
||||
size={1}
|
||||
fa="fa-solid fa-caret-left"
|
||||
onClick={() => showPluginInfo(false)}
|
||||
/>
|
||||
</div>
|
||||
</PluginInfoModalHeader>
|
||||
<PluginInfoModalContent className="flex flex-col items-center cursor-default">
|
||||
<img
|
||||
className="w-12 h-12 mb-2"
|
||||
src={config.icon || DefaultPluginIcon}
|
||||
alt="Plugin Icon"
|
||||
/>
|
||||
<span className="text-3xl text-blue-600 font-semibold">
|
||||
{config.title}
|
||||
</span>
|
||||
<div className="text-slate-500 text-lg">{config.description}</div>
|
||||
</PluginInfoModalContent>
|
||||
</PluginInfoModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RemovePlugin(props: {
|
||||
onRemove: MouseEventHandler;
|
||||
showRemove: (show: boolean) => void;
|
||||
config: PluginConfig;
|
||||
}): ReactElement {
|
||||
const { onRemove, showRemove, config } = props;
|
||||
|
||||
const onCancel: MouseEventHandler = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
showRemove(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full gap-1">
|
||||
<div className="font-bold text-red-700">
|
||||
{`Are you sure you want to remove "${config.title}" plugin?`}
|
||||
</div>
|
||||
<div className="mb-1">Warning: this cannot be undone.</div>
|
||||
<div className="flex flex-row w-full gap-1">
|
||||
<button className="flex-grow button p-1" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="flex-grow font-bold bg-red-500 hover:bg-red-600 text-white rounded p-1"
|
||||
onClick={onRemove}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import c from 'classnames';
|
||||
|
||||
export function InputBody(props: {
|
||||
body: string;
|
||||
setBody: (body: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
className="textarea h-[90%] w-full resize-none"
|
||||
value={props.body}
|
||||
onChange={(e) => props.setBody(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormBodyTable(props: {
|
||||
formBody: [string, string, boolean?][];
|
||||
setFormBody: (formBody: [string, string, boolean?][]) => void;
|
||||
}) {
|
||||
const toggleKV = useCallback(
|
||||
(index: number) => {
|
||||
const newFormBody = [...props.formBody];
|
||||
newFormBody[index][2] = !newFormBody[index][2];
|
||||
props.setFormBody(newFormBody);
|
||||
},
|
||||
[props.formBody],
|
||||
);
|
||||
|
||||
const setKV = useCallback(
|
||||
(index: number, key: string, value: string) => {
|
||||
const newFormBody = [...props.formBody];
|
||||
newFormBody[index] = [key, value];
|
||||
props.setFormBody(newFormBody);
|
||||
|
||||
if (index === props.formBody.length - 1 && (key || value)) {
|
||||
props.setFormBody([...newFormBody, ['', '', true]]);
|
||||
}
|
||||
},
|
||||
[props.formBody],
|
||||
);
|
||||
|
||||
const last = props.formBody.length - 1;
|
||||
|
||||
return (
|
||||
<table className="border border-slate-300 border-collapse table-fixed w-full">
|
||||
<tbody>
|
||||
{props.formBody.map(([key, value, silent], i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={c('border-b border-slate-200', {
|
||||
'opacity-30': !!silent,
|
||||
})}
|
||||
>
|
||||
<td className="w-8 text-center pt-2">
|
||||
{last !== i && (
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={() => toggleKV(i)}
|
||||
checked={!silent}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-slate-300 font-bold align-top break-all w-fit">
|
||||
<input
|
||||
className="input py-1 px-2 w-full"
|
||||
type="text"
|
||||
value={key}
|
||||
placeholder="Key"
|
||||
onChange={(e) => {
|
||||
setKV(i, e.target.value, value);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top">
|
||||
<input
|
||||
className="input py-1 px-2 w-full"
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder="Value"
|
||||
onChange={(e) => {
|
||||
setKV(i, key, e.target.value);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatForRequest(
|
||||
input: string | [string, string, boolean?][],
|
||||
type: string,
|
||||
): string {
|
||||
try {
|
||||
let pairs: [string, string][] = [];
|
||||
|
||||
if (typeof input === 'string') {
|
||||
const lines = input.split('\n').filter((line) => line.trim() !== '');
|
||||
pairs = lines.map((line) => {
|
||||
const [key, value] = line.split('=').map((part) => part.trim());
|
||||
return [key, value];
|
||||
});
|
||||
} else {
|
||||
pairs = input
|
||||
.filter(([, , silent]) => silent !== true)
|
||||
.map(([key, value]) => [key, value]);
|
||||
}
|
||||
if (type === 'text/plain') {
|
||||
return JSON.stringify(input as string);
|
||||
}
|
||||
if (type === 'application/json') {
|
||||
const jsonObject = JSON.parse(input as string);
|
||||
return JSON.stringify(jsonObject);
|
||||
}
|
||||
|
||||
if (type === 'application/x-www-form-urlencoded') {
|
||||
const searchParams = new URLSearchParams();
|
||||
pairs.forEach(([key, value]) => {
|
||||
searchParams.append(key, value);
|
||||
});
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
return pairs.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
} catch (e) {
|
||||
console.error('Error formatting for request:', e);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseResponse(contentType: string, res: Response) {
|
||||
const parsedResponseData = {
|
||||
json: '',
|
||||
text: '',
|
||||
img: '',
|
||||
headers: Array.from(res.headers.entries()),
|
||||
};
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
parsedResponseData.json = await res.json();
|
||||
} else if (contentType?.includes('text')) {
|
||||
parsedResponseData.text = await res.text();
|
||||
} else if (contentType?.includes('image')) {
|
||||
const blob = await res.blob();
|
||||
parsedResponseData.img = URL.createObjectURL(blob);
|
||||
} else {
|
||||
parsedResponseData.text = await res.text();
|
||||
}
|
||||
|
||||
return parsedResponseData;
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
import React, {
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { notarizeRequest, useRequest } from '../../reducers/requests';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import {
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router';
|
||||
import Icon from '../Icon';
|
||||
import NavigateWithParams from '../NavigateWithParams';
|
||||
import {
|
||||
set,
|
||||
get,
|
||||
MAX_SENT_LS_KEY,
|
||||
MAX_RECEIVED_LS_KEY,
|
||||
getMaxRecv,
|
||||
getMaxSent,
|
||||
} from '../../utils/storage';
|
||||
import { MAX_RECV, MAX_SENT } from '../../utils/constants';
|
||||
|
||||
type Props = {
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export default function RequestDetail(props: Props): ReactElement {
|
||||
const request = useRequest(props.requestId);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const notarize = useCallback(async () => {
|
||||
if (!request) return;
|
||||
|
||||
navigate('/notary/' + request.requestId);
|
||||
}, [request, props.requestId]);
|
||||
|
||||
if (!request) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row flex-nowrap relative items-center bg-slate-300 py-2 px-2 gap-2">
|
||||
<Icon
|
||||
className="cursor-point text-slate-400 hover:text-slate-700"
|
||||
fa="fa-solid fa-xmark"
|
||||
onClick={() => navigate('/requests')}
|
||||
/>
|
||||
<RequestDetailsHeaderTab path="/headers">
|
||||
Headers
|
||||
</RequestDetailsHeaderTab>
|
||||
<RequestDetailsHeaderTab path="/payloads">
|
||||
Payload
|
||||
</RequestDetailsHeaderTab>
|
||||
<RequestDetailsHeaderTab path="/response">
|
||||
Response
|
||||
</RequestDetailsHeaderTab>
|
||||
<RequestDetailsHeaderTab path="/advanced">
|
||||
Advanced
|
||||
</RequestDetailsHeaderTab>
|
||||
<button
|
||||
className="absolute right-2 bg-primary/[0.9] text-white font-bold px-2 py-0.5 hover:bg-primary/[0.8] active:bg-primary"
|
||||
onClick={notarize}
|
||||
>
|
||||
Notarize
|
||||
</button>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route
|
||||
path="headers"
|
||||
element={<RequestHeaders requestId={props.requestId} />}
|
||||
/>
|
||||
<Route
|
||||
path="payloads"
|
||||
element={<RequestPayload requestId={props.requestId} />}
|
||||
/>
|
||||
<Route
|
||||
path="response"
|
||||
element={<WebResponse requestId={props.requestId} />}
|
||||
/>
|
||||
<Route path="advanced" element={<AdvancedOptions />} />
|
||||
<Route path="/" element={<NavigateWithParams to="/headers" />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestDetailsHeaderTab(props: {
|
||||
children: ReactNode;
|
||||
path: string;
|
||||
}): ReactElement {
|
||||
const loc = useLocation();
|
||||
const params = useParams<{ requestId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const selected = loc.pathname.includes(props.path);
|
||||
return (
|
||||
<div
|
||||
className={classNames('font-bold', {
|
||||
'text-slate-700 cursor-default': selected,
|
||||
'text-slate-400 hover:text-slate-500 cursor-pointer': !selected,
|
||||
})}
|
||||
onClick={() => navigate('/requests/' + params.requestId + props.path)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdvancedOptions(): ReactElement {
|
||||
const [maxSent, setMaxSent] = useState(MAX_SENT);
|
||||
const [maxRecv, setMaxRecv] = useState(MAX_RECV);
|
||||
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setMaxRecv((await getMaxRecv()) || MAX_RECV);
|
||||
setMaxSent((await getMaxSent()) || MAX_SENT);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(async () => {
|
||||
await set(MAX_RECEIVED_LS_KEY, maxRecv.toString());
|
||||
await set(MAX_SENT_LS_KEY, maxSent.toString());
|
||||
setDirty(false);
|
||||
}, [maxSent, maxRecv]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
|
||||
<div className="font-semibold">Max Sent Data</div>
|
||||
<input
|
||||
type="number"
|
||||
className="input border"
|
||||
value={maxSent}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
setMaxSent(parseInt(e.target.value));
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
<div className="font-semibold">Max Received Data</div>
|
||||
<input
|
||||
type="number"
|
||||
className="input border"
|
||||
value={maxRecv}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
setMaxRecv(parseInt(e.target.value));
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-row flex-nowrap justify-end gap-2 p-2">
|
||||
<button
|
||||
className="button !bg-primary/[0.9] hover:bg-primary/[0.8] active:bg-primary !text-white"
|
||||
disabled={!dirty}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestPayload(props: Props): ReactElement {
|
||||
const data = useRequest(props.requestId);
|
||||
const [url, setUrl] = useState<URL | null>();
|
||||
const [json, setJson] = useState<any | null>();
|
||||
const [formData, setFormData] = useState<URLSearchParams | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.formData) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(data.formData).forEach(([key, values]) => {
|
||||
values.forEach((v) => params.append(key, v));
|
||||
});
|
||||
setFormData(params);
|
||||
}
|
||||
}, [data?.formData]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setUrl(new URL(data!.url));
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
if (data?.requestBody) {
|
||||
setJson(JSON.parse(data.requestBody));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setJson(null);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-nowrap overflow-y-auto">
|
||||
<table className="border border-slate-300 border-collapse table-fixed w-full">
|
||||
{!!url?.searchParams.size && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Query String Parameters
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from(url.searchParams).map((param) => {
|
||||
return (
|
||||
<tr key={param[0]} className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 break-all">
|
||||
{param[0]}
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2 break-all">
|
||||
{param[1]}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
{!!json && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Body Payload
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={10}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={JSON.stringify(json, null, 2)}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!!formData && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Form Data
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={10}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={formData.toString()}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!json && !!data?.requestBody && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Body
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={6}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={data?.requestBody}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebResponse(props: Props): ReactElement {
|
||||
const data = useRequest(props.requestId);
|
||||
const [response, setResponse] = useState<Response | null>(null);
|
||||
const [json, setJSON] = useState<any | null>(null);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [img, setImg] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<URLSearchParams | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.formData) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(data.formData).forEach(([key, values]) => {
|
||||
values.forEach((v) => params.append(key, v));
|
||||
});
|
||||
setFormData(params);
|
||||
}
|
||||
}, [data?.formData]);
|
||||
|
||||
const replay = useCallback(async () => {
|
||||
if (!data) return null;
|
||||
|
||||
const options = {
|
||||
method: data.method,
|
||||
headers: data.requestHeaders.reduce(
|
||||
// @ts-ignore
|
||||
(acc: { [key: string]: string }, h: chrome.webRequest.HttpHeader) => {
|
||||
if (typeof h.name !== 'undefined' && typeof h.value !== 'undefined') {
|
||||
acc[h.name] = h.value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
body: data?.requestBody,
|
||||
};
|
||||
|
||||
if (formData) {
|
||||
options.body = formData.toString();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const resp = await fetch(data.url, options);
|
||||
setResponse(resp);
|
||||
|
||||
const contentType =
|
||||
resp?.headers.get('content-type') || resp?.headers.get('Content-Type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
resp.json().then((json) => {
|
||||
if (json) {
|
||||
setJSON(json);
|
||||
}
|
||||
});
|
||||
} else if (contentType?.includes('text')) {
|
||||
resp.text().then((_text) => {
|
||||
if (_text) {
|
||||
setText(_text);
|
||||
}
|
||||
});
|
||||
} else if (contentType?.includes('image')) {
|
||||
resp.blob().then((blob) => {
|
||||
if (blob) {
|
||||
setImg(URL.createObjectURL(blob));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resp
|
||||
.blob()
|
||||
.then((blob) => blob.text())
|
||||
.then((_text) => {
|
||||
if (_text) {
|
||||
setText(_text);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [data, formData]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-nowrap overflow-y-auto">
|
||||
{!response && (
|
||||
<div className="p-2">
|
||||
<button className="button" onClick={replay}>
|
||||
Fetch Response
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<table className="border border-slate-300 border-collapse table-fixed w-full">
|
||||
{!!response?.headers && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Headers
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from(response.headers.entries()).map(([name, value]) => {
|
||||
return (
|
||||
<tr className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
|
||||
{name}
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
{!!json && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
JSON
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={16}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={JSON.stringify(json, null, 2)}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!!text && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Text
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={16}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={text}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!!img && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Img
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td className="bg-slate-100" colSpan={2}>
|
||||
<img src={img} />
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestHeaders(props: Props): ReactElement {
|
||||
const data = useRequest(props.requestId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-nowrap overflow-y-auto">
|
||||
<table className="border border-slate-300 border-collapse table-fixed">
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
General
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
|
||||
Method
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2">
|
||||
{data?.method}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
|
||||
Type
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2">
|
||||
{data?.type}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
|
||||
URL
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2">
|
||||
{data?.url}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Headers
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="">
|
||||
{data?.requestHeaders?.map((h) => (
|
||||
<tr key={h.name} className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
|
||||
{h.name}
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2">
|
||||
{h.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import React, {
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { BackgroundActiontype, RequestLog } from '../../entries/Background/rpc';
|
||||
import { useNavigate } from 'react-router';
|
||||
import Fuse from 'fuse.js';
|
||||
import Icon from '../Icon';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { setRequests } from '../../reducers/requests';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
requests: RequestLog[];
|
||||
shouldFix?: boolean;
|
||||
};
|
||||
|
||||
export default function RequestTable(props: Props): ReactElement {
|
||||
const { requests } = props;
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const fuse = new Fuse(requests, {
|
||||
isCaseSensitive: true,
|
||||
minMatchCharLength: 2,
|
||||
shouldSort: true,
|
||||
findAllMatches: true,
|
||||
threshold: 0.2,
|
||||
includeMatches: true,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'method', weight: 2 },
|
||||
{ name: 'type', weight: 2 },
|
||||
{ name: 'requestHeaders.name', weight: 1 },
|
||||
{ name: 'requestHeaders.value', weight: 1 },
|
||||
{ name: 'responseHeaders.name', weight: 1 },
|
||||
{ name: 'responseHeaders.value', weight: 1 },
|
||||
{ name: 'url', weight: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = query ? fuse.search(query) : null;
|
||||
const list = result ? result.map((r) => r.item) : requests;
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: BackgroundActiontype.clear_requests,
|
||||
});
|
||||
dispatch(setRequests([]));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-nowrap flex-grow">
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-row flex-nowrap bg-slate-300 py-1 px-2 gap-2',
|
||||
{
|
||||
'fixed top-[4.5rem] w-full shadow': props.shouldFix,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={query}
|
||||
></input>
|
||||
<Icon
|
||||
className="text-slate-400"
|
||||
fa="fa-solid fa-trash"
|
||||
onClick={reset}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<table className="border border-slate-300 border-collapse table-fixed w-full">
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td className="border border-slate-300 py-1 px-2 w-2/12">
|
||||
Method
|
||||
</td>
|
||||
<td className="border border-slate-300 py-1 px-2 w-3/12">Type</td>
|
||||
<td className="border border-slate-300 py-1 px-2">Name</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => {
|
||||
let url;
|
||||
|
||||
try {
|
||||
url = new URL(r.url);
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={r.requestId}
|
||||
onClick={() => navigate('/requests/' + r.requestId)}
|
||||
className="cursor-pointer hover:bg-slate-100"
|
||||
>
|
||||
<td className="border border-slate-200 align-top py-1 px-2 whitespace-nowrap w-2/12">
|
||||
{r.method}
|
||||
</td>
|
||||
<td className="border border-slate-200 align-top py-1 px-2 whitespace-nowrap w-3/12">
|
||||
{r.type}
|
||||
</td>
|
||||
<td className="border border-slate-200 py-1 px-2 break-all truncate">
|
||||
{url?.pathname}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
export default function ResponseDetail(props: {
|
||||
responseData: {
|
||||
json: any | null;
|
||||
text: string | null;
|
||||
img: string | null;
|
||||
headers: [string, string][] | null;
|
||||
} | null;
|
||||
className?: string;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex flex-col flex-nowrap overflow-y-auto',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<table className="border border-slate-300 border-collapse table-fixed w-full">
|
||||
{!!props.responseData?.json && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
JSON
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={16}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={JSON.stringify(props.responseData.json, null, 2)}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!!props.responseData?.text && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Text
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<textarea
|
||||
rows={16}
|
||||
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
|
||||
value={props.responseData.text}
|
||||
></textarea>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!!props.responseData?.img && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Img
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td className="bg-slate-100" colSpan={2}>
|
||||
<img src={props.responseData.img} />
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{!!props.responseData?.headers && (
|
||||
<>
|
||||
<thead className="bg-slate-200">
|
||||
<tr>
|
||||
<td colSpan={2} className="border border-slate-300 py-1 px-2">
|
||||
Headers
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.responseData?.headers.map(([name, value]) => {
|
||||
return (
|
||||
<tr className="border-b border-slate-200">
|
||||
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
|
||||
{name}
|
||||
</td>
|
||||
<td className="border border-slate-300 break-all align-top py-1 px-2">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
let RequestsLogs: {
|
||||
[tabId: string]: NodeCache;
|
||||
} = {};
|
||||
|
||||
export const deleteCacheByTabId = (tabId: number) => {
|
||||
delete RequestsLogs[tabId];
|
||||
};
|
||||
|
||||
export const getCacheByTabId = (tabId: number): NodeCache => {
|
||||
RequestsLogs[tabId] =
|
||||
RequestsLogs[tabId] ||
|
||||
new NodeCache({
|
||||
stdTTL: 60 * 5, // default 5m TTL
|
||||
maxKeys: 1000000,
|
||||
});
|
||||
|
||||
return RequestsLogs[tabId];
|
||||
};
|
||||
|
||||
export const clearRequestCache = () => {
|
||||
RequestsLogs = {};
|
||||
};
|
||||
|
||||
export const clearCache = () => {
|
||||
clearRequestCache();
|
||||
};
|
||||
@@ -1,514 +0,0 @@
|
||||
import { Level } from 'level';
|
||||
import { PluginConfig, PluginMetadata, sha256, urlify } from '../../utils/misc';
|
||||
import { RequestHistory, RequestProgress } from './rpc';
|
||||
import mutex from './mutex';
|
||||
import { minimatch } from 'minimatch';
|
||||
const charwise = require('charwise');
|
||||
|
||||
export const db = new Level('./ext-db', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const historyDb = db.sublevel<string, RequestHistory>('history', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const pluginDb = db.sublevel<string, string>('plugin', {
|
||||
valueEncoding: 'hex',
|
||||
});
|
||||
const pluginConfigDb = db.sublevel<string, PluginConfig>('pluginConfig', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const pluginMetadataDb = db.sublevel<string, PluginMetadata>('pluginMetadata', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const connectionDb = db.sublevel<string, boolean>('connections', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const cookiesDb = db.sublevel<string, boolean>('cookies', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const headersDb = db.sublevel<string, boolean>('headers', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const localStorageDb = db.sublevel<string, any>('sessionStorage', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const sessionStorageDb = db.sublevel<string, any>('localStorage', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
const appDb = db.sublevel<string, any>('app', {
|
||||
valueEncoding: 'json',
|
||||
});
|
||||
enum AppDatabaseKey {
|
||||
DefaultPluginsInstalled = 'DefaultPluginsInstalled',
|
||||
}
|
||||
|
||||
export async function addNotaryRequest(
|
||||
now = Date.now(),
|
||||
request: Omit<RequestHistory, 'status' | 'id'>,
|
||||
): Promise<RequestHistory> {
|
||||
const id = charwise.encode(now).toString('hex');
|
||||
const newReq: RequestHistory = {
|
||||
...request,
|
||||
id,
|
||||
status: '',
|
||||
};
|
||||
await historyDb.put(id, newReq);
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function addNotaryRequestProofs(
|
||||
id: string,
|
||||
proof: { session: any; substrings: any },
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const newReq: RequestHistory = {
|
||||
...existing,
|
||||
proof,
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
await historyDb.put(id, newReq);
|
||||
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function setNotaryRequestStatus(
|
||||
id: string,
|
||||
status: '' | 'pending' | 'success' | 'error',
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const newReq = {
|
||||
...existing,
|
||||
status,
|
||||
};
|
||||
|
||||
await historyDb.put(id, newReq);
|
||||
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function setNotaryRequestError(
|
||||
id: string,
|
||||
error: any,
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const newReq: RequestHistory = {
|
||||
...existing,
|
||||
error,
|
||||
status: 'error',
|
||||
};
|
||||
|
||||
await historyDb.put(id, newReq);
|
||||
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function setNotaryRequestProgress(
|
||||
id: string,
|
||||
progress: RequestProgress,
|
||||
errorMessage?: string,
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const newReq: RequestHistory = {
|
||||
...existing,
|
||||
progress,
|
||||
errorMessage,
|
||||
};
|
||||
|
||||
await historyDb.put(id, newReq);
|
||||
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function setNotaryRequestVerification(
|
||||
id: string,
|
||||
verification: {
|
||||
sent: string;
|
||||
recv: string;
|
||||
verifierKey: string;
|
||||
notaryKey?: string;
|
||||
},
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const newReq = {
|
||||
...existing,
|
||||
verification,
|
||||
};
|
||||
|
||||
await historyDb.put(id, newReq);
|
||||
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function removeNotaryRequest(
|
||||
id: string,
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
await historyDb.del(id);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function getNotaryRequests(): Promise<RequestHistory[]> {
|
||||
const retVal = [];
|
||||
for await (const [key, value] of historyDb.iterator()) {
|
||||
retVal.push(value);
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
export async function getNotaryRequest(
|
||||
id: string,
|
||||
): Promise<RequestHistory | null> {
|
||||
return historyDb.get(id).catch(() => null);
|
||||
}
|
||||
|
||||
export async function getPluginHashes(): Promise<string[]> {
|
||||
const retVal: string[] = [];
|
||||
for await (const [key] of pluginDb.iterator()) {
|
||||
retVal.push(key);
|
||||
}
|
||||
return retVal;
|
||||
}
|
||||
|
||||
export async function getPluginByHash(hash: string): Promise<string | null> {
|
||||
try {
|
||||
const plugin = await pluginDb.get(hash);
|
||||
return plugin;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPlugin(hex: string): Promise<string | null> {
|
||||
const hash = await sha256(hex);
|
||||
|
||||
if (await getPluginByHash(hash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await pluginDb.put(hash, hex);
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function removePlugin(hash: string): Promise<string | null> {
|
||||
const existing = await pluginDb.get(hash);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
await pluginDb.del(hash);
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
export async function getPluginConfigByHash(
|
||||
hash: string,
|
||||
): Promise<PluginConfig | null> {
|
||||
try {
|
||||
const config = await pluginConfigDb.get(hash);
|
||||
return config;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPluginConfig(
|
||||
hash: string,
|
||||
config: PluginConfig,
|
||||
): Promise<PluginConfig | null> {
|
||||
if (await getPluginConfigByHash(hash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await pluginConfigDb.put(hash, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function removePluginConfig(
|
||||
hash: string,
|
||||
): Promise<PluginConfig | null> {
|
||||
const existing = await pluginConfigDb.get(hash);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
await pluginConfigDb.del(hash);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function getPlugins(): Promise<
|
||||
(PluginConfig & { hash: string; metadata: PluginMetadata })[]
|
||||
> {
|
||||
const hashes = await getPluginHashes();
|
||||
const ret: (PluginConfig & { hash: string; metadata: PluginMetadata })[] = [];
|
||||
for (const hash of hashes) {
|
||||
const config = await getPluginConfigByHash(hash);
|
||||
const metadata = await getPluginMetadataByHash(hash);
|
||||
if (config) {
|
||||
ret.push({
|
||||
...config,
|
||||
hash,
|
||||
metadata: metadata
|
||||
? {
|
||||
...metadata,
|
||||
hash,
|
||||
}
|
||||
: {
|
||||
filePath: '',
|
||||
origin: '',
|
||||
hash,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export async function getPluginMetadataByHash(
|
||||
hash: string,
|
||||
): Promise<PluginMetadata | null> {
|
||||
try {
|
||||
const metadata = await pluginMetadataDb.get(hash);
|
||||
return metadata;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPluginMetadata(
|
||||
hash: string,
|
||||
metadata: PluginMetadata,
|
||||
): Promise<PluginMetadata | null> {
|
||||
await pluginMetadataDb.put(hash, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function removePluginMetadata(
|
||||
hash: string,
|
||||
): Promise<PluginMetadata | null> {
|
||||
const existing = await pluginMetadataDb.get(hash);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
await pluginMetadataDb.del(hash);
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
export async function setNotaryRequestCid(
|
||||
id: string,
|
||||
cid: string,
|
||||
): Promise<RequestHistory | null> {
|
||||
const existing = await historyDb.get(id);
|
||||
|
||||
if (!existing) return null;
|
||||
|
||||
const newReq = {
|
||||
...existing,
|
||||
cid,
|
||||
};
|
||||
|
||||
await historyDb.put(id, newReq);
|
||||
|
||||
return newReq;
|
||||
}
|
||||
|
||||
export async function setConnection(origin: string) {
|
||||
if (await getConnection(origin)) return null;
|
||||
await connectionDb.put(origin, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function setCookies(host: string, name: string, value: string) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await cookiesDb.sublevel(host).put(name, value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearCookies(host: string) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await cookiesDb.sublevel(host).clear();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCookies(link: string, name: string) {
|
||||
try {
|
||||
const existing = await cookiesDb.sublevel(link).get(name);
|
||||
return existing;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCookiesByHost(link: string) {
|
||||
const ret: { [key: string]: string } = {};
|
||||
const links: { [k: string]: boolean } = {};
|
||||
const url = urlify(link);
|
||||
|
||||
for await (const sublevel of cookiesDb.keys({ keyEncoding: 'utf8' })) {
|
||||
const l = sublevel.split('!')[1];
|
||||
links[l] = true;
|
||||
}
|
||||
|
||||
const cookieLink = url
|
||||
? Object.keys(links).filter((l) => minimatch(l, link))[0]
|
||||
: Object.keys(links).filter((l) => urlify(l)?.host === link)[0];
|
||||
|
||||
if (!cookieLink) return ret;
|
||||
|
||||
for await (const [key, value] of cookiesDb.sublevel(cookieLink).iterator()) {
|
||||
ret[key] = value;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export async function deleteConnection(origin: string) {
|
||||
return mutex.runExclusive(async () => {
|
||||
if (await getConnection(origin)) {
|
||||
await connectionDb.del(origin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConnection(origin: string) {
|
||||
try {
|
||||
const existing = await connectionDb.get(origin);
|
||||
return existing;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setHeaders(link: string, name: string, value?: string) {
|
||||
if (!value) return null;
|
||||
return mutex.runExclusive(async () => {
|
||||
await headersDb.sublevel(link).put(name, value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearHeaders(host: string) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await headersDb.sublevel(host).clear();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHeaders(host: string, name: string) {
|
||||
try {
|
||||
const existing = await headersDb.sublevel(host).get(name);
|
||||
return existing;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export async function getHeadersByHost(link: string) {
|
||||
const ret: { [key: string]: string } = {};
|
||||
const url = urlify(link);
|
||||
|
||||
const links: { [k: string]: boolean } = {};
|
||||
for await (const sublevel of headersDb.keys({ keyEncoding: 'utf8' })) {
|
||||
const l = sublevel.split('!')[1];
|
||||
links[l] = true;
|
||||
}
|
||||
|
||||
const headerLink = url
|
||||
? Object.keys(links).filter((l) => minimatch(l, link))[0]
|
||||
: Object.keys(links).filter((l) => urlify(l)?.host === link)[0];
|
||||
|
||||
if (!headerLink) return ret;
|
||||
|
||||
for await (const [key, value] of headersDb.sublevel(headerLink).iterator()) {
|
||||
ret[key] = value;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export async function setLocalStorage(
|
||||
host: string,
|
||||
name: string,
|
||||
value: string,
|
||||
) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await localStorageDb.sublevel(host).put(name, value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSessionStorage(
|
||||
host: string,
|
||||
name: string,
|
||||
value: string,
|
||||
) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await sessionStorageDb.sublevel(host).put(name, value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearLocalStorage(host: string) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await localStorageDb.sublevel(host).clear();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionStorage(host: string) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await sessionStorageDb.sublevel(host).clear();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLocalStorageByHost(host: string) {
|
||||
const ret: { [key: string]: string } = {};
|
||||
for await (const [key, value] of localStorageDb.sublevel(host).iterator()) {
|
||||
ret[key] = value;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export async function getSessionStorageByHost(host: string) {
|
||||
const ret: { [key: string]: string } = {};
|
||||
for await (const [key, value] of sessionStorageDb.sublevel(host).iterator()) {
|
||||
ret[key] = value;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
async function getDefaultPluginsInstalled(): Promise<string | boolean> {
|
||||
return appDb.get(AppDatabaseKey.DefaultPluginsInstalled).catch(() => false);
|
||||
}
|
||||
|
||||
export async function setDefaultPluginsInstalled(
|
||||
installed: string | boolean = false,
|
||||
) {
|
||||
return mutex.runExclusive(async () => {
|
||||
await appDb.put(AppDatabaseKey.DefaultPluginsInstalled, installed);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAppState() {
|
||||
return {
|
||||
defaultPluginsInstalled: await getDefaultPluginsInstalled(),
|
||||
};
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { getCacheByTabId } from './cache';
|
||||
import { BackgroundActiontype, RequestLog } from './rpc';
|
||||
import mutex from './mutex';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { addRequest } from '../../reducers/requests';
|
||||
import { urlify } from '../../utils/misc';
|
||||
import { getHeadersByHost, setCookies, setHeaders } from './db';
|
||||
export const onSendHeaders = (
|
||||
details: browser.WebRequest.OnSendHeadersDetailsType,
|
||||
) => {
|
||||
return mutex.runExclusive(async () => {
|
||||
const { method, tabId, requestId } = details;
|
||||
|
||||
if (method !== 'OPTIONS') {
|
||||
const cache = getCacheByTabId(tabId);
|
||||
const existing = cache.get<RequestLog>(requestId);
|
||||
const { origin, pathname } = urlify(details.url) || {};
|
||||
|
||||
const link = [origin, pathname].join('');
|
||||
|
||||
if (link && details.requestHeaders) {
|
||||
details.requestHeaders.forEach((header) => {
|
||||
const { name, value } = header;
|
||||
if (/^cookie$/i.test(name) && value) {
|
||||
value.split(';').forEach((cookieStr) => {
|
||||
const index = cookieStr.indexOf('=');
|
||||
if (index !== -1) {
|
||||
const cookieName = cookieStr.slice(0, index).trim();
|
||||
const cookieValue = cookieStr.slice(index + 1);
|
||||
setCookies(link, cookieName, cookieValue);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setHeaders(link, name, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cache.set(requestId, {
|
||||
...existing,
|
||||
method: details.method as 'GET' | 'POST',
|
||||
type: details.type,
|
||||
url: details.url,
|
||||
initiator: details.initiator || null,
|
||||
requestHeaders: details.requestHeaders || [],
|
||||
tabId: tabId,
|
||||
requestId: requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const onBeforeRequest = (
|
||||
details: browser.WebRequest.OnBeforeRequestDetailsType,
|
||||
) => {
|
||||
mutex.runExclusive(async () => {
|
||||
const { method, requestBody, tabId, requestId } = details;
|
||||
|
||||
if (method === 'OPTIONS') return;
|
||||
|
||||
if (requestBody) {
|
||||
const cache = getCacheByTabId(tabId);
|
||||
const existing = cache.get<RequestLog>(requestId);
|
||||
|
||||
if (requestBody.raw && requestBody.raw[0]?.bytes) {
|
||||
try {
|
||||
cache.set(requestId, {
|
||||
...existing,
|
||||
requestBody: Buffer.from(requestBody.raw[0].bytes).toString(
|
||||
'utf-8',
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} else if (requestBody.formData) {
|
||||
cache.set(requestId, {
|
||||
...existing,
|
||||
formData: requestBody.formData,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const onResponseStarted = (
|
||||
details: browser.WebRequest.OnResponseStartedDetailsType,
|
||||
) => {
|
||||
mutex.runExclusive(async () => {
|
||||
const { method, responseHeaders, tabId, requestId } = details;
|
||||
|
||||
if (method === 'OPTIONS') return;
|
||||
|
||||
const cache = getCacheByTabId(tabId);
|
||||
|
||||
const existing = cache.get<RequestLog>(requestId);
|
||||
const newLog: RequestLog = {
|
||||
requestHeaders: [],
|
||||
...existing,
|
||||
method: details.method,
|
||||
type: details.type,
|
||||
url: details.url,
|
||||
initiator: details.initiator || null,
|
||||
tabId: tabId,
|
||||
requestId: requestId,
|
||||
responseHeaders,
|
||||
};
|
||||
|
||||
cache.set(requestId, newLog);
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
type: BackgroundActiontype.push_action,
|
||||
data: {
|
||||
tabId: details.tabId,
|
||||
request: newLog,
|
||||
},
|
||||
action: addRequest(newLog),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
import { onBeforeRequest, onResponseStarted, onSendHeaders } from './handlers';
|
||||
import { deleteCacheByTabId } from './cache';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { getAppState, removePlugin, setDefaultPluginsInstalled } from './db';
|
||||
import { installPlugin } from './plugins/utils';
|
||||
|
||||
(async () => {
|
||||
browser.webRequest.onSendHeaders.addListener(
|
||||
onSendHeaders,
|
||||
{
|
||||
urls: ['<all_urls>'],
|
||||
},
|
||||
['requestHeaders', 'extraHeaders'],
|
||||
);
|
||||
|
||||
browser.webRequest.onBeforeRequest.addListener(
|
||||
onBeforeRequest,
|
||||
{
|
||||
urls: ['<all_urls>'],
|
||||
},
|
||||
['requestBody'],
|
||||
);
|
||||
|
||||
browser.webRequest.onResponseStarted.addListener(
|
||||
onResponseStarted,
|
||||
{
|
||||
urls: ['<all_urls>'],
|
||||
},
|
||||
['responseHeaders', 'extraHeaders'],
|
||||
);
|
||||
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
deleteCacheByTabId(tabId);
|
||||
});
|
||||
|
||||
const { defaultPluginsInstalled } = await getAppState();
|
||||
|
||||
switch (defaultPluginsInstalled) {
|
||||
case false: {
|
||||
try {
|
||||
const twitterProfileUrl = browser.runtime.getURL(
|
||||
'twitter_profile.wasm',
|
||||
);
|
||||
const discordDmUrl = browser.runtime.getURL('discord_dm.wasm');
|
||||
await installPlugin(twitterProfileUrl);
|
||||
await installPlugin(discordDmUrl);
|
||||
} finally {
|
||||
await setDefaultPluginsInstalled('0.1.0.703');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case true: {
|
||||
try {
|
||||
await removePlugin(
|
||||
'6931d2ad63340d3a1fb1a5c1e3f4454c5a518164d6de5ad272e744832355ee02',
|
||||
);
|
||||
const twitterProfileUrl = browser.runtime.getURL(
|
||||
'twitter_profile.wasm',
|
||||
);
|
||||
await installPlugin(twitterProfileUrl);
|
||||
} finally {
|
||||
await setDefaultPluginsInstalled('0.1.0.703');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '0.1.0.703':
|
||||
break;
|
||||
}
|
||||
|
||||
const { initRPC } = await import('./rpc');
|
||||
await createOffscreenDocument();
|
||||
initRPC();
|
||||
})();
|
||||
|
||||
let creatingOffscreen: any;
|
||||
async function createOffscreenDocument() {
|
||||
const offscreenUrl = browser.runtime.getURL('offscreen.html');
|
||||
// @ts-ignore
|
||||
const existingContexts = await browser.runtime.getContexts({
|
||||
contextTypes: ['OFFSCREEN_DOCUMENT'],
|
||||
documentUrls: [offscreenUrl],
|
||||
});
|
||||
|
||||
if (existingContexts.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (creatingOffscreen) {
|
||||
await creatingOffscreen;
|
||||
} else {
|
||||
creatingOffscreen = (chrome as any).offscreen.createDocument({
|
||||
url: 'offscreen.html',
|
||||
reasons: ['WORKERS'],
|
||||
justification: 'workers for multithreading',
|
||||
});
|
||||
await creatingOffscreen;
|
||||
creatingOffscreen = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Mutex } from 'async-mutex';
|
||||
|
||||
const mutex = new Mutex();
|
||||
|
||||
export default mutex;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { addPlugin, addPluginConfig, addPluginMetadata } from '../db';
|
||||
import { getPluginConfig } from '../../../utils/misc';
|
||||
|
||||
export async function installPlugin(
|
||||
urlOrBuffer: ArrayBuffer | string,
|
||||
origin = '',
|
||||
filePath = '',
|
||||
metadata: {[key: string]: string} = {},
|
||||
) {
|
||||
let arrayBuffer;
|
||||
|
||||
if (typeof urlOrBuffer === 'string') {
|
||||
const resp = await fetch(urlOrBuffer);
|
||||
arrayBuffer = await resp.arrayBuffer();
|
||||
} else {
|
||||
arrayBuffer = urlOrBuffer;
|
||||
}
|
||||
|
||||
const config = await getPluginConfig(arrayBuffer);
|
||||
const hex = Buffer.from(arrayBuffer).toString('hex');
|
||||
const hash = await addPlugin(hex);
|
||||
await addPluginConfig(hash!, config);
|
||||
await addPluginMetadata(hash!, {
|
||||
...metadata,
|
||||
origin,
|
||||
filePath,
|
||||
});
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function mapSecretsToRange(secrets: string[], text: string) {
|
||||
return secrets
|
||||
.map((secret: string) => {
|
||||
const index = text.indexOf(secret);
|
||||
return index > -1
|
||||
? {
|
||||
start: index,
|
||||
end: index + secret.length,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter((data: any) => !!data) as { start: number; end: number }[]
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import { devlog, safeParseJSON, sha256 } from '../../utils/misc';
|
||||
import {
|
||||
appendIncomingPairingRequests,
|
||||
appendIncomingProofRequests,
|
||||
appendOutgoingPairingRequests,
|
||||
appendOutgoingProofRequest,
|
||||
setClientId,
|
||||
setConnected,
|
||||
setIncomingPairingRequest,
|
||||
setIncomingProofRequest,
|
||||
setIsProving,
|
||||
setIsVerifying,
|
||||
setOutgoingPairingRequest,
|
||||
setOutgoingProofRequest,
|
||||
setP2PError,
|
||||
setP2PPresentation,
|
||||
setPairing,
|
||||
} from '../../reducers/p2p';
|
||||
import { pushToRedux } from '../utils';
|
||||
import { getPluginByHash } from './db';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { OffscreenActionTypes } from '../Offscreen/types';
|
||||
import { getMaxRecv, getMaxSent, getRendezvousApi } from '../../utils/storage';
|
||||
import { SidePanelActionTypes } from '../SidePanel/types';
|
||||
import { Transcript, VerifierOutput } from 'tlsn-js';
|
||||
|
||||
const state: {
|
||||
clientId: string;
|
||||
pairing: string;
|
||||
socket: WebSocket | null;
|
||||
connected: boolean;
|
||||
reqId: number;
|
||||
incomingPairingRequests: string[];
|
||||
outgoingPairingRequests: string[];
|
||||
incomingProofRequests: string[];
|
||||
outgoingProofRequests: string[];
|
||||
isProving: boolean;
|
||||
isVerifying: boolean;
|
||||
presentation: null | { sent: string; recv: string };
|
||||
} = {
|
||||
clientId: '',
|
||||
pairing: '',
|
||||
socket: null,
|
||||
connected: false,
|
||||
reqId: 0,
|
||||
incomingPairingRequests: [],
|
||||
outgoingPairingRequests: [],
|
||||
incomingProofRequests: [],
|
||||
outgoingProofRequests: [],
|
||||
isProving: false,
|
||||
isVerifying: false,
|
||||
presentation: null,
|
||||
};
|
||||
|
||||
export const getP2PState = async () => {
|
||||
pushToRedux(setPairing(state.pairing));
|
||||
pushToRedux(setConnected(state.connected));
|
||||
pushToRedux(setClientId(state.clientId));
|
||||
pushToRedux(setIncomingPairingRequest(state.incomingPairingRequests));
|
||||
pushToRedux(setOutgoingPairingRequest(state.outgoingPairingRequests));
|
||||
pushToRedux(setIncomingProofRequest(state.incomingProofRequests));
|
||||
pushToRedux(setOutgoingProofRequest(state.outgoingProofRequests));
|
||||
pushToRedux(setIsProving(state.isProving));
|
||||
pushToRedux(setIsVerifying(state.isVerifying));
|
||||
pushToRedux(setP2PPresentation(state.presentation));
|
||||
};
|
||||
|
||||
export const connectSession = async () => {
|
||||
if (state.socket) return;
|
||||
|
||||
const rendezvousAPI = await getRendezvousApi();
|
||||
const socket = new WebSocket(rendezvousAPI);
|
||||
|
||||
socket.onopen = () => {
|
||||
devlog('Connected to websocket');
|
||||
state.connected = true;
|
||||
state.socket = socket;
|
||||
pushToRedux(setConnected(true));
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
if (socket.readyState === 1) {
|
||||
// Check if connection is open
|
||||
socket.send(bufferify({ method: 'ping' }));
|
||||
} else {
|
||||
disconnectSession();
|
||||
clearInterval(heartbeatInterval); // Stop heartbeat if connection is closed
|
||||
}
|
||||
}, 55000);
|
||||
};
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
const message: any = safeParseJSON(await event.data.text());
|
||||
|
||||
if (message.error) {
|
||||
pushToRedux(setP2PError(message.error.message));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.method) {
|
||||
case 'client_connect': {
|
||||
const { clientId } = message.params;
|
||||
state.clientId = clientId;
|
||||
pushToRedux(setClientId(clientId));
|
||||
break;
|
||||
}
|
||||
case 'pair_request': {
|
||||
const { from } = message.params;
|
||||
state.incomingPairingRequests = [
|
||||
...new Set(state.incomingPairingRequests.concat(from)),
|
||||
];
|
||||
pushToRedux(appendIncomingPairingRequests(from));
|
||||
sendMessage(from, 'pair_request_sent', { pairId: state.clientId });
|
||||
break;
|
||||
}
|
||||
case 'pair_request_sent': {
|
||||
const { pairId } = message.params;
|
||||
state.outgoingPairingRequests = [
|
||||
...new Set(state.outgoingPairingRequests.concat(pairId)),
|
||||
];
|
||||
pushToRedux(appendOutgoingPairingRequests(pairId));
|
||||
break;
|
||||
}
|
||||
case 'pair_request_cancel': {
|
||||
const { from } = message.params;
|
||||
state.incomingPairingRequests = state.incomingPairingRequests.filter(
|
||||
(id) => id !== from,
|
||||
);
|
||||
pushToRedux(setIncomingPairingRequest(state.incomingPairingRequests));
|
||||
sendMessage(from, 'pair_request_cancelled', { pairId: state.clientId });
|
||||
break;
|
||||
}
|
||||
case 'pair_request_cancelled': {
|
||||
const { pairId } = message.params;
|
||||
state.outgoingPairingRequests = state.outgoingPairingRequests.filter(
|
||||
(id) => id !== pairId,
|
||||
);
|
||||
pushToRedux(setOutgoingPairingRequest(state.outgoingPairingRequests));
|
||||
break;
|
||||
}
|
||||
case 'pair_request_reject': {
|
||||
const { from } = message.params;
|
||||
state.outgoingPairingRequests = state.outgoingPairingRequests.filter(
|
||||
(id) => id !== from,
|
||||
);
|
||||
pushToRedux(setOutgoingPairingRequest(state.outgoingPairingRequests));
|
||||
sendMessage(from, 'pair_request_rejected', { pairId: state.clientId });
|
||||
break;
|
||||
}
|
||||
case 'pair_request_accept': {
|
||||
const { from } = message.params;
|
||||
state.pairing = from;
|
||||
state.outgoingPairingRequests = state.outgoingPairingRequests.filter(
|
||||
(id) => id !== from,
|
||||
);
|
||||
pushToRedux(setOutgoingPairingRequest(state.outgoingPairingRequests));
|
||||
pushToRedux(setPairing(from));
|
||||
sendMessage(from, 'pair_request_success', { pairId: state.clientId });
|
||||
break;
|
||||
}
|
||||
case 'pair_request_success': {
|
||||
const { pairId } = message.params;
|
||||
state.pairing = pairId;
|
||||
pushToRedux(setPairing(pairId));
|
||||
state.incomingPairingRequests = state.incomingPairingRequests.filter(
|
||||
(id) => id !== pairId,
|
||||
);
|
||||
pushToRedux(setIncomingPairingRequest(state.incomingPairingRequests));
|
||||
break;
|
||||
}
|
||||
case 'pair_request_rejected': {
|
||||
const { pairId } = message.params;
|
||||
state.incomingPairingRequests = state.incomingPairingRequests.filter(
|
||||
(id) => id !== pairId,
|
||||
);
|
||||
pushToRedux(setIncomingPairingRequest(state.incomingPairingRequests));
|
||||
break;
|
||||
}
|
||||
case 'request_proof': {
|
||||
const { plugin, pluginHash, from } = message.params;
|
||||
state.incomingProofRequests = [
|
||||
...new Set(state.incomingProofRequests.concat(plugin)),
|
||||
];
|
||||
pushToRedux(appendIncomingProofRequests(plugin));
|
||||
sendMessage(from, 'proof_request_received', { pluginHash });
|
||||
break;
|
||||
}
|
||||
case 'request_proof_by_hash': {
|
||||
const { pluginHash, from } = message.params;
|
||||
const plugin = await getPluginByHash(pluginHash);
|
||||
if (plugin) {
|
||||
state.incomingProofRequests = [
|
||||
...new Set(state.incomingProofRequests.concat(plugin)),
|
||||
];
|
||||
pushToRedux(appendIncomingProofRequests(plugin));
|
||||
sendMessage(from, 'proof_request_received', { pluginHash });
|
||||
} else {
|
||||
sendMessage(from, 'request_proof_by_hash_failed', { pluginHash });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'request_proof_by_hash_failed': {
|
||||
const { pluginHash } = message.params;
|
||||
requestProof(pluginHash);
|
||||
break;
|
||||
}
|
||||
case 'proof_request_received': {
|
||||
const { pluginHash } = message.params;
|
||||
state.outgoingProofRequests = [
|
||||
...new Set(state.outgoingProofRequests.concat(pluginHash)),
|
||||
];
|
||||
pushToRedux(appendOutgoingProofRequest(pluginHash));
|
||||
break;
|
||||
}
|
||||
case 'proof_request_cancelled':
|
||||
await handleRemoveOutgoingProofRequest(message);
|
||||
break;
|
||||
case 'proof_request_reject': {
|
||||
const { pluginHash, from } = message.params;
|
||||
await handleRemoveOutgoingProofRequest(message);
|
||||
sendMessage(from, 'proof_request_rejected', { pluginHash });
|
||||
break;
|
||||
}
|
||||
case 'proof_request_cancel': {
|
||||
const { pluginHash, from } = message.params;
|
||||
await handleRemoveIncomingProofRequest(message);
|
||||
sendMessage(from, 'proof_request_cancelled', { pluginHash });
|
||||
break;
|
||||
}
|
||||
case 'proof_request_rejected':
|
||||
await handleRemoveIncomingProofRequest(message);
|
||||
break;
|
||||
case 'proof_request_accept': {
|
||||
const { pluginHash, from } = message.params;
|
||||
const maxSentData = await getMaxSent();
|
||||
const maxRecvData = await getMaxRecv();
|
||||
const rendezvousApi = await getRendezvousApi();
|
||||
browser.runtime.sendMessage({
|
||||
type: OffscreenActionTypes.start_p2p_verifier,
|
||||
data: {
|
||||
pluginHash,
|
||||
maxSentData,
|
||||
maxRecvData,
|
||||
verifierUrl:
|
||||
rendezvousApi + '?clientId=' + state.clientId + ':proof',
|
||||
peerId: state.pairing,
|
||||
},
|
||||
});
|
||||
state.isVerifying = true;
|
||||
pushToRedux(setIsVerifying(true));
|
||||
break;
|
||||
}
|
||||
case 'verifier_started': {
|
||||
const { pluginHash } = message.params;
|
||||
browser.runtime.sendMessage({
|
||||
type: SidePanelActionTypes.start_p2p_plugin,
|
||||
data: {
|
||||
pluginHash: pluginHash,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'prover_setup': {
|
||||
const { pluginHash } = message.params;
|
||||
browser.runtime.sendMessage({
|
||||
type: OffscreenActionTypes.prover_setup,
|
||||
data: {
|
||||
pluginHash: pluginHash,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'prover_started': {
|
||||
const { pluginHash } = message.params;
|
||||
browser.runtime.sendMessage({
|
||||
type: OffscreenActionTypes.prover_started,
|
||||
data: {
|
||||
pluginHash: pluginHash,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'proof_request_start': {
|
||||
const { pluginHash, from } = message.params;
|
||||
browser.runtime.sendMessage({
|
||||
type: OffscreenActionTypes.start_p2p_proof_request,
|
||||
data: {
|
||||
pluginHash: pluginHash,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'proof_request_end': {
|
||||
const { pluginHash, proof } = message.params;
|
||||
const transcript = new Transcript({
|
||||
sent: proof.transcript.sent,
|
||||
recv: proof.transcript.recv,
|
||||
});
|
||||
|
||||
state.presentation = {
|
||||
sent: transcript.sent(),
|
||||
recv: transcript.recv(),
|
||||
};
|
||||
|
||||
pushToRedux(setP2PPresentation(state.presentation));
|
||||
|
||||
browser.runtime.sendMessage({
|
||||
type: OffscreenActionTypes.end_p2p_proof_request,
|
||||
data: {
|
||||
pluginHash: pluginHash,
|
||||
proof: proof,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn(`Unknown message type "${message.method}"`);
|
||||
break;
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
console.error('Error connecting to websocket');
|
||||
pushToRedux(setConnected(false));
|
||||
};
|
||||
};
|
||||
|
||||
async function handleRemoveOutgoingProofRequest(message: {
|
||||
params: { pluginHash: string };
|
||||
}) {
|
||||
const { pluginHash } = message.params;
|
||||
state.outgoingProofRequests = state.outgoingProofRequests.filter(
|
||||
(hash) => hash !== pluginHash,
|
||||
);
|
||||
pushToRedux(setOutgoingProofRequest(state.outgoingProofRequests));
|
||||
}
|
||||
|
||||
async function handleRemoveIncomingProofRequest(message: {
|
||||
params: { pluginHash: string };
|
||||
}) {
|
||||
const { pluginHash } = message.params;
|
||||
const plugin = await getPluginByHash(pluginHash);
|
||||
const incomingProofRequest = [];
|
||||
for (const hex of state.incomingProofRequests) {
|
||||
if (plugin) {
|
||||
if (plugin !== hex) incomingProofRequest.push(hex);
|
||||
} else {
|
||||
if ((await sha256(hex)) !== pluginHash) incomingProofRequest.push(hex);
|
||||
}
|
||||
}
|
||||
|
||||
state.incomingProofRequests = incomingProofRequest;
|
||||
pushToRedux(setIncomingProofRequest(state.incomingProofRequests));
|
||||
}
|
||||
|
||||
export const disconnectSession = async () => {
|
||||
if (!state.socket) return;
|
||||
const socket = state.socket;
|
||||
state.socket = null;
|
||||
state.clientId = '';
|
||||
state.pairing = '';
|
||||
state.connected = false;
|
||||
state.incomingPairingRequests = [];
|
||||
state.outgoingPairingRequests = [];
|
||||
state.incomingProofRequests = [];
|
||||
state.outgoingProofRequests = [];
|
||||
state.isProving = false;
|
||||
state.isVerifying = false;
|
||||
state.presentation = null;
|
||||
pushToRedux(setPairing(''));
|
||||
pushToRedux(setConnected(false));
|
||||
pushToRedux(setClientId(''));
|
||||
pushToRedux(setIncomingPairingRequest([]));
|
||||
pushToRedux(setOutgoingPairingRequest([]));
|
||||
pushToRedux(setIncomingProofRequest([]));
|
||||
pushToRedux(setOutgoingProofRequest([]));
|
||||
pushToRedux(setIsProving(false));
|
||||
pushToRedux(setIsVerifying(false));
|
||||
pushToRedux(setP2PPresentation(null));
|
||||
await socket.close();
|
||||
};
|
||||
|
||||
export async function sendMessage(
|
||||
target: string,
|
||||
method: string,
|
||||
params?: any,
|
||||
) {
|
||||
const { socket, clientId } = state;
|
||||
|
||||
if (clientId === target) {
|
||||
console.error('client cannot send message to itself.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!socket) {
|
||||
console.error('socket connection not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
console.error('clientId not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
bufferify({
|
||||
method,
|
||||
params: {
|
||||
from: clientId,
|
||||
to: target,
|
||||
id: state.reqId++,
|
||||
...params,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendPairedMessage(method: string, params?: any) {
|
||||
const { pairing } = state;
|
||||
|
||||
if (!pairing) {
|
||||
console.error('not paired to a peer.');
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(pairing, method, params);
|
||||
}
|
||||
|
||||
export const requestProof = async (pluginHash: string) => {
|
||||
const pluginHex = await getPluginByHash(pluginHash);
|
||||
sendPairedMessage('request_proof', {
|
||||
plugin: pluginHex,
|
||||
pluginHash,
|
||||
});
|
||||
};
|
||||
|
||||
export const endProofRequest = async (data: {
|
||||
pluginHash: string;
|
||||
proof: VerifierOutput;
|
||||
}) => {
|
||||
const transcript = new Transcript({
|
||||
sent: data.proof.transcript.sent,
|
||||
recv: data.proof.transcript.recv,
|
||||
});
|
||||
|
||||
state.presentation = {
|
||||
sent: transcript.sent(),
|
||||
recv: transcript.recv(),
|
||||
};
|
||||
|
||||
pushToRedux(setP2PPresentation(state.presentation));
|
||||
|
||||
sendPairedMessage('proof_request_end', {
|
||||
pluginHash: data.pluginHash,
|
||||
proof: data.proof,
|
||||
});
|
||||
};
|
||||
|
||||
export const onProverInstantiated = async () => {
|
||||
state.isProving = true;
|
||||
pushToRedux(setIsProving(true));
|
||||
};
|
||||
|
||||
function bufferify(data: any): Buffer {
|
||||
return Buffer.from(JSON.stringify(data));
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { ContentScriptTypes, RPCClient } from './rpc';
|
||||
import { RequestHistory } from '../Background/rpc';
|
||||
import { PluginConfig, PluginMetadata } from '../../utils/misc';
|
||||
import { PresentationJSON } from '../../utils/types';
|
||||
|
||||
const client = new RPCClient();
|
||||
|
||||
class TLSN {
|
||||
async getHistory(
|
||||
method: string,
|
||||
url: string,
|
||||
metadata?: {
|
||||
[key: string]: string;
|
||||
},
|
||||
): Promise<
|
||||
(Pick<
|
||||
RequestHistory,
|
||||
'id' | 'method' | 'notaryUrl' | 'url' | 'websocketProxyUrl'
|
||||
> & { time: Date })[]
|
||||
> {
|
||||
const resp = await client.call(ContentScriptTypes.get_history, {
|
||||
method,
|
||||
url,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return resp || [];
|
||||
}
|
||||
|
||||
async getProof(id: string): Promise<PresentationJSON | null> {
|
||||
const resp = await client.call(ContentScriptTypes.get_proof, {
|
||||
id,
|
||||
});
|
||||
|
||||
return resp || null;
|
||||
}
|
||||
|
||||
async notarize(
|
||||
url: string,
|
||||
requestOptions?: {
|
||||
method?: string;
|
||||
headers?: { [key: string]: string };
|
||||
body?: string;
|
||||
},
|
||||
proofOptions?: {
|
||||
notaryUrl?: string;
|
||||
websocketProxyUrl?: string;
|
||||
maxSentData?: number;
|
||||
maxRecvData?: number;
|
||||
metadata?: {
|
||||
[k: string]: string;
|
||||
};
|
||||
},
|
||||
): Promise<PresentationJSON> {
|
||||
const resp = await client.call(ContentScriptTypes.notarize, {
|
||||
url,
|
||||
method: requestOptions?.method,
|
||||
headers: requestOptions?.headers,
|
||||
body: requestOptions?.body,
|
||||
maxSentData: proofOptions?.maxSentData,
|
||||
maxRecvData: proofOptions?.maxRecvData,
|
||||
notaryUrl: proofOptions?.notaryUrl,
|
||||
websocketProxyUrl: proofOptions?.websocketProxyUrl,
|
||||
metadata: proofOptions?.metadata,
|
||||
});
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
async installPlugin(
|
||||
url: string,
|
||||
metadata?: { [k: string]: string },
|
||||
): Promise<string> {
|
||||
const resp = await client.call(ContentScriptTypes.install_plugin, {
|
||||
url,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
async getPlugins(
|
||||
url: string,
|
||||
origin?: string,
|
||||
metadata?: {
|
||||
[key: string]: string;
|
||||
},
|
||||
): Promise<(PluginConfig & { hash: string; metadata: PluginMetadata })[]> {
|
||||
const resp = await client.call(ContentScriptTypes.get_plugins, {
|
||||
url,
|
||||
origin,
|
||||
metadata,
|
||||
});
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
async runPlugin(hash: string, params?: Record<string, string>) {
|
||||
const resp = await client.call(ContentScriptTypes.run_plugin, {
|
||||
hash,
|
||||
params,
|
||||
});
|
||||
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const resp = await client.call(ContentScriptTypes.connect);
|
||||
|
||||
if (resp) {
|
||||
return new TLSN();
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
window.tlsn = {
|
||||
connect,
|
||||
};
|
||||
|
||||
window.dispatchEvent(new CustomEvent('tlsn_loaded'));
|
||||
@@ -1,239 +0,0 @@
|
||||
import browser, { browserAction } from 'webextension-polyfill';
|
||||
import { ContentScriptRequest, ContentScriptTypes, RPCServer } from './rpc';
|
||||
import { BackgroundActiontype, RequestHistory } from '../Background/rpc';
|
||||
import { urlify } from '../../utils/misc';
|
||||
|
||||
(async () => {
|
||||
loadScript('content.bundle.js');
|
||||
const server = new RPCServer();
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === BackgroundActiontype.get_local_storage) {
|
||||
chrome.runtime.sendMessage({
|
||||
type: BackgroundActiontype.set_local_storage,
|
||||
data: { ...localStorage },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === BackgroundActiontype.get_session_storage) {
|
||||
chrome.runtime.sendMessage({
|
||||
type: BackgroundActiontype.set_session_storage,
|
||||
data: { ...sessionStorage },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.on(ContentScriptTypes.connect, async () => {
|
||||
const connected = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.connect_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!connected) throw new Error('user rejected.');
|
||||
|
||||
return connected;
|
||||
});
|
||||
|
||||
server.on(
|
||||
ContentScriptTypes.get_history,
|
||||
async (
|
||||
request: ContentScriptRequest<{
|
||||
method: string;
|
||||
url: string;
|
||||
metadata?: { [k: string]: string };
|
||||
}>,
|
||||
) => {
|
||||
const {
|
||||
method: filterMethod,
|
||||
url: filterUrl,
|
||||
metadata,
|
||||
} = request.params || {};
|
||||
|
||||
if (!filterMethod || !filterUrl)
|
||||
throw new Error('params must include method and url.');
|
||||
|
||||
const response: RequestHistory[] = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.get_history_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
method: filterMethod,
|
||||
url: filterUrl,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
ContentScriptTypes.get_proof,
|
||||
async (request: ContentScriptRequest<{ id: string }>) => {
|
||||
const { id } = request.params || {};
|
||||
|
||||
if (!id) throw new Error('params must include id.');
|
||||
|
||||
const proof = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.get_proof_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
return proof;
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
ContentScriptTypes.notarize,
|
||||
async (
|
||||
request: ContentScriptRequest<{
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: { [key: string]: string };
|
||||
metadata?: { [key: string]: string };
|
||||
body?: string;
|
||||
notaryUrl?: string;
|
||||
websocketProxyUrl?: string;
|
||||
maxSentData?: number;
|
||||
maxRecvData?: number;
|
||||
}>,
|
||||
) => {
|
||||
const {
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
maxSentData,
|
||||
maxRecvData,
|
||||
notaryUrl,
|
||||
websocketProxyUrl,
|
||||
metadata,
|
||||
} = request.params || {};
|
||||
|
||||
if (!url || !urlify(url)) throw new Error('invalid url.');
|
||||
|
||||
const proof = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.notarize_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
maxSentData,
|
||||
maxRecvData,
|
||||
notaryUrl,
|
||||
websocketProxyUrl,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return proof;
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
ContentScriptTypes.install_plugin,
|
||||
async (
|
||||
request: ContentScriptRequest<{
|
||||
url: string;
|
||||
metadata?: { [k: string]: string };
|
||||
}>,
|
||||
) => {
|
||||
const { url, metadata } = request.params || {};
|
||||
|
||||
if (!url) throw new Error('params must include url.');
|
||||
|
||||
const response: RequestHistory[] = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.install_plugin_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
url,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
ContentScriptTypes.get_plugins,
|
||||
async (
|
||||
request: ContentScriptRequest<{
|
||||
url: string;
|
||||
origin?: string;
|
||||
metadata?: { [k: string]: string };
|
||||
}>,
|
||||
) => {
|
||||
const {
|
||||
url: filterUrl,
|
||||
origin: filterOrigin,
|
||||
metadata,
|
||||
} = request.params || {};
|
||||
|
||||
if (!filterUrl) throw new Error('params must include url.');
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.get_plugins_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
url: filterUrl,
|
||||
origin: filterOrigin,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
server.on(
|
||||
ContentScriptTypes.run_plugin,
|
||||
async (
|
||||
request: ContentScriptRequest<{
|
||||
hash: string;
|
||||
params?: Record<string, string>;
|
||||
}>,
|
||||
) => {
|
||||
const { hash, params } = request.params || {};
|
||||
|
||||
if (!hash) throw new Error('params must include hash');
|
||||
|
||||
const response = await browser.runtime.sendMessage({
|
||||
type: BackgroundActiontype.run_plugin_request,
|
||||
data: {
|
||||
...getPopupData(),
|
||||
hash,
|
||||
params,
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
);
|
||||
})();
|
||||
|
||||
function loadScript(filename: string) {
|
||||
const url = browser.runtime.getURL(filename);
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('type', 'text/javascript');
|
||||
script.setAttribute('src', url);
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
function getPopupData() {
|
||||
return {
|
||||
origin: window.origin,
|
||||
position: {
|
||||
left: window.screen.width / 2 - 240,
|
||||
top: window.screen.height / 2 - 300,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { deferredPromise, PromiseResolvers } from '../../utils/promise';
|
||||
|
||||
export enum ContentScriptTypes {
|
||||
connect = 'tlsn/cs/connect',
|
||||
get_history = 'tlsn/cs/get_history',
|
||||
get_proof = 'tlsn/cs/get_proof',
|
||||
notarize = 'tlsn/cs/notarize',
|
||||
install_plugin = 'tlsn/cs/install_plugin',
|
||||
get_plugins = 'tlsn/cs/get_plugins',
|
||||
run_plugin = 'tlsn/cs/run_plugin',
|
||||
}
|
||||
|
||||
export type ContentScriptRequest<params> = {
|
||||
tlsnrpc: string;
|
||||
} & RPCRequest<ContentScriptTypes, params>;
|
||||
|
||||
export type ContentScriptResponse = {
|
||||
tlsnrpc: string;
|
||||
} & RPCResponse;
|
||||
|
||||
export type RPCRequest<method, params> = {
|
||||
id: number;
|
||||
method: method;
|
||||
params?: params;
|
||||
};
|
||||
|
||||
export type RPCResponse = {
|
||||
id: number;
|
||||
result?: never;
|
||||
error?: never;
|
||||
};
|
||||
|
||||
export class RPCServer {
|
||||
#handlers: Map<
|
||||
ContentScriptTypes,
|
||||
(message: ContentScriptRequest<any>) => Promise<any>
|
||||
> = new Map();
|
||||
|
||||
constructor() {
|
||||
window.addEventListener(
|
||||
'message',
|
||||
async (event: MessageEvent<ContentScriptRequest<never>>) => {
|
||||
const data = event.data;
|
||||
|
||||
if (data.tlsnrpc !== '1.0') return;
|
||||
if (!data.method) return;
|
||||
|
||||
const handler = this.#handlers.get(data.method);
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
const result = await handler(data);
|
||||
window.postMessage({
|
||||
tlsnrpc: '1.0',
|
||||
id: data.id,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
window.postMessage({
|
||||
tlsnrpc: '1.0',
|
||||
id: data.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown method - ${data.method}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
on(
|
||||
method: ContentScriptTypes,
|
||||
handler: (message: ContentScriptRequest<any>) => Promise<any>,
|
||||
) {
|
||||
this.#handlers.set(method, handler);
|
||||
}
|
||||
}
|
||||
|
||||
export class RPCClient {
|
||||
#requests: Map<number, PromiseResolvers> = new Map();
|
||||
#id = 0;
|
||||
|
||||
get id() {
|
||||
return this.#id++;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
window.addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<ContentScriptResponse>) => {
|
||||
const data = event.data;
|
||||
|
||||
if (data.tlsnrpc !== '1.0') return;
|
||||
|
||||
const promise = this.#requests.get(data.id);
|
||||
|
||||
if (promise) {
|
||||
if (typeof data.result !== 'undefined') {
|
||||
promise.resolve(data.result);
|
||||
this.#requests.delete(data.id);
|
||||
} else if (typeof data.error !== 'undefined') {
|
||||
promise.reject(data.error);
|
||||
this.#requests.delete(data.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async call(method: ContentScriptTypes, params?: any): Promise<never> {
|
||||
const request = { tlsnrpc: '1.0', id: this.id, method, params };
|
||||
const defer = deferredPromise();
|
||||
this.#requests.set(request.id, defer);
|
||||
window.postMessage(request, '*');
|
||||
return defer.promise;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { OffscreenActionTypes } from './types';
|
||||
|
||||
import { BackgroundActiontype } from '../Background/rpc';
|
||||
import {
|
||||
initThreads,
|
||||
onCreatePresentationRequest,
|
||||
onCreateProverRequest,
|
||||
onNotarizationRequest,
|
||||
onProcessProveRequest,
|
||||
onVerifyProof,
|
||||
onVerifyProofRequest,
|
||||
startP2PProver,
|
||||
startP2PVerifier,
|
||||
} from './rpc';
|
||||
|
||||
const Offscreen = () => {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await initThreads();
|
||||
// @ts-ignore
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
switch (request.type) {
|
||||
case OffscreenActionTypes.notarization_request: {
|
||||
onNotarizationRequest(request);
|
||||
break;
|
||||
}
|
||||
case OffscreenActionTypes.create_prover_request: {
|
||||
onCreateProverRequest(request);
|
||||
break;
|
||||
}
|
||||
case OffscreenActionTypes.create_presentation_request: {
|
||||
onCreatePresentationRequest(request);
|
||||
break;
|
||||
}
|
||||
case BackgroundActiontype.process_prove_request: {
|
||||
onProcessProveRequest(request);
|
||||
break;
|
||||
}
|
||||
case BackgroundActiontype.verify_proof: {
|
||||
onVerifyProof(request, sendResponse);
|
||||
return true;
|
||||
}
|
||||
case BackgroundActiontype.verify_prove_request: {
|
||||
onVerifyProofRequest(request);
|
||||
break;
|
||||
}
|
||||
case OffscreenActionTypes.start_p2p_verifier: {
|
||||
startP2PVerifier(request);
|
||||
break;
|
||||
}
|
||||
case OffscreenActionTypes.start_p2p_prover: {
|
||||
startP2PProver(request);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return <div className="App" />;
|
||||
};
|
||||
|
||||
export default Offscreen;
|
||||
@@ -1,8 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Offscreen from './Offscreen';
|
||||
|
||||
const container = document.getElementById('app-container');
|
||||
const root = createRoot(container!);
|
||||
root.render(<Offscreen />);
|
||||