Skip to content

Commit fb98af2

Browse files
committed
Merge branch 'mv2' into main
2 parents 02c6826 + c8300ac commit fb98af2

6 files changed

Lines changed: 258 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
- extension args: `--disable-extensions-except=<build>` and `--load-extension=<build>`
116116
- Firefox validation in this environment must set `HOME=/root` before launching browser automation as root, or Firefox exits early with a root/session ownership error.
117117
- For Firefox runtime checks, prefer `https://www.youtube.com/live_chat?v=jfKfPfyJRdk&is_popout=1` for deterministic chat-frame loading in headless mode.
118+
- Packaged LiveTL Firefox translation is a special case: keep the request bridge in HC, but host the actual translator iframe on the YouTube page side. Do not call `iframe-translator`'s default `getClient()` directly from the MV2 webpack page-host path; its `import.meta.env.DEV` check can bundle to `(void 0).DEV` and fail at runtime.
118119

119120
## Embed 404 Notes (MV3)
120121

src/scripts/chat-injector.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
setTheme
1212
} from '../ts/messaging';
1313

14+
const isFirefox = navigator.userAgent.includes('Firefox');
15+
1416
const hcWarning = 'An existing HyperChat button has been detected. This ' +
1517
'usually means both LiveTL and standalone HyperChat are enabled. ' +
1618
'LiveTL already includes HyperChat, so please enable only one of them.\n\n' +
@@ -24,6 +26,18 @@ const getScriptURL = (path: string): string => {
2426
return chrome.runtime.getURL('scripts/' + path);
2527
};
2628

