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",
"scripts": {
"dev": "plasmo dev",
"dev:safari": "plasmo dev --target=safari-mv3 --no-hmr",
"build": "plasmo build",
"build:firefox": "plasmo build --target=firefox-mv3",
"build:safari": "plasmo build --target=safari-mv3",
"package": "plasmo package",
"package:firefox": "plasmo package --target=firefox-mv3",
"package:safari": "plasmo package --target=safari-mv3",
"lint": "eslint --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",
@ -18,7 +21,7 @@
"dependencies": {
"@plasmohq/messaging": "^0.6.2",
"@plasmohq/storage": "^1.11.0",
"plasmo": "0.89.4",
"plasmo": "0.90.5",
"react": "18.2.0",
"react-dom": "18.2.0"
},
@ -45,11 +48,15 @@
"permissions": [
"declarativeNetRequest",
"activeTab",
"cookies"
"cookies",
"storage"
],
"optional_host_permissions": [
"<all_urls>"
],
"host_permissions": [
"<all_urls>"
],
"browser_specific_settings": {
"gecko": {
"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,
// If this isn't done, they will never load properly and will fail updateDynamicRules()
if (isChrome()) {
import { getBrowserAPI, isChrome, isFirefox, isSafari } from '~utils/extension'
// 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.reload();
});
} else {
chrome.runtime.reload()
})
chrome.runtime.onInstalled.addListener(() => {
console.log('Chrome extension installed/updated')
})
} else if (isFirefox()) {
// Firefox behavior
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 { BaseResponse } from '~types/response';
import { removeDynamicRules, setDynamicRules } from '~utils/declarativeNetRequest';
import { isFirefox } from '~utils/extension';
import { makeFullUrl } from '~utils/fetcher';
import { assertDomainWhitelist, canAccessCookies } from '~utils/storage';
import type { BaseRequest } from '~types/request'
import type { BaseResponse } from '~types/response'
import {
removeDynamicRules,
setDynamicRules
} 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 {
baseUrl?: string;
headers?: Record<string, string>;
method?: string;
query?: Record<string, string>;
readHeaders?: Record<string, string>;
url: string;
body?: any;
bodyType?: 'string' | 'FormData' | 'URLSearchParams' | 'object';
baseUrl?: string
headers?: Record<string, string>
method?: string
query?: Record<string, string>
readHeaders?: Record<string, string>
url: string
body?: any
bodyType?: 'string' | 'FormData' | 'URLSearchParams' | 'object'
}
type Response<T> = BaseResponse<{
response: {
statusCode: number;
headers: Record<string, string>;
finalUrl: string;
body: T;
};
}>;
statusCode: number
headers: Record<string, string>
finalUrl: string
body: T
}
}>
const mapBodyToFetchBody = (body: Request['body'], bodyType: Request['bodyType']): BodyInit => {
const mapBodyToFetchBody = (
body: Request['body'],
bodyType: Request['bodyType']
): BodyInit => {
if (bodyType === 'FormData') {
const formData = new FormData();
const formData = new FormData()
body.forEach(([key, value]: [any, any]) => {
formData.append(key, value.toString());
});
formData.append(key, value.toString())
})
}
if (bodyType === 'URLSearchParams') {
return new URLSearchParams(body);
return new URLSearchParams(body)
}
if (bodyType === 'object') {
return JSON.stringify(body);
return JSON.stringify(body)
}
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 {
if (!req.sender?.tab?.url) throw new Error('No tab URL found in the request.');
if (!req.body) throw new Error('No request body found in the request.');
if (!req.sender?.tab?.url)
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);
await assertDomainWhitelist(req.sender.tab.url);
const url = makeFullUrl(req.body.url, req.body)
await assertDomainWhitelist(req.sender.tab.url)
await setDynamicRules({
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
responseHeaders: {
...(canAccessCookies(new URL(url).hostname) && {
'Access-Control-Allow-Credentials': 'true',
}),
},
});
'Access-Control-Allow-Credentials': 'true'
})
}
})
const response = await fetch(url, {
method: req.body.method,
headers: req.body.headers,
body: mapBodyToFetchBody(req.body.body, req.body.bodyType),
});
await removeDynamicRules([MAKE_REQUEST_DYNAMIC_RULE]);
const contentType = response.headers.get('content-type');
const body = contentType?.includes('application/json') ? await response.json() : await response.text();
body: mapBodyToFetchBody(req.body.body, req.body.bodyType)
})
await removeDynamicRules([MAKE_REQUEST_DYNAMIC_RULE])
const contentType = response.headers.get('content-type')
const body = contentType?.includes('application/json')
? await response.json()
: await response.text()
const cookies = await (chrome || browser).cookies.getAll({
url: response.url,
...(isFirefox() && {
firstPartyDomain: new URL(response.url).hostname,
}),
});
firstPartyDomain: new URL(response.url).hostname
})
})
res.send({
success: true,
@ -92,20 +104,22 @@ const handler: PlasmoMessaging.MessageHandler<Request, Response<any>> = async (r
...Object.fromEntries(response.headers.entries()),
// include cookies if allowed for the reqested host
...(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,
finalUrl: response.url,
},
});
finalUrl: response.url
}
})
} catch (err) {
console.error('failed request', err);
console.error('failed request', err)
res.send({
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 { BaseResponse } from '~types/response';
import { isChrome } from '~utils/extension';
import type { BaseRequest } from '~types/request'
import type { BaseResponse } from '~types/response'
import { getBrowserAPI } from '~utils/extension'
type Request = BaseRequest & {
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.body) throw new Error('No body found in the request.');
const searchParams = new URLSearchParams();
searchParams.set('redirectUrl', req.body.redirectUrl);
const url = (chrome || browser).runtime.getURL(`/tabs/${req.body.page}.html?${searchParams.toString()}`);
const searchParams = new URLSearchParams()
searchParams.set('redirectUrl', req.body.redirectUrl)
if (isChrome()) {
await chrome.tabs.update(req.sender.tab.id, {
url,
});
} else {
await browser.tabs.update(req.sender.tab.id, { url });
}
const browserAPI = getBrowserAPI()
const url = browserAPI.runtime.getURL(
`/tabs/${req.body.page}.html?${searchParams.toString()}`
)
await (browserAPI.tabs as any).update(req.sender.tab.id, {
url
});
res.send({
success: true,
@ -35,6 +34,6 @@ const handler: PlasmoMessaging.MessageHandler<Request, BaseResponse> = async (re
error: err instanceof Error ? err.message : String(err),
});
}
};
}
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 type { PlasmoCSConfig } from 'plasmo';
import { relayMessage } from '@plasmohq/messaging'
import type { PlasmoCSConfig } from 'plasmo'
export const config: PlasmoCSConfig = {
matches: ['<all_urls>'],
};
matches: ['<all_urls>']
}
relayMessage({
name: 'hello',
});
// Safari requires a delay before setting up messaging
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({
name: 'makeRequest',
});
const setupMessaging = () => {
try {
relayMessage({
name: 'hello'
})
relayMessage({
name: 'prepareStream',
});
relayMessage({
name: 'makeRequest'
})
relayMessage({
name: 'openPage',
});
relayMessage({
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() {
return chrome.permissions.contains({
origins: ['<all_urls>'],
});
export async function hasPermission () {
const browserAPI = getBrowserAPI()
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() {
const { addDomain } = useDomainWhitelist();
const [permission, setPermission] = useState(false);
export function usePermission () {
const { addDomain } = useDomainWhitelist()
const [permission, setPermission] = useState(false)
const grantPermission = useCallback(async (domain?: string) => {
const granted = await chrome.permissions.request({
origins: ['<all_urls>'],
});
setPermission(granted);
if (granted && domain) addDomain(domain);
return granted;
}, []);
const grantPermission = useCallback(
async (domain?: string) => {
const browserAPI = getBrowserAPI()
if (isSafari()) {
const hasPerms = await hasPermission()
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(() => {
hasPermission().then((has) => setPermission(has));
}, []);
hasPermission().then(has => setPermission(has))
}, [])
return {
hasPermission: permission,
grantPermission,
};
grantPermission
}
}

View file

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

View file

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

View file

@ -1,11 +1,32 @@
export const isChrome = () => {
return chrome.runtime.getURL('').startsWith('chrome-extension://');
};
return chrome.runtime.getURL('').startsWith('chrome-extension://')
}
export const isFirefox = () => {
try {
return browser.runtime.getURL('').startsWith('moz-extension://');
return browser.runtime.getURL('').startsWith('moz-extension://')
} 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) => {
if (!tabUrl) cb(null);
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);
if (!tabUrl) cb(null)
else cb(tabUrl)
}
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) {
if (isChrome()) {
chrome.tabs.onActivated.removeListener(cb);
chrome.tabs.onUpdated.removeListener(cb);
} else if (browser) {
browser.tabs.onActivated.removeListener(cb);
browser.tabs.onUpdated.removeListener(cb);
}
export function listenToTabChanges (cb: () => void) {
const browserAPI = getBrowserAPI()
;(browserAPI.tabs as any).onActivated.addListener(cb)
;(browserAPI.tabs as any).onUpdated.addListener(cb)
}
export function stopListenToTabChanges (cb: () => void) {
const browserAPI = getBrowserAPI()
;(browserAPI.tabs as any).onActivated.removeListener(cb)
;(browserAPI.tabs as any).onUpdated.removeListener(cb)
}