feat: Add Safari support and improve messaging system

This commit is contained in:
TrinityHades 2025-07-20 00:10:53 -07:00
parent 1c1cc9cfdf
commit b8a678d46c
16 changed files with 1659 additions and 931 deletions

77
SAFARI.md Normal file
View file

@ -0,0 +1,77 @@
# Safari Extension Development Guide
## Building for Safari
To build the extension specifically for Safari:
```bash
pnpm build:safari
```
## Development for Safari
For Safari development (without hot-reload to avoid WebSocket issues):
```bash
pnpm dev:safari
```
## Safari Installation & Testing
### 1. Load Extension in Safari
1. Build the extension: `pnpm build:safari`
2. Open Safari and go to Safari → Preferences → Advanced
3. Check "Show Develop menu in menu bar"
4. Go to Develop → Allow Unsigned Extensions (for development)
5. Go to Safari → Preferences → Extensions
6. Click the "+" button and select the `build/safari-mv3-prod` folder
### 2. Grant Permissions
Safari requires manual permission setup:
1. **Enable Extension**: Safari → Preferences → Extensions → Enable "P-Stream extension"
2. **Website Access**: Safari → Preferences → Websites → P-Stream extension → Set to "Allow on all websites"
3. **Reload Pages**: Refresh any streaming website where you want to use the extension
### 3. Common Safari Issues & Fixes
#### Issue: "Invalid call to runtime.connect()"
**Fix**: This occurs when the background script isn't ready. The extension now includes:
- Delayed messaging setup for Safari
- Retry logic for failed connections
- Better error handling
#### Issue: WebSocket Connection Blocked
**Fix**: Use `pnpm dev:safari` which disables hot-reload WebSockets that Safari blocks.
#### Issue: Permissions Not Working
**Fix**:
- Ensure both `host_permissions` and `optional_host_permissions` are in manifest
- Guide users through Safari's manual permission setup
- Check permissions through Safari Preferences, not runtime API
### 4. Safari-Specific Behavior
- **No Runtime Permission Requests**: Safari doesn't support `chrome.permissions.request()` - users must enable manually
- **Different Timing**: Safari background scripts load differently - we added delays and retry logic
- **Security Policies**: Safari blocks insecure content more aggressively than Chrome
- **Manifest Differences**: Safari requires `host_permissions` for proper website access
### 5. Testing Checklist
- [ ] Extension loads in Safari Extensions preferences
- [ ] Website permissions are granted in Safari Preferences → Websites
- [ ] No "Invalid call to runtime.connect()" errors
- [ ] No WebSocket connection warnings
- [ ] Permission guide shows correctly when Safari is detected
- [ ] All message handlers work properly
### 6. Production Deployment
For Safari App Store distribution:
1. You'll need to create a native macOS app wrapper
2. Use Xcode to create the Safari extension project
3. Follow Apple's Safari extension guidelines
4. The built extension in `build/safari-mv3-prod` can be embedded in the macOS app

12
manifest.safari.ts Normal file
View file

@ -0,0 +1,12 @@
const manifest = {
host_permissions: ['<all_urls>'],
permissions: ['storage', 'declarativeNetRequest', 'activeTab', 'cookies'],
web_accessible_resources: [
{
resources: ['assets/active.png', 'assets/inactive.png'],
matches: ['<all_urls>']
}
]
}
export default manifest

View file

@ -6,10 +6,13 @@
"author": "P-Stream", "author": "P-Stream",
"scripts": { "scripts": {
"dev": "plasmo dev", "dev": "plasmo dev",
"dev:safari": "plasmo dev --target=safari-mv3 --no-hmr",
"build": "plasmo build", "build": "plasmo build",
"build:firefox": "plasmo build --target=firefox-mv3", "build:firefox": "plasmo build --target=firefox-mv3",
"build:safari": "plasmo build --target=safari-mv3",
"package": "plasmo package", "package": "plasmo package",
"package:firefox": "plasmo package --target=firefox-mv3", "package:firefox": "plasmo package --target=firefox-mv3",
"package:safari": "plasmo package --target=safari-mv3",
"lint": "eslint --ext .tsx,.ts src", "lint": "eslint --ext .tsx,.ts src",
"lint:fix": "eslint --fix --ext .tsx,.ts src", "lint:fix": "eslint --fix --ext .tsx,.ts src",
"lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src", "lint:report": "eslint --ext .tsx,.ts --output-file eslint_report.json --format json src",
@ -18,7 +21,7 @@
"dependencies": { "dependencies": {
"@plasmohq/messaging": "^0.6.2", "@plasmohq/messaging": "^0.6.2",
"@plasmohq/storage": "^1.11.0", "@plasmohq/storage": "^1.11.0",
"plasmo": "0.89.4", "plasmo": "0.90.5",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0" "react-dom": "18.2.0"
}, },
@ -45,11 +48,15 @@
"permissions": [ "permissions": [
"declarativeNetRequest", "declarativeNetRequest",
"activeTab", "activeTab",
"cookies" "cookies",
"storage"
], ],
"optional_host_permissions": [ "optional_host_permissions": [
"<all_urls>" "<all_urls>"
], ],
"host_permissions": [
"<all_urls>"
],
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "{0c3fcdbd-5e0f-40d5-8f6c-d5eef8ff2b7c}" "id": "{0c3fcdbd-5e0f-40d5-8f6c-d5eef8ff2b7c}"

File diff suppressed because it is too large Load diff

6
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,6 @@
onlyBuiltDependencies:
- '@swc/core'
- esbuild
- lmdb
- msgpackr-extract
- sharp

24
safari.config.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "P-Stream Safari Extension",
"displayName": "P-Stream Safari Extension",
"description": "Enhance your streaming experience with just one click - Safari version",
"author": "P-Stream",
"version": "1.3.2",
"safari": {
"bundleIdentifier": "com.pstream.safari.extension",
"minimumVersion": "14.0",
"appName": "P-Stream Extension",
"developerIdentifier": "YOUR_TEAM_ID"
},
"permissions": {
"storage": true,
"declarativeNetRequest": true,
"activeTab": true,
"cookies": true,
"hostPermissions": ["<all_urls>"]
},
"background": {
"persistent": false,
"type": "module"
}
}

View file

@ -1,13 +1,56 @@
import { isChrome } from '~utils/extension'; import '@plasmohq/messaging/background'
// Both brave and firefox for some reason need this extension reload, import { getBrowserAPI, isChrome, isFirefox, isSafari } from '~utils/extension'
// If this isn't done, they will never load properly and will fail updateDynamicRules()
if (isChrome()) { // Initialize Plasmo messaging system
// This ensures that the messaging handlers are properly set up
console.log('Background script loaded')
const browserAPI = getBrowserAPI()
// Safari has different startup behavior, so we need to handle it differently
if (isSafari()) {
// Safari doesn't need the reload behavior that Chrome/Firefox require
console.log('Running on Safari')
// Ensure messaging is ready for Safari
browserAPI.runtime.onInstalled.addListener(() => {
console.log('Safari extension installed/updated')
// Give Safari time to initialize messaging
setTimeout(() => {
console.log('Safari messaging should be ready')
}, 1000)
})
// Handle Safari runtime errors more gracefully
browserAPI.runtime.onConnect.addListener(port => {
console.log('Safari port connected:', port.name)
port.onDisconnect.addListener(() => {
if (browserAPI.runtime.lastError) {
console.log(
'Safari port disconnected with error:',
browserAPI.runtime.lastError.message
)
}
})
})
} else if (isChrome()) {
// Both brave and chrome for some reason need this extension reload,
// If this isn't done, they will never load properly and will fail updateDynamicRules()
chrome.runtime.onStartup.addListener(() => { chrome.runtime.onStartup.addListener(() => {
chrome.runtime.reload(); chrome.runtime.reload()
}); })
} else {
chrome.runtime.onInstalled.addListener(() => {
console.log('Chrome extension installed/updated')
})
} else if (isFirefox()) {
// Firefox behavior
browser.runtime.onStartup.addListener(() => { browser.runtime.onStartup.addListener(() => {
browser.runtime.reload(); browser.runtime.reload()
}); })
browser.runtime.onInstalled.addListener(() => {
console.log('Firefox extension installed/updated')
})
} }

View file

@ -1,60 +1,70 @@
import type { PlasmoMessaging } from '@plasmohq/messaging'; import type { PlasmoMessaging } from '@plasmohq/messaging'
import type { BaseRequest } from '~types/request'; import type { BaseRequest } from '~types/request'
import type { BaseResponse } from '~types/response'; import type { BaseResponse } from '~types/response'
import { removeDynamicRules, setDynamicRules } from '~utils/declarativeNetRequest'; import {
import { isFirefox } from '~utils/extension'; removeDynamicRules,
import { makeFullUrl } from '~utils/fetcher'; setDynamicRules
import { assertDomainWhitelist, canAccessCookies } from '~utils/storage'; } from '~utils/declarativeNetRequest'
import { getBrowserAPI, isFirefox, isSafari } from '~utils/extension'
import { makeFullUrl } from '~utils/fetcher'
import { assertDomainWhitelist, canAccessCookies } from '~utils/storage'
const MAKE_REQUEST_DYNAMIC_RULE = 23498; const MAKE_REQUEST_DYNAMIC_RULE = 23498
export interface Request extends BaseRequest { export interface Request extends BaseRequest {
baseUrl?: string; baseUrl?: string
headers?: Record<string, string>; headers?: Record<string, string>
method?: string; method?: string
query?: Record<string, string>; query?: Record<string, string>
readHeaders?: Record<string, string>; readHeaders?: Record<string, string>
url: string; url: string
body?: any; body?: any
bodyType?: 'string' | 'FormData' | 'URLSearchParams' | 'object'; bodyType?: 'string' | 'FormData' | 'URLSearchParams' | 'object'
} }
type Response<T> = BaseResponse<{ type Response<T> = BaseResponse<{
response: { response: {
statusCode: number; statusCode: number
headers: Record<string, string>; headers: Record<string, string>
finalUrl: string; finalUrl: string
body: T; body: T
}; }
}>; }>
const mapBodyToFetchBody = (body: Request['body'], bodyType: Request['bodyType']): BodyInit => { const mapBodyToFetchBody = (
body: Request['body'],
bodyType: Request['bodyType']
): BodyInit => {
if (bodyType === 'FormData') { if (bodyType === 'FormData') {
const formData = new FormData(); const formData = new FormData()
body.forEach(([key, value]: [any, any]) => { body.forEach(([key, value]: [any, any]) => {
formData.append(key, value.toString()); formData.append(key, value.toString())
}); })
} }
if (bodyType === 'URLSearchParams') { if (bodyType === 'URLSearchParams') {
return new URLSearchParams(body); return new URLSearchParams(body)
} }
if (bodyType === 'object') { if (bodyType === 'object') {
return JSON.stringify(body); return JSON.stringify(body)
} }
if (bodyType === 'string') { if (bodyType === 'string') {
return body; return body
} }
return body; return body
}; }
const handler: PlasmoMessaging.MessageHandler<Request, Response<any>> = async (req, res) => { const handler: PlasmoMessaging.MessageHandler<Request, Response<any>> = async (
req,
res
) => {
try { try {
if (!req.sender?.tab?.url) throw new Error('No tab URL found in the request.'); if (!req.sender?.tab?.url)
if (!req.body) throw new Error('No request body found in the request.'); throw new Error('No tab URL found in the request.')
if (!req.body) throw new Error('No request body found in the request.')
const url = makeFullUrl(req.body.url, req.body); const url = makeFullUrl(req.body.url, req.body)
await assertDomainWhitelist(req.sender.tab.url); await assertDomainWhitelist(req.sender.tab.url)
await setDynamicRules({ await setDynamicRules({
ruleId: MAKE_REQUEST_DYNAMIC_RULE, ruleId: MAKE_REQUEST_DYNAMIC_RULE,
@ -63,26 +73,28 @@ const handler: PlasmoMessaging.MessageHandler<Request, Response<any>> = async (r
// set Access-Control-Allow-Credentials if the reqested host has access to cookies // set Access-Control-Allow-Credentials if the reqested host has access to cookies
responseHeaders: { responseHeaders: {
...(canAccessCookies(new URL(url).hostname) && { ...(canAccessCookies(new URL(url).hostname) && {
'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Credentials': 'true'
}), })
}, }
}); })
const response = await fetch(url, { const response = await fetch(url, {
method: req.body.method, method: req.body.method,
headers: req.body.headers, headers: req.body.headers,
body: mapBodyToFetchBody(req.body.body, req.body.bodyType), body: mapBodyToFetchBody(req.body.body, req.body.bodyType)
}); })
await removeDynamicRules([MAKE_REQUEST_DYNAMIC_RULE]); await removeDynamicRules([MAKE_REQUEST_DYNAMIC_RULE])
const contentType = response.headers.get('content-type'); const contentType = response.headers.get('content-type')
const body = contentType?.includes('application/json') ? await response.json() : await response.text(); const body = contentType?.includes('application/json')
? await response.json()
: await response.text()
const cookies = await (chrome || browser).cookies.getAll({ const cookies = await (chrome || browser).cookies.getAll({
url: response.url, url: response.url,
...(isFirefox() && { ...(isFirefox() && {
firstPartyDomain: new URL(response.url).hostname, firstPartyDomain: new URL(response.url).hostname
}), })
}); })
res.send({ res.send({
success: true, success: true,
@ -92,20 +104,22 @@ const handler: PlasmoMessaging.MessageHandler<Request, Response<any>> = async (r
...Object.fromEntries(response.headers.entries()), ...Object.fromEntries(response.headers.entries()),
// include cookies if allowed for the reqested host // include cookies if allowed for the reqested host
...(canAccessCookies(new URL(url).hostname) && { ...(canAccessCookies(new URL(url).hostname) && {
'Set-Cookie': cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join(', '), 'Set-Cookie': cookies
}), .map(cookie => `${cookie.name}=${cookie.value}`)
.join(', ')
})
}, },
body, body,
finalUrl: response.url, finalUrl: response.url
}, }
}); })
} catch (err) { } catch (err) {
console.error('failed request', err); console.error('failed request', err)
res.send({ res.send({
success: false, success: false,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err)
}); })
} }
}; }
export default handler; export default handler

View file

@ -1,8 +1,8 @@
import type { PlasmoMessaging } from '@plasmohq/messaging'; import type { PlasmoMessaging } from '@plasmohq/messaging'
import type { BaseRequest } from '~types/request'; import type { BaseRequest } from '~types/request'
import type { BaseResponse } from '~types/response'; import type { BaseResponse } from '~types/response'
import { isChrome } from '~utils/extension'; import { getBrowserAPI } from '~utils/extension'
type Request = BaseRequest & { type Request = BaseRequest & {
page: string; page: string;
@ -14,17 +14,16 @@ const handler: PlasmoMessaging.MessageHandler<Request, BaseResponse> = async (re
if (!req.sender?.tab?.id) throw new Error('No tab ID found in the request.'); if (!req.sender?.tab?.id) throw new Error('No tab ID found in the request.');
if (!req.body) throw new Error('No body found in the request.'); if (!req.body) throw new Error('No body found in the request.');
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams()
searchParams.set('redirectUrl', req.body.redirectUrl); searchParams.set('redirectUrl', req.body.redirectUrl)
const url = (chrome || browser).runtime.getURL(`/tabs/${req.body.page}.html?${searchParams.toString()}`);
if (isChrome()) { const browserAPI = getBrowserAPI()
await chrome.tabs.update(req.sender.tab.id, { const url = browserAPI.runtime.getURL(
url, `/tabs/${req.body.page}.html?${searchParams.toString()}`
}); )
} else { await (browserAPI.tabs as any).update(req.sender.tab.id, {
await browser.tabs.update(req.sender.tab.id, { url }); url
} });
res.send({ res.send({
success: true, success: true,
@ -35,6 +34,6 @@ const handler: PlasmoMessaging.MessageHandler<Request, BaseResponse> = async (re
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
} }
}; }
export default handler; export default handler;

View file

@ -0,0 +1,101 @@
import { useCallback } from 'react';
import { Button } from '~components/Button';
import { Icon } from '~components/Icon';
import { isSafari } from '~utils/extension';
import { usePermission } from '~hooks/usePermission';
import '../tabs/PermissionRequest.css';
function Card(props: { purple?: boolean; children: React.ReactNode; icon?: React.ReactNode; right?: React.ReactNode }) {
return (
<div className={['card', props.purple ? 'purple' : ''].join(' ')}>
<div>
<div className="icon-circle">{props.icon}</div>
</div>
<div>{props.children}</div>
{props.right ? <div className="center-y">{props.right}</div> : null}
</div>
);
}
export default function SafariPermissionGuide() {
const { grantPermission } = usePermission();
const handleSafariSetup = useCallback(() => {
if (isSafari()) {
grantPermission().then(() => {
// Check if permissions were granted
setTimeout(() => {
window.location.reload();
}, 2000);
});
}
}, [grantPermission]);
if (!isSafari()) {
return null;
}
return (
<div className="container permission-request">
<div className="inner-container">
<h1 className="color-white">
Safari Setup Required
</h1>
<p className="text-color paragraph">
Safari extensions require manual setup. Please follow these steps to enable the P-Stream extension:
</p>
<div className="card-list" style={{ marginTop: '2.5rem' }}>
<Card
icon={<Icon name="shield" />}
purple
>
<div>
<h3 className="card-title">Step 1: Enable Extension</h3>
<p className="card-description">
Open Safari Preferences Extensions, then enable "P-Stream extension"
</p>
</div>
</Card>
<Card
icon={<Icon name="network" />}
>
<div>
<h3 className="card-title">Step 2: Grant Website Access</h3>
<p className="card-description">
Go to Safari Preferences Websites P-Stream extension Set to "Allow on all websites" or add specific sites
</p>
</div>
</Card>
<Card
icon={<Icon name="power" />}
>
<div>
<h3 className="card-title">Step 3: Reload Pages</h3>
<p className="card-description">
Reload any streaming website pages where you want to use the extension
</p>
</div>
</Card>
</div>
<div className="button-spacing">
<Button onClick={handleSafariSetup}>
Test Extension Setup
</Button>
</div>
<div style={{ marginTop: '1.5rem' }}>
<p className="text-color paragraph" style={{ fontSize: '0.9rem' }}>
<strong>Note:</strong> Safari handles extension permissions differently than Chrome.
These steps are required for the extension to work properly with streaming websites.
</p>
</div>
</div>
</div>
);
}

View file

@ -1,22 +1,49 @@
import { relayMessage } from '@plasmohq/messaging'; import { relayMessage } from '@plasmohq/messaging'
import type { PlasmoCSConfig } from 'plasmo'; import type { PlasmoCSConfig } from 'plasmo'
export const config: PlasmoCSConfig = { export const config: PlasmoCSConfig = {
matches: ['<all_urls>'], matches: ['<all_urls>']
}; }
relayMessage({ // Safari requires a delay before setting up messaging
name: 'hello', const isSafari = () => {
}); try {
return (
chrome.runtime.getURL('').startsWith('safari-web-extension://') ||
(typeof browser !== 'undefined' &&
browser.runtime.getURL('').startsWith('safari-web-extension://'))
)
} catch {
return false
}
}
relayMessage({ const setupMessaging = () => {
name: 'makeRequest', try {
}); relayMessage({
name: 'hello'
})
relayMessage({ relayMessage({
name: 'prepareStream', name: 'makeRequest'
}); })
relayMessage({ relayMessage({
name: 'openPage', name: 'prepareStream'
}); })
relayMessage({
name: 'openPage'
})
} catch (error) {
console.log('Failed to setup messaging, retrying...', error)
setTimeout(setupMessaging, 1000)
}
}
// Safari needs a delay to ensure background script is ready
if (isSafari()) {
setTimeout(setupMessaging, 500)
} else {
setupMessaging()
}

View file

@ -1,32 +1,81 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react'
import { useDomainWhitelist } from './useDomainWhitelist'; import { getBrowserAPI, isSafari } from '~utils/extension'
import { useDomainWhitelist } from './useDomainWhitelist'
export async function hasPermission() { export async function hasPermission () {
return chrome.permissions.contains({ const browserAPI = getBrowserAPI()
origins: ['<all_urls>'],
}); if (isSafari()) {
try {
const tabs = await (browserAPI.tabs as any).query({
active: true,
currentWindow: true
})
return tabs && tabs.length > 0
} catch (error) {
console.log('Safari permission check failed:', error)
return false
}
}
try {
return await (browserAPI.permissions as any).contains({
origins: ['<all_urls>']
})
} catch (error) {
console.log('Permission check failed:', error)
return false
}
} }
export function usePermission() { export function usePermission () {
const { addDomain } = useDomainWhitelist(); const { addDomain } = useDomainWhitelist()
const [permission, setPermission] = useState(false); const [permission, setPermission] = useState(false)
const grantPermission = useCallback(async (domain?: string) => { const grantPermission = useCallback(
const granted = await chrome.permissions.request({ async (domain?: string) => {
origins: ['<all_urls>'], const browserAPI = getBrowserAPI()
});
setPermission(granted); if (isSafari()) {
if (granted && domain) addDomain(domain); const hasPerms = await hasPermission()
return granted; if (!hasPerms) {
}, []); alert(
'To use this extension, please:\n\n' +
'1. Open Safari Preferences\n' +
'2. Go to the Websites tab\n' +
'3. Find "P-Stream extension" in the left sidebar\n' +
'4. Set it to "Allow" for the websites you want to use\n\n' +
'You may also need to enable the extension in Safari > Preferences > Extensions'
)
return false
}
setPermission(true)
if (domain) addDomain(domain)
return true
}
try {
const granted = await (browserAPI.permissions as any).request({
origins: ['<all_urls>']
})
setPermission(granted)
if (granted && domain) addDomain(domain)
return granted
} catch (error) {
console.log('Permission request failed:', error)
return false
}
},
[addDomain]
)
useEffect(() => { useEffect(() => {
hasPermission().then((has) => setPermission(has)); hasPermission().then(has => setPermission(has))
}, []); }, [])
return { return {
hasPermission: permission, hasPermission: permission,
grantPermission, grantPermission
}; }
} }

View file

@ -2,7 +2,9 @@ import { useCallback } from 'react';
import { Button } from '~components/Button'; import { Button } from '~components/Button';
import { Icon } from '~components/Icon'; import { Icon } from '~components/Icon';
import SafariPermissionGuide from '~components/SafariPermissionGuide';
import { usePermission } from '~hooks/usePermission'; import { usePermission } from '~hooks/usePermission';
import { isSafari } from '~utils/extension';
import './PermissionRequest.css'; import './PermissionRequest.css';
@ -25,6 +27,10 @@ export default function PermissionRequest() {
grantPermission().then(() => window.close()); grantPermission().then(() => window.close());
}, [grantPermission]); }, [grantPermission]);
if (isSafari()) {
return <SafariPermissionGuide />;
}
return ( return (
<div className="container permission-request"> <div className="container permission-request">
<div className="inner-container"> <div className="inner-container">

View file

@ -1,119 +1,134 @@
import { isChrome } from './extension'; import { getBrowserAPI, isChrome, isSafari } from './extension'
import { modifiableResponseHeaders } from './storage'; import { modifiableResponseHeaders } from './storage'
interface DynamicRule { interface DynamicRule {
ruleId: number; ruleId: number
targetDomains?: [string, ...string[]]; targetDomains?: [string, ...string[]]
targetRegex?: string; targetRegex?: string
requestHeaders?: Record<string, string>; requestHeaders?: Record<string, string>
responseHeaders?: Record<string, string>; responseHeaders?: Record<string, string>
} }
const mapHeadersToDeclarativeNetRequestHeaders = ( const mapHeadersToDeclarativeNetRequestHeaders = (
headers: Record<string, string>, headers: Record<string, string>,
op: string, op: string
): { header: string; operation: any; value: string }[] => { ): { header: string; operation: any; value: string }[] => {
return Object.entries(headers).map(([name, value]) => ({ return Object.entries(headers).map(([name, value]) => ({
header: name, header: name,
operation: op, operation: op,
value, value
})); }))
}; }
export const setDynamicRules = async (body: DynamicRule) => { export const setDynamicRules = async (body: DynamicRule) => {
if (isChrome()) { const browserAPI = getBrowserAPI()
await chrome.declarativeNetRequest.updateDynamicRules({
if (isChrome() || isSafari()) {
await (browserAPI.declarativeNetRequest as any).updateDynamicRules({
removeRuleIds: [body.ruleId], removeRuleIds: [body.ruleId],
addRules: [ addRules: [
{ {
id: body.ruleId, id: body.ruleId,
condition: { condition: {
...(body.targetDomains && { requestDomains: body.targetDomains }), ...(body.targetDomains && { requestDomains: body.targetDomains }),
...(body.targetRegex && { regexFilter: body.targetRegex }), ...(body.targetRegex && { regexFilter: body.targetRegex })
},
action: {
type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
...(body.requestHeaders && Object.keys(body.requestHeaders).length > 0
? {
requestHeaders: mapHeadersToDeclarativeNetRequestHeaders(
body.requestHeaders,
chrome.declarativeNetRequest.HeaderOperation.SET,
),
}
: {}),
responseHeaders: [
{
header: 'Access-Control-Allow-Origin',
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
value: '*',
},
{
header: 'Access-Control-Allow-Methods',
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
value: 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
},
{
header: 'Access-Control-Allow-Headers',
operation: chrome.declarativeNetRequest.HeaderOperation.SET,
value: '*',
},
...mapHeadersToDeclarativeNetRequestHeaders(
body.responseHeaders ?? {},
chrome.declarativeNetRequest.HeaderOperation.SET,
),
],
},
},
],
});
if (chrome.runtime.lastError?.message) throw new Error(chrome.runtime.lastError.message);
} else {
await browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [body.ruleId],
addRules: [
{
id: body.ruleId,
condition: {
...(body.targetDomains && { requestDomains: body.targetDomains }),
...(body.targetRegex && { regexFilter: body.targetRegex }),
}, },
action: { action: {
type: 'modifyHeaders', type: 'modifyHeaders',
...(body.requestHeaders && Object.keys(body.requestHeaders).length > 0 ...(body.requestHeaders &&
Object.keys(body.requestHeaders).length > 0
? { ? {
requestHeaders: mapHeadersToDeclarativeNetRequestHeaders(body.requestHeaders, 'set'), requestHeaders: mapHeadersToDeclarativeNetRequestHeaders(
body.requestHeaders,
'set'
)
} }
: {}), : {}),
responseHeaders: [ responseHeaders: [
{ {
header: 'Access-Control-Allow-Origin', header: 'Access-Control-Allow-Origin',
operation: 'set', operation: 'set',
value: '*', value: '*'
}, },
{ {
header: 'Access-Control-Allow-Methods', header: 'Access-Control-Allow-Methods',
operation: 'set', operation: 'set',
value: 'GET, POST, PUT, DELETE, PATCH, OPTIONS', value: 'GET, POST, PUT, DELETE, PATCH, OPTIONS'
}, },
{ {
header: 'Access-Control-Allow-Headers', header: 'Access-Control-Allow-Headers',
operation: 'set', operation: 'set',
value: '*', value: '*'
}, },
...mapHeadersToDeclarativeNetRequestHeaders(body.responseHeaders ?? {}, 'set'), ...mapHeadersToDeclarativeNetRequestHeaders(
], body.responseHeaders ?? {},
'set'
)
]
}
}
]
})
} else {
// Firefox
await (browserAPI.declarativeNetRequest as any).updateDynamicRules({
removeRuleIds: [body.ruleId],
addRules: [
{
id: body.ruleId,
condition: {
...(body.targetDomains && { requestDomains: body.targetDomains }),
...(body.targetRegex && { regexFilter: body.targetRegex })
}, },
}, action: {
], type: 'modifyHeaders',
}); ...(body.requestHeaders &&
if (browser.runtime.lastError?.message) throw new Error(browser.runtime.lastError.message); Object.keys(body.requestHeaders).length > 0
? {
requestHeaders: mapHeadersToDeclarativeNetRequestHeaders(
body.requestHeaders,
'set'
)
}
: {}),
responseHeaders: [
{
header: 'Access-Control-Allow-Origin',
operation: 'set',
value: '*'
},
{
header: 'Access-Control-Allow-Methods',
operation: 'set',
value: 'GET, POST, PUT, DELETE, PATCH, OPTIONS'
},
{
header: 'Access-Control-Allow-Headers',
operation: 'set',
value: '*'
},
...mapHeadersToDeclarativeNetRequestHeaders(
body.responseHeaders ?? {},
'set'
)
]
}
}
]
})
} }
};
if (browserAPI.runtime.lastError?.message) {
throw new Error(browserAPI.runtime.lastError.message)
}
}
export const removeDynamicRules = async (ruleIds: number[]) => { export const removeDynamicRules = async (ruleIds: number[]) => {
await (chrome || browser).declarativeNetRequest.updateDynamicRules({ const browserAPI = getBrowserAPI()
removeRuleIds: ruleIds, await (browserAPI.declarativeNetRequest as any).updateDynamicRules({
}); removeRuleIds: ruleIds
if ((chrome || browser).runtime.lastError?.message) })
throw new Error((chrome || browser).runtime.lastError?.message ?? 'Unknown error'); if (browserAPI.runtime.lastError?.message) {
}; throw new Error(browserAPI.runtime.lastError?.message ?? 'Unknown error')
}
}

View file

@ -1,11 +1,32 @@
export const isChrome = () => { export const isChrome = () => {
return chrome.runtime.getURL('').startsWith('chrome-extension://'); return chrome.runtime.getURL('').startsWith('chrome-extension://')
}; }
export const isFirefox = () => { export const isFirefox = () => {
try { try {
return browser.runtime.getURL('').startsWith('moz-extension://'); return browser.runtime.getURL('').startsWith('moz-extension://')
} catch { } catch {
return false; return false
} }
}; }
export const isSafari = () => {
try {
return (
chrome.runtime.getURL('').startsWith('safari-web-extension://') ||
browser.runtime.getURL('').startsWith('safari-web-extension://')
)
} catch {
return false
}
}
export const getBrowserAPI = () => {
if (isSafari()) {
return typeof browser !== 'undefined' ? browser : chrome
}
if (isFirefox()) {
return browser
}
return chrome
}

View file

@ -1,32 +1,26 @@
import { isChrome } from './extension'; import { getBrowserAPI, isChrome, isSafari } from './extension'
export function queryCurrentDomain(cb: (domain: string | null) => void) { export function queryCurrentDomain (cb: (domain: string | null) => void) {
const handle = (tabUrl: string | undefined) => { const handle = (tabUrl: string | undefined) => {
if (!tabUrl) cb(null); if (!tabUrl) cb(null)
else cb(tabUrl); else cb(tabUrl)
};
const ops = { active: true, currentWindow: true } as const;
if (isChrome()) chrome.tabs.query(ops).then((tabs) => handle(tabs[0]?.url));
else browser.tabs.query(ops).then((tabs) => handle(tabs[0]?.url));
}
export function listenToTabChanges(cb: () => void) {
if (isChrome()) {
chrome.tabs.onActivated.addListener(cb);
chrome.tabs.onUpdated.addListener(cb);
} else if (browser) {
browser.tabs.onActivated.addListener(cb);
browser.tabs.onUpdated.addListener(cb);
} }
const ops = { active: true, currentWindow: true } as const
const browserAPI = getBrowserAPI()
;(browserAPI.tabs as any)
.query(ops)
.then((tabs: any[]) => handle(tabs[0]?.url))
} }
export function stopListenToTabChanges(cb: () => void) { export function listenToTabChanges (cb: () => void) {
if (isChrome()) { const browserAPI = getBrowserAPI()
chrome.tabs.onActivated.removeListener(cb); ;(browserAPI.tabs as any).onActivated.addListener(cb)
chrome.tabs.onUpdated.removeListener(cb); ;(browserAPI.tabs as any).onUpdated.addListener(cb)
} else if (browser) { }
browser.tabs.onActivated.removeListener(cb);
browser.tabs.onUpdated.removeListener(cb); export function stopListenToTabChanges (cb: () => void) {
} const browserAPI = getBrowserAPI()
;(browserAPI.tabs as any).onActivated.removeListener(cb)
;(browserAPI.tabs as any).onUpdated.removeListener(cb)
} }