29+
const ensureLiveTLTranslatorHost = (): void => {
30+
if (!isLiveTL || !isFirefox) return;
31+
if (document.querySelector('#hc-ltl-translator-host')) return;
32+
33+
const script = document.createElement('script');
34+
script.id = 'hc-ltl-translator-host';
35+
script.src = chrome.runtime.getURL('chat-translation-host.bundle.js');
36+
script.onload = () => script.remove();
37+
script.onerror = () => script.remove();
38+
(document.head ?? document.documentElement).appendChild(script);
39+
};
40+
2741
const chatLoaded = async (): Promise<void> => {
2842
if (!isLiveTL && checkInjected(hcWarning)) return;
2943

@@ -104,6 +118,8 @@ const chatLoaded = async (): Promise<void> => {
104118
// Everything past this point will only run if HC is enabled
105119
if (!hyperChatEnabled) return;
106120

121+
ensureLiveTLTranslatorHost();
122+
107123
const frameInfo = await getFrameInfoAsync();
108124
if (!isValidFrameInfo(frameInfo)) {
109125
console.error('Failed to get valid frame info', { frameInfo });
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { IframeTranslatorClient } from 'iframe-translator';
2+
import { getSafeTranslatorClient } from '../ts/iframe-translator-safe';
3+
import { isLiveTLTranslateRequest, makeLiveTLTranslateResponse } from '../ts/ltl-translation';
4+
5+
declare global {
6+
interface Window {
7+
__hcLiveTLTranslatorHostRegistered?: boolean;
8+
}
9+
}
10+
11+
if (!window.__hcLiveTLTranslatorHostRegistered) {
12+
window.__hcLiveTLTranslatorHostRegistered = true;
13+
14+
let translatorClientPromise: Promise<IframeTranslatorClient> | null = null;
15+
16+
const getTranslatorClientAsync = async (): Promise<IframeTranslatorClient> => {
17+
try {
18+
translatorClientPromise ??= getSafeTranslatorClient();
19+
return await translatorClientPromise;
20+
} catch (error) {
21+
translatorClientPromise = null;
22+
throw error;
23+
}
24+
};
25+
26+
window.addEventListener('message', (event) => {
27+
if (!isLiveTLTranslateRequest(event.data)) return;
28+
if (event.source == null) return;
29+
30+
void (async () => {
31+
let translatedText = event.data.text;
32+
33+
try {
34+
const translatorClient = await getTranslatorClientAsync();
35+
translatedText = await translatorClient.translate(
36+
event.data.text,
37+
event.data.targetLanguage
38+
);
39+
} catch (error) {
40+
console.error('Failed to translate packaged HyperChat message', {
41+
error,
42+
request: event.data
43+
});
44+
}
45+
46+
(event.source as Window).postMessage(
47+
makeLiveTLTranslateResponse(event.data.messageId, translatedText),
48+
'*'
49+
);
50+
})();
51+
});
52+
}

src/ts/iframe-translator-safe.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { AvailableLanguages, DefaultHost } from 'iframe-translator/constants';
2+
import type { AvailableLanguageCodes, IframeTranslatorClient } from 'iframe-translator';
3+
4+
const makeID = (length: number): string => {
5+
let result = '';
6+
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
7+
for (let i = 0; i < length; i++) {
8+
result += characters.charAt(Math.floor(Math.random() * characters.length));
9+
}
10+
return result;
11+
};
12+
13+
export const getSafeTranslatorClient = async (
14+
host: string = DefaultHost
15+
): Promise<IframeTranslatorClient> => {
16+
return await new Promise((resolve) => {
17+
const iframe = document.querySelector<HTMLIFrameElement>('#iframe-translator') ?? document.createElement('iframe');
18+
iframe.src = host;
19+
iframe.id = 'iframe-translator';
20+
iframe.style.position = 'fixed';
21+
iframe.style.top = '0px';
22+
iframe.style.left = '0px';
23+
iframe.style.width = '100%';
24+
iframe.style.height = '100%';
25+
iframe.style.zIndex = '1000000000';
26+
iframe.style.pointerEvents = 'none';
27+
iframe.style.border = 'none';
28+
iframe.style.filter = 'opacity(0)';
29+
iframe.style.touchAction = 'none';
30+
31+
let callbacks: Record<string, (text: string) => void> = {};
32+
33+
const translate = async (
34+
text: string,
35+
targetLanguage: AvailableLanguageCodes = 'en'
36+
): Promise<string> => {
37+
const messageID = `iframe-translator-${makeID(69)}`;
38+
return await new Promise((resolveTranslate) => {
39+
callbacks[messageID] = resolveTranslate;
40+
iframe.contentWindow?.postMessage(JSON.stringify({
41+
messageID,
42+
type: 'request',
43+
targetLanguage: AvailableLanguages[targetLanguage],
44+
text
45+
}), '*');
46+
});
47+
};
48+
49+
const onMessage = (event: MessageEvent): void => {
50+
try {
51+
const data = JSON.parse(event.data as string) as {
52+
messageID?: string;
53+
type?: string;
54+
text?: string;
55+
};
56+
if (data.type === 'loaded') {
57+
resolve({
58+
translate,
59+
destroy
60+
});
61+
return;
62+
}
63+
if (data.type === 'response' && data.messageID != null) {
64+
callbacks[data.messageID]?.(data.text ?? '');
65+
delete callbacks[data.messageID];
66+
}
67+
} catch (error) {
68+
}
69+
};
70+
71+
const destroy = (): void => {
72+
document.body.removeChild(iframe);
73+
callbacks = {};
74+
window.removeEventListener('message', onMessage);
75+
};
76+
77+
window.addEventListener('message', onMessage);
78+
document.body.appendChild(iframe);
79+
});
80+
};

src/ts/ltl-translation.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { AvailableLanguageCodes, IframeTranslatorClient } from 'iframe-translator';
2+
import { Browser, getBrowser, isLiveTL } from './chat-constants';
3+
4+
const REQUEST_TYPE = 'hc-ltl-translate-request';
5+
const RESPONSE_TYPE = 'hc-ltl-translate-response';
6+
const REQUEST_TIMEOUT_MS = 10000;
7+
8+
let requestCounter = 0;
9+
10+
type LiveTLTranslateRequest = {
11+
type: typeof REQUEST_TYPE;
12+
messageId: string;
13+
text: string;
14+
targetLanguage: AvailableLanguageCodes;
15+
};
16+
17+
type LiveTLTranslateResponse = {
18+
type: typeof RESPONSE_TYPE;
19+
messageId: string;
20+
text: string;
21+
};
22+
23+
const isObject = (value: unknown): value is Record<string, unknown> => {
24+
return value != null && typeof value === 'object';
25+
};
26+
27+
export const shouldUseLiveTLTranslatorBridge = (): boolean => {
28+
return isLiveTL &&
29+
getBrowser() === Browser.FIREFOX &&
30+
window.parent !== window;
31+
};
32+
33+
export const isLiveTLTranslateRequest = (value: unknown): value is LiveTLTranslateRequest => {
34+
return isObject(value) &&
35+
value.type === REQUEST_TYPE &&
36+
typeof value.messageId === 'string' &&
37+
typeof value.text === 'string' &&
38+
typeof value.targetLanguage === 'string';
39+
};
40+
41+
const isLiveTLTranslateResponse = (value: unknown): value is LiveTLTranslateResponse => {
42+
return isObject(value) &&
43+
value.type === RESPONSE_TYPE &&
44+
typeof value.messageId === 'string' &&
45+
typeof value.text === 'string';
46+
};
47+
48+
export const makeLiveTLTranslateResponse = (
49+
messageId: string,
50+
text: string
51+
): LiveTLTranslateResponse => {
52+
return {
53+
type: RESPONSE_TYPE,
54+
messageId,
55+
text
56+
};
57+
};
58+
59+
export const createLiveTLTranslatorClient = (): IframeTranslatorClient => {
60+
const callbacks = new Map<string, (text: string) => void>();
61+
62+
const onMessage = (event: MessageEvent): void => {
63+
if (!isLiveTLTranslateResponse(event.data)) return;
64+
65+
const resolve = callbacks.get(event.data.messageId);
66+
if (resolve == null) return;
67+
68+
callbacks.delete(event.data.messageId);
69+
resolve(event.data.text);
70+
};
71+
72+
window.addEventListener('message', onMessage);
73+
74+
return {
75+
translate: async (
76+
text: string,
77+
targetLanguage: AvailableLanguageCodes = 'en'
78+
): Promise<string> => {
79+
const messageId = `hc-ltl-${Date.now()}-${requestCounter++}`;
80+
81+
return await new Promise((resolve) => {
82+
const timeout = window.setTimeout(() => {
83+
callbacks.delete(messageId);
84+
resolve(text);
85+
}, REQUEST_TIMEOUT_MS);
86+
87+
callbacks.set(messageId, (translatedText) => {
88+
window.clearTimeout(timeout);
89+
resolve(translatedText);
90+
});
91+
92+
window.parent.postMessage({
93+
type: REQUEST_TYPE,
94+
messageId,
95+
text,
96+
targetLanguage
97+
}, '*');
98+
});
99+
},
100+
destroy: () => {
101+
callbacks.clear();
102+
window.removeEventListener('message', onMessage);
103+
}
104+
};
105+
};

src/ts/storage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Writable } from 'svelte/store';
44
import { getClient, AvailableLanguages } from 'iframe-translator';
55
import type { IframeTranslatorClient, AvailableLanguageCodes } from 'iframe-translator';
66
import { ChatReportUserOptions, Theme, YoutubeEmojiRenderMode } from './chat-constants';
7+
import { createLiveTLTranslatorClient, shouldUseLiveTLTranslatorBridge } from './ltl-translation';
78
import type { Chat } from './typings/chat';
89

910
export const stores = webExtStores();
@@ -26,7 +27,9 @@ export const translatorClient = readable(null as (null | IframeTranslatorClient)
2627
return;
2728
}
2829
if (client) return;
29-
client = await getClient();
30+
client = shouldUseLiveTLTranslatorBridge()
31+
? createLiveTLTranslatorClient()
32+
: await getClient();
3033
set(client);
3134
});
3235
translateTargetLanguage.ready().then(() => {

0 commit comments

Comments
 (0)