Merge remote-tracking branch 'public/main' into safari

This commit is contained in:
Pas 2025-10-29 19:28:20 -06:00
commit 62e205c5e4
14 changed files with 453 additions and 114 deletions

83
SAFARI.md Normal file
View file

@ -0,0 +1,83 @@
# 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",
"sharp": "^0.34.3"
@ -46,11 +49,15 @@
"permissions": [
"declarativeNetRequest",
"activeTab",
"cookies"
"cookies",
"storage"
],
"optional_host_permissions": [
"<all_urls>"
],
"host_permissions": [
"<all_urls>"
],
"browser_specific_settings": {
"gecko": {
"id": "{de055456-589b-45fe-8342-c685a7ffb424}"

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,47 @@
import { isChrome } from '~utils/extension';
import '@plasmohq/messaging/background';
import { getBrowserAPI, isChrome, isFirefox, isSafari } from '~utils/extension';
// 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()) {
const browserAPI = getBrowserAPI();
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.onInstalled.addListener(() => {
console.log('Chrome extension installed/updated');
});
} else if (isFirefox()) {
// Firefox behavior
browser.runtime.onStartup.addListener(() => {
browser.runtime.reload();
});
browser.runtime.onInstalled.addListener(() => {
console.log('Firefox extension installed/updated');
});
}

View file

@ -2,7 +2,7 @@ import type { PlasmoMessaging } from '@plasmohq/messaging';
import type { BaseRequest } from '~types/request';
import type { BaseResponse } from '~types/response';
import { isChrome } from '~utils/extension';
import { getBrowserAPI } from '~utils/extension';
type Request = BaseRequest & {
page: string;
@ -16,15 +16,12 @@ const handler: PlasmoMessaging.MessageHandler<Request, BaseResponse> = async (re
const searchParams = new URLSearchParams();
searchParams.set('redirectUrl', req.body.redirectUrl);
const url = (chrome || browser).runtime.getURL(`/tabs/${req.body.page}.html?${searchParams.toString()}`);
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,

View file

@ -0,0 +1,89 @@
import { useCallback } from 'react';
import { Button } from '~components/Button';
import { Icon } from '~components/Icon';
import { usePermission } from '~hooks/usePermission';
import { isSafari } from '~utils/extension';
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 &quot;P-Stream extension&quot;
</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 &quot;Allow on all websites&quot; 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

@ -5,18 +5,44 @@ export const config: PlasmoCSConfig = {
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

@ -27,9 +27,7 @@ export function useToggleWhitelistDomain(domain: string | null) {
const { domainWhitelist, addDomain, removeDomain } = useDomainWhitelist();
const isWhitelisted = domainWhitelist.includes(domain ?? '');
const { grantPermission } = usePermission();
const iconPath = (chrome || browser).runtime.getURL(
isWhitelisted ? 'assets/active.png' : 'assets/inactive.png',
);
const iconPath = (chrome || browser).runtime.getURL(isWhitelisted ? 'assets/active.png' : 'assets/inactive.png');
(chrome || browser).action.setIcon({
path: iconPath,

View file

@ -1,25 +1,76 @@
import { useCallback, useEffect, useState } from 'react';
import { getBrowserAPI, isSafari } from '~utils/extension';
import { useDomainWhitelist } from './useDomainWhitelist';
export async function hasPermission() {
return chrome.permissions.contains({
origins: ['<all_urls>'],
});
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);
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) {
// eslint-disable-next-line no-alert
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));

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,5 +1,4 @@
import { isChrome } from './extension';
import { modifiableResponseHeaders } from './storage';
import { getBrowserAPI, isChrome, isSafari } from './extension';
interface DynamicRule {
ruleId: number;
@ -21,54 +20,10 @@ const mapHeadersToDeclarativeNetRequestHeaders = (
};
export const setDynamicRules = async (body: DynamicRule) => {
if (isChrome()) {
await chrome.declarativeNetRequest.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({
const browserAPI = getBrowserAPI();
if (isChrome() || isSafari()) {
await (browserAPI.declarativeNetRequest as any).updateDynamicRules({
removeRuleIds: [body.ruleId],
addRules: [
{
@ -106,14 +61,59 @@ export const setDynamicRules = async (body: DynamicRule) => {
},
],
});
if (browser.runtime.lastError?.message) throw new Error(browser.runtime.lastError.message);
} 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 && 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({
const browserAPI = getBrowserAPI();
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

@ -9,3 +9,24 @@ export const isFirefox = () => {
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,4 +1,4 @@
import { isChrome } from './extension';
import { getBrowserAPI } from './extension';
export function queryCurrentDomain(cb: (domain: string | null) => void) {
const handle = (tabUrl: string | undefined) => {
@ -6,27 +6,18 @@ export function queryCurrentDomain(cb: (domain: string | null) => void) {
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));
const browserAPI = getBrowserAPI();
(browserAPI.tabs as any).query(ops).then((tabs: any[]) => 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 browserAPI = getBrowserAPI();
(browserAPI.tabs as any).onActivated.addListener(cb);
(browserAPI.tabs as any).onUpdated.addListener(cb);
}
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);
}
const browserAPI = getBrowserAPI();
(browserAPI.tabs as any).onActivated.removeListener(cb);
(browserAPI.tabs as any).onUpdated.removeListener(cb);
}