mirror of
https://github.com/p-stream/extension.git
synced 2026-01-11 20:10:35 +00:00
feat: Add Safari support and improve messaging system
This commit is contained in:
parent
1c1cc9cfdf
commit
b8a678d46c
16 changed files with 1659 additions and 931 deletions
77
SAFARI.md
Normal file
77
SAFARI.md
Normal 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
12
manifest.safari.ts
Normal 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
|
||||
11
package.json
11
package.json
|
|
@ -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}"
|
||||
|
|
|
|||
1735
pnpm-lock.yaml
1735
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
onlyBuiltDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- lmdb
|
||||
- msgpackr-extract
|
||||
- sharp
|
||||
24
safari.config.json
Normal file
24
safari.config.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
101
src/components/SafariPermissionGuide.tsx
Normal file
101
src/components/SafariPermissionGuide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue