+ {PRODUCT_INSIGHTS_AVAILABLE && (
+
+
+
+
+ Share diagnostics
+
+
+ Sends usage logs to improve product quality
+
+
+
+
+
+ )}
+
+
+
- Share diagnostics
-
-
- Sends usage logs to improve product quality
+ Log level
-
+
>
diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts
index 1413261..a206fad 100644
--- a/src/entrypoints/background.ts
+++ b/src/entrypoints/background.ts
@@ -1,8 +1,12 @@
import { onMessage, StorageMessageType } from "@/lib/messaging";
import { AppStorageManager } from "@/lib/storage";
import { syncAllDynamicRules, syncDynamicRule } from "@/lib/dnr-rules";
-import { logger } from "@/lib/logger";
-import { initPostHog, setProductInsightsEnabled } from "@/lib/posthog";
+import { logger, setGlobalLogLevel } from "@/lib/logger";
+import {
+ initPostHog,
+ PRODUCT_INSIGHTS_AVAILABLE,
+ setProductInsightsEnabled,
+} from "@/lib/posthog";
import { isSessionOnlyRule } from "@/lib/session-rules";
import { exportCookies, importCookies } from "@/lib/cookie-transfer-background";
@@ -10,13 +14,17 @@ import { WELCOME_URL } from "@/lib/shared/constants";
export default defineBackground(() => {
const storageManager = new AppStorageManager();
- storageManager
- .getProductInsightsEnabled()
- .then((enabled) => {
- if (enabled) {
+ Promise.all([
+ storageManager.getProductInsightsEnabled(),
+ storageManager.getLogLevel(),
+ ])
+ .then(([enabled, logLevel]) => {
+ setGlobalLogLevel(logLevel);
+ const insightsEnabled = PRODUCT_INSIGHTS_AVAILABLE && enabled;
+ if (insightsEnabled) {
initPostHog();
}
- setProductInsightsEnabled(enabled);
+ setProductInsightsEnabled(insightsEnabled);
})
.catch((error) => {
logger.error("Failed to load product insights setting", { error });
@@ -57,9 +65,17 @@ export default defineBackground(() => {
});
onMessage(StorageMessageType.GET_PRODUCT_INSIGHTS_ENABLED, async () => {
+ if (!PRODUCT_INSIGHTS_AVAILABLE) {
+ return false;
+ }
+
return await storageManager.getProductInsightsEnabled();
});
+ onMessage(StorageMessageType.GET_LOG_LEVEL, async () => {
+ return await storageManager.getLogLevel();
+ });
+
onMessage(StorageMessageType.SET_RULE_ENABLED, async (message) => {
const { appId, ruleId, enabled } = message.data;
@@ -93,7 +109,7 @@ export default defineBackground(() => {
onMessage(
StorageMessageType.SET_PRODUCT_INSIGHTS_ENABLED,
async (message) => {
- const { enabled } = message.data;
+ const enabled = PRODUCT_INSIGHTS_AVAILABLE && message.data.enabled;
await storageManager.setProductInsightsEnabled(enabled);
if (enabled) {
initPostHog();
@@ -119,6 +135,29 @@ export default defineBackground(() => {
},
);
+ onMessage(StorageMessageType.SET_LOG_LEVEL, async (message) => {
+ const { level } = message.data;
+ await storageManager.setLogLevel(level);
+ setGlobalLogLevel(level);
+
+ const tabs = await browser.tabs.query({});
+ for (const tab of tabs) {
+ if (tab.id) {
+ browser.tabs
+ .sendMessage(tab.id, {
+ type: StorageMessageType.STORAGE_CHANGED,
+ data: { logLevel: level },
+ })
+ .catch((error) => {
+ logger.error("Failed to send log level message to tab", {
+ tabId: tab.id,
+ error,
+ });
+ });
+ }
+ }
+ });
+
onMessage(StorageMessageType.GET_APP_CONFIG, async (message) => {
const config = await storageManager.getAppConfig(message.data);
if (!config) {
diff --git a/src/entrypoints/content/index.tsx b/src/entrypoints/content/index.tsx
index 88b7a59..f4d67a3 100644
--- a/src/entrypoints/content/index.tsx
+++ b/src/entrypoints/content/index.tsx
@@ -1,13 +1,12 @@
import ReactDOM from "react-dom/client";
import type { ContentScriptContext } from "#imports";
import App from "@/components/App";
-import { PostHogProvider } from "posthog-js/react";
import {
initPostHog,
- POSTHOG_API_KEY,
- posthogOptions,
+ PRODUCT_INSIGHTS_AVAILABLE,
setProductInsightsEnabled,
} from "@/lib/posthog";
+import { ProductInsightsProvider } from "@/lib/posthog-provider";
import "@/assets/global.css";
import { StorageMessageType, sendMessage } from "@/lib/messaging";
@@ -103,8 +102,9 @@ async function createUi(ctx: ContentScriptContext) {
sendMessage(StorageMessageType.GET_PRODUCT_INSIGHTS_ENABLED),
]);
const configsWithSupport = withRuleSupport(allAppConfigs);
- setProductInsightsEnabled(productInsightsEnabled);
- if (productInsightsEnabled) {
+ const insightsEnabled = PRODUCT_INSIGHTS_AVAILABLE && productInsightsEnabled;
+ setProductInsightsEnabled(insightsEnabled);
+ if (insightsEnabled) {
initPostHog();
}
const app = configsWithSupport.find((config) =>
@@ -126,11 +126,11 @@ async function createUi(ctx: ContentScriptContext) {
onMount: (uiContainer, _, shadowHost) => {
if (!root) {
root = ReactDOM.createRoot(uiContainer);
- if (productInsightsEnabled) {
+ if (insightsEnabled) {
root.render(
-
+
- ,
+ ,
);
} else {
root.render(
);
diff --git a/src/entrypoints/primevideo-bootstrap.ts b/src/entrypoints/primevideo-bootstrap.ts
new file mode 100644
index 0000000..a9bdeae
--- /dev/null
+++ b/src/entrypoints/primevideo-bootstrap.ts
@@ -0,0 +1,216 @@
+import {
+ isPrimeVideoTargetScriptUrl,
+ patchPrimeVideoScriptContent,
+} from "@/lib/apps/primevideo/script-patch-shared";
+
+const PRIMEVIDEO_PAGE_BOOTSTRAP_FLAG = "__OTT_PRO_PRIMEVIDEO_PAGE_BOOTSTRAP__";
+const PENDING_URL_KEY = "__ottProPrimevideoPendingSrc";
+type PrimeVideoScriptElement = HTMLScriptElement & {
+ __ottProPrimevideoPendingSrc?: string;
+};
+
+export default defineUnlistedScript(() => {
+ if (!window.location.hostname.endsWith("primevideo.com")) {
+ return;
+ }
+
+ const pageWindow = window as Window & {
+ [PRIMEVIDEO_PAGE_BOOTSTRAP_FLAG]?: boolean;
+ };
+
+ if (pageWindow[PRIMEVIDEO_PAGE_BOOTSTRAP_FLAG]) {
+ return;
+ }
+ pageWindow[PRIMEVIDEO_PAGE_BOOTSTRAP_FLAG] = true;
+
+ const processedScripts = new WeakSet
();
+ const scriptCache = new Map>();
+
+ const setStatus = (status: string, url?: string) => {
+ if (!document.documentElement) {
+ return;
+ }
+
+ if (url) {
+ document.documentElement.dataset.ottProPrimevideoInterceptor = url;
+ }
+ document.documentElement.dataset.ottProPrimevideoInterceptorStatus = status;
+ };
+
+ const fetchPatchedScript = (url: string) => {
+ let pendingScript = scriptCache.get(url);
+ if (!pendingScript) {
+ pendingScript = fetch(url)
+ .then((response) => response.text())
+ .then((content) => patchPrimeVideoScriptContent(content).content);
+ scriptCache.set(url, pendingScript);
+ }
+
+ return pendingScript;
+ };
+
+ const replaceScript = (
+ node: Node | null | undefined,
+ forcedUrl?: string,
+ ): void => {
+ if (!(node instanceof HTMLScriptElement)) {
+ return;
+ }
+
+ const scriptNode = node as PrimeVideoScriptElement;
+ const scriptUrl =
+ forcedUrl ??
+ scriptNode[PENDING_URL_KEY] ??
+ scriptNode.getAttribute("src") ??
+ scriptNode.src;
+ if (
+ !scriptUrl ||
+ !isPrimeVideoTargetScriptUrl(scriptUrl) ||
+ processedScripts.has(scriptNode)
+ ) {
+ return;
+ }
+
+ processedScripts.add(scriptNode);
+ delete scriptNode[PENDING_URL_KEY];
+
+ const originalOnload = scriptNode.onload;
+ const originalOnerror = scriptNode.onerror;
+ setStatus("intercepting", scriptUrl);
+
+ scriptNode.removeAttribute("src");
+ scriptNode.removeAttribute("defer");
+ scriptNode.removeAttribute("async");
+
+ fetchPatchedScript(scriptUrl)
+ .then((patchedContent) => {
+ const replacement = document.createElement("script");
+ for (const { name, value } of Array.from(scriptNode.attributes)) {
+ if (name === "src" || name === "async" || name === "defer") {
+ continue;
+ }
+ replacement.setAttribute(name, value);
+ }
+
+ replacement.textContent = `${patchedContent}\n//# sourceURL=${scriptUrl}`;
+ replacement.dataset.ottProPrimevideoIntercepted = "page";
+ scriptNode.replaceWith(replacement);
+ setStatus("patched", scriptUrl);
+
+ if (originalOnload) {
+ originalOnload.call(replacement, new Event("load"));
+ }
+ })
+ .catch((error) => {
+ setStatus("failed", scriptUrl);
+ if (originalOnerror) {
+ originalOnerror.call(scriptNode, new Event("error"));
+ }
+ console.error("OTT Pro Prime Video bootstrap failed", error);
+ });
+ };
+
+ const inspectNode = (node: Node) => {
+ if (node instanceof HTMLScriptElement) {
+ replaceScript(node);
+ return;
+ }
+
+ if (node instanceof Element) {
+ node
+ .querySelectorAll("script[src]")
+ .forEach((scriptNode) => {
+ replaceScript(scriptNode);
+ });
+ }
+ };
+
+ const wrapMethod = (
+ proto: typeof Node.prototype,
+ methodName: "appendChild" | "insertBefore" | "replaceChild",
+ ) => {
+ const original = proto[methodName] as (...args: unknown[]) => unknown;
+ if (typeof original !== "function") {
+ return;
+ }
+
+ Object.defineProperty(proto, methodName, {
+ configurable: true,
+ value: function (...args: unknown[]) {
+ const node = args[0];
+ if (node instanceof Node) {
+ inspectNode(node);
+ }
+ return original.apply(this, args as never[]);
+ },
+ });
+ };
+
+ wrapMethod(Node.prototype, "appendChild");
+ wrapMethod(Node.prototype, "insertBefore");
+ wrapMethod(Node.prototype, "replaceChild");
+
+ const originalSetAttribute = HTMLScriptElement.prototype.setAttribute;
+ HTMLScriptElement.prototype.setAttribute = function (name, value) {
+ if (name === "src") {
+ const nextUrl = String(value);
+ if (isPrimeVideoTargetScriptUrl(nextUrl)) {
+ (this as PrimeVideoScriptElement)[PENDING_URL_KEY] = nextUrl;
+ queueMicrotask(() => {
+ replaceScript(this, nextUrl);
+ });
+ return;
+ }
+ }
+
+ return originalSetAttribute.call(this, name, value);
+ };
+
+ const srcDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLScriptElement.prototype,
+ "src",
+ );
+ if (srcDescriptor?.configurable && srcDescriptor.get && srcDescriptor.set) {
+ const originalGet = srcDescriptor.get;
+ const originalSet = srcDescriptor.set;
+
+ Object.defineProperty(HTMLScriptElement.prototype, "src", {
+ configurable: true,
+ enumerable: srcDescriptor.enumerable ?? true,
+ get(this: HTMLScriptElement) {
+ return originalGet.call(this);
+ },
+ set(this: HTMLScriptElement, value) {
+ const nextUrl = String(value);
+ if (isPrimeVideoTargetScriptUrl(nextUrl)) {
+ (this as PrimeVideoScriptElement)[PENDING_URL_KEY] = nextUrl;
+ queueMicrotask(() => {
+ replaceScript(this, nextUrl);
+ });
+ return;
+ }
+
+ return originalSet.call(this, value);
+ },
+ });
+ }
+
+ const observer = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ inspectNode(node);
+ }
+ }
+ });
+
+ observer.observe(document.documentElement ?? document, {
+ childList: true,
+ subtree: true,
+ });
+
+ document
+ .querySelectorAll("script[src]")
+ .forEach((scriptNode) => {
+ replaceScript(scriptNode);
+ });
+});
diff --git a/src/entrypoints/primevideo-interceptor.content.ts b/src/entrypoints/primevideo-interceptor.content.ts
new file mode 100644
index 0000000..ff40c76
--- /dev/null
+++ b/src/entrypoints/primevideo-interceptor.content.ts
@@ -0,0 +1,13 @@
+export default defineContentScript({
+ matches: ["*://*.primevideo.com/*"],
+ runAt: "document_start",
+ main() {
+ const isDev = import.meta.env.MODE === "development";
+ const bootstrapPath =
+ "/primevideo-bootstrap.js" as Parameters[0];
+
+ void injectScript(bootstrapPath, {
+ keepInDom: isDev,
+ });
+ },
+});
diff --git a/src/entrypoints/script.ts b/src/entrypoints/script.ts
index 72c5a39..d556ef0 100644
--- a/src/entrypoints/script.ts
+++ b/src/entrypoints/script.ts
@@ -29,7 +29,9 @@ export default defineUnlistedScript(() => {
return;
}
- const rule = staticConfig.rules.find((configRule) => configRule.id === ruleId);
+ const rule = staticConfig.rules.find(
+ (configRule) => configRule.id === ruleId,
+ );
if (!rule?.sessionOnly || !rule.onInit) {
return;
}
@@ -58,15 +60,15 @@ export default defineUnlistedScript(() => {
}
}
+ if (middlewares.length > 0) {
+ fetchApiPolyfill(middlewares);
+ }
+
for (const rule of staticConfig.rules) {
if (enabledRuleIds.includes(rule.id) && rule.onInit) {
rule.onInit();
}
}
-
- if (middlewares.length > 0) {
- fetchApiPolyfill(middlewares);
- }
} catch (error) {
console.error("Script: Error during execution:", error);
}
diff --git a/src/hooks/useStore.tsx b/src/hooks/useStore.tsx
index 093ed6f..e0daea9 100644
--- a/src/hooks/useStore.tsx
+++ b/src/hooks/useStore.tsx
@@ -8,8 +8,16 @@ import {
import { useStore } from "zustand";
import { createStore } from "zustand/vanilla";
import { StorageMessageType, sendMessage } from "@/lib/messaging";
-import { logger } from "@/lib/logger";
-import { setProductInsightsEnabled } from "@/lib/posthog";
+import {
+ DEFAULT_LOG_LEVEL,
+ logger,
+ setGlobalLogLevel,
+ type LogLevel,
+} from "@/lib/logger";
+import {
+ PRODUCT_INSIGHTS_AVAILABLE,
+ setProductInsightsEnabled,
+} from "@/lib/posthog";
import {
getSessionOnlyRuleDefault,
isSessionOnlyRule,
@@ -24,13 +32,16 @@ export interface RootState {
appStates: Map;
ruleStates: Map;
productInsightsEnabled: boolean;
+ logLevel: LogLevel;
}
export interface RootActions {
toggleApp: (appId: string) => Promise;
toggleRule: (appId: string, ruleId: string) => Promise;
toggleProductInsights: () => Promise;
+ setLogLevel: (level: LogLevel) => Promise;
updateProductInsightsFromStorage: (enabled: boolean) => void;
+ updateLogLevelFromStorage: (level: LogLevel) => void;
updateFromStorage: (
appId: string,
appEnabled: boolean,
@@ -46,7 +57,8 @@ export const defaultInitState: RootState = {
currentApp: undefined,
appStates: new Map(),
ruleStates: new Map(),
- productInsightsEnabled: true,
+ productInsightsEnabled: PRODUCT_INSIGHTS_AVAILABLE,
+ logLevel: DEFAULT_LOG_LEVEL,
};
export const createRootStore = (initState: RootState = defaultInitState) => {
@@ -103,6 +115,11 @@ export const createRootStore = (initState: RootState = defaultInitState) => {
},
toggleProductInsights: async () => {
+ if (!PRODUCT_INSIGHTS_AVAILABLE) {
+ set({ productInsightsEnabled: false });
+ return;
+ }
+
const { productInsightsEnabled } = get();
const enabled = !productInsightsEnabled;
@@ -114,9 +131,21 @@ export const createRootStore = (initState: RootState = defaultInitState) => {
set({ productInsightsEnabled: enabled });
},
+ setLogLevel: async (level: LogLevel) => {
+ await sendMessage(StorageMessageType.SET_LOG_LEVEL, { level });
+ setGlobalLogLevel(level);
+ set({ logLevel: level });
+ },
+
updateProductInsightsFromStorage: (enabled: boolean) => {
- setProductInsightsEnabled(enabled);
- set({ productInsightsEnabled: enabled });
+ const nextEnabled = PRODUCT_INSIGHTS_AVAILABLE && enabled;
+ setProductInsightsEnabled(nextEnabled);
+ set({ productInsightsEnabled: nextEnabled });
+ },
+
+ updateLogLevelFromStorage: (level: LogLevel) => {
+ setGlobalLogLevel(level);
+ set({ logLevel: level });
},
updateFromStorage: (
@@ -143,9 +172,10 @@ export const createRootStore = (initState: RootState = defaultInitState) => {
initializeFromStorage: async () => {
try {
- const [appConfigs, productInsightsEnabled] = await Promise.all([
+ const [appConfigs, productInsightsEnabled, logLevel] = await Promise.all([
sendMessage(StorageMessageType.GET_ALL_APP_CONFIGS),
sendMessage(StorageMessageType.GET_PRODUCT_INSIGHTS_ENABLED),
+ sendMessage(StorageMessageType.GET_LOG_LEVEL),
]);
const newAppStates = new Map();
@@ -161,11 +191,15 @@ export const createRootStore = (initState: RootState = defaultInitState) => {
}
}
- setProductInsightsEnabled(productInsightsEnabled);
+ const insightsEnabled =
+ PRODUCT_INSIGHTS_AVAILABLE && productInsightsEnabled;
+ setProductInsightsEnabled(insightsEnabled);
+ setGlobalLogLevel(logLevel);
set({
appStates: newAppStates,
ruleStates: newRuleStates,
- productInsightsEnabled,
+ productInsightsEnabled: insightsEnabled,
+ logLevel,
});
} catch (error) {
logger.error("Failed to initialize from storage:", { error });
@@ -245,6 +279,9 @@ export const RootStoreProvider = ({
} else if ("productInsightsEnabled" in message.data) {
const enabled = message.data.productInsightsEnabled as boolean;
store.getState().updateProductInsightsFromStorage(enabled);
+ } else if ("logLevel" in message.data) {
+ const logLevel = message.data.logLevel as LogLevel;
+ store.getState().updateLogLevelFromStorage(logLevel);
}
}
};
@@ -356,3 +393,11 @@ export const useProductInsightsEnabled = () => {
export const useToggleProductInsights = () => {
return useRootStore((state) => state.toggleProductInsights);
};
+
+export const useLogLevel = () => {
+ return useRootStore((state) => state.logLevel);
+};
+
+export const useSetLogLevel = () => {
+ return useRootStore((state) => state.setLogLevel);
+};
diff --git a/src/lib/apps/primevideo/config.ts b/src/lib/apps/primevideo/config.ts
index f198496..31e62b2 100644
--- a/src/lib/apps/primevideo/config.ts
+++ b/src/lib/apps/primevideo/config.ts
@@ -1,7 +1,7 @@
import type { AppConfig } from "@/lib/shared/types";
import { blockAds } from "./block-ads";
import { blockTelemetry } from "./block-telemetry";
-import { patchScripts, startScriptTagInterceptor } from "./script-watcher";
+import { patchScripts } from "./script-watcher";
/**
* Prime Video App Configuration
@@ -20,9 +20,6 @@ export const config: AppConfig = {
description:
"With a Lite plan you can watch 1080p FHD content on desktop as well.",
middleware: patchScripts,
- onInit: () => {
- startScriptTagInterceptor();
- },
},
{
id: "block-ads",
diff --git a/src/lib/apps/primevideo/script-patch-shared.ts b/src/lib/apps/primevideo/script-patch-shared.ts
new file mode 100644
index 0000000..bfe0e96
--- /dev/null
+++ b/src/lib/apps/primevideo/script-patch-shared.ts
@@ -0,0 +1,27 @@
+const PRIMEVIDEO_SCRIPT_TARGET_URL_PATTERN =
+ /^https:\/\/m\.media-amazon\.com\/images\/.*\.js/i;
+
+const PRIMEVIDEO_SCRIPT_FIND_PATTERN =
+ "e=>(null==e?void 0:e.isBonus)||(null==e?void 0:e.sequenceNumber)&&e.sequenceNumber>=1&&e.sequenceNumber<=3";
+const PRIMEVIDEO_SCRIPT_REPLACE_WITH = "e=>true";
+
+export function isPrimeVideoTargetScriptUrl(url: string) {
+ return PRIMEVIDEO_SCRIPT_TARGET_URL_PATTERN.test(url);
+}
+
+export function patchPrimeVideoScriptContent(content: string) {
+ if (!content.includes(PRIMEVIDEO_SCRIPT_FIND_PATTERN)) {
+ return {
+ changed: false,
+ content,
+ };
+ }
+
+ return {
+ changed: true,
+ content: content.replace(
+ PRIMEVIDEO_SCRIPT_FIND_PATTERN,
+ PRIMEVIDEO_SCRIPT_REPLACE_WITH,
+ ),
+ };
+}
diff --git a/src/lib/apps/primevideo/script-watcher.ts b/src/lib/apps/primevideo/script-watcher.ts
index 9e23891..2291c3a 100644
--- a/src/lib/apps/primevideo/script-watcher.ts
+++ b/src/lib/apps/primevideo/script-watcher.ts
@@ -1,118 +1,6 @@
import type { Middleware } from "@/lib/shared/middleware";
-import { logger } from "@/lib/logger";
-
-/**
- * Script Patcher - Patches Prime Video scripts via fetch interception
- *
- * For scripts loaded via fetch/XHR: middleware patches response directly
- * For scripts loaded via