import browser from 'webextension-polyfill' import { isLikelyDownloadResponse, normalizeUrl } from '../lib/downloadIntent' import { nativeAddUri, nativeFocus } from '../lib/nativeHost' import { getSettings } from '../lib/settings' import { upsertHistory } from '../lib/history' import { makeMediaCandidate, mediaFingerprint, shouldCaptureMediaResponse } from '../lib/mediaCapture' import { clearMediaCandidates, listMediaCandidates, upsertMediaCandidate } from '../lib/mediaStore' const REQUEST_TTL_MS = 8000 const TRANSFER_DEDUPE_TTL_MS = 7000 const contextMenuId = 'gomdown-helper-download-context-menu-option' const pendingRequests = new Map() const capturedUrls = new Map() const capturedFingerprints = new Map() const recentTransferFingerprints = new Map() const handledRequestIds = new Map() const capturedTabIds = new Map() const recentMediaFingerprints = new Map() let webRequestHooked = false let downloadHooked = false let contextMenuHooked = false let contextMenuUpdateInFlight: Promise | null = null function urlFingerprint(raw: string): string { try { const u = new URL(raw) const path = (u.pathname || '/').replace(/\/+$/, '') || '/' return `${u.protocol}//${u.host}${path}`.toLowerCase() } catch { return String(raw || '').toLowerCase() } } function pruneMap(map: Map): void { const now = Date.now() for (const [key, expiresAt] of map.entries()) { if (expiresAt <= now) map.delete(key) } } function rememberCapturedUrl(url: string): void { const expiresAt = Date.now() + REQUEST_TTL_MS capturedUrls.set(normalizeUrl(url), expiresAt) capturedFingerprints.set(urlFingerprint(url), expiresAt) } function rememberCapturedTab(tabId?: number): void { if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return capturedTabIds.set(tabId as number, Date.now() + REQUEST_TTL_MS) } function wasCapturedUrl(url: string): boolean { pruneMap(capturedUrls) pruneMap(capturedFingerprints) const normalized = normalizeUrl(url) return capturedUrls.has(normalized) || capturedFingerprints.has(urlFingerprint(url)) } function wasCapturedTab(tabId?: number): boolean { pruneMap(capturedTabIds) if (!Number.isInteger(tabId) || (tabId ?? -1) < 0) return false return capturedTabIds.has(tabId as number) } function shouldSuppressDuplicateTransfer(url: string): boolean { pruneMap(recentTransferFingerprints) return recentTransferFingerprints.has(urlFingerprint(url)) } function rememberRecentTransfer(url: string): void { recentTransferFingerprints.set(urlFingerprint(url), Date.now() + TRANSFER_DEDUPE_TTL_MS) } function wasRequestHandled(requestId?: string): boolean { pruneMap(handledRequestIds) return !!requestId && handledRequestIds.has(requestId) } function markRequestHandled(requestId?: string): void { if (!requestId) return handledRequestIds.set(requestId, Date.now() + REQUEST_TTL_MS) } function shouldSuppressDuplicateMedia(url: string): boolean { pruneMap(recentMediaFingerprints) const fp = mediaFingerprint(url) return recentMediaFingerprints.has(fp) } function rememberMediaFingerprint(url: string): void { recentMediaFingerprints.set(mediaFingerprint(url), Date.now() + REQUEST_TTL_MS) } async function requestGdownFocus(): Promise { try { await nativeFocus() } catch { // ignored } } async function notify(message: string): Promise { await browser.notifications .create(`gomdown-notice-${Date.now()}`, { type: 'basic', iconUrl: '/images/icon-large.png', title: 'Gomdown Helper', message, }) .catch(() => null) } async function transferUrlToGdown( url: string, referer = '', extractor?: 'yt-dlp' | 'aria2', format?: string ): Promise<{ ok: boolean; error?: string }> { if (shouldSuppressDuplicateTransfer(url)) { return { ok: false, error: 'duplicate transfer suppressed' } } const settings = await getSettings() if (!settings.extensionStatus) return { ok: false, error: 'extension disabled' } if (!settings.motrixAPIkey) return { ok: false, error: 'motrixAPIkey is not set' } try { const nativeResult = await nativeAddUri({ url, rpcPort: settings.motrixPort, rpcSecret: settings.motrixAPIkey, referer, split: 64, extractor: extractor === 'yt-dlp' ? 'yt-dlp' : undefined, format: extractor === 'yt-dlp' ? format || 'bestvideo*+bestaudio/best' : undefined, }) if (!nativeResult?.ok) { return { ok: false, error: nativeResult?.error || 'native host addUri failed' } } if (settings.activateAppOnDownload) { await requestGdownFocus() } rememberRecentTransfer(url) const gid = String(nativeResult?.gid || nativeResult?.requestId || `pending-${Date.now()}`) const pathname = (() => { try { return new URL(url).pathname } catch { return '' } })() const guessedName = pathname.split('/').filter(Boolean).pop() || url await upsertHistory({ gid, downloader: 'native', startTime: new Date().toISOString(), icon: '/images/32.png', name: decodeURIComponent(guessedName), path: null, status: nativeResult?.pending ? 'queued' : 'downloading', size: 0, downloaded: 0, }) if (settings.enableNotifications) { await browser.notifications.create(`gomdown-transfer-${Date.now()}`, { type: 'basic', iconUrl: '/images/icon-large.png', title: 'Gomdown Helper', message: 'Download sent to gdown', }) } return { ok: true } } catch (error) { return { ok: false, error: String(error) } } } async function shouldCaptureRequest(details: any): Promise { if (details.type !== 'main_frame') return false if ((details.method || '').toUpperCase() !== 'GET') return false if (typeof details.statusCode === 'number' && (details.statusCode < 200 || details.statusCode > 299)) return false const settings = await getSettings() if (!settings.extensionStatus || !settings.motrixAPIkey) return false const contentLengthRaw = String( Array.isArray(details?.responseHeaders) ? details.responseHeaders.find((h: any) => String(h?.name || '').toLowerCase() === 'content-length')?.value || '' : '' ) const contentLength = Number(contentLengthRaw || 0) if (settings.minFileSize > 0 && contentLength > 0 && contentLength < settings.minFileSize * 1024 * 1024) { return false } return isLikelyDownloadResponse(details) } async function interceptByWebRequest(details: any): Promise { if (wasRequestHandled(details.requestId)) return const accepted = await shouldCaptureRequest(details) if (!accepted) return const req = pendingRequests.get(details.requestId) const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '') const sent = await transferUrlToGdown(details.url, referer) if (!sent.ok) return markRequestHandled(details.requestId) rememberCapturedUrl(details.url) rememberCapturedTab(details.tabId) } async function interceptMediaByWebRequest(details: any): Promise { if (!shouldCaptureMediaResponse(details)) return const settings = await getSettings() if (!settings.extensionStatus) return if (shouldSuppressDuplicateMedia(details.url)) return const req = pendingRequests.get(details.requestId) const referer = String(req?.documentUrl || req?.originUrl || req?.initiator || req?.url || '') const candidate = makeMediaCandidate(details, referer) await upsertMediaCandidate(candidate, mediaFingerprint(candidate.url)) rememberMediaFingerprint(candidate.url) } async function applyShelfVisibility(): Promise { const downloadsApi = browser.downloads as any if (!downloadsApi.setShelfEnabled) return const settings = await getSettings() if (!settings.extensionStatus) return const enabled = settings.useNativeHost ? false : !settings.hideChromeBar await downloadsApi.setShelfEnabled(enabled) } function setupDownloadCancelHook(): void { if (downloadHooked) return downloadHooked = true browser.downloads.onCreated.addListener(async (downloadItem) => { await applyShelfVisibility() const download = downloadItem as any const target = download.finalUrl || download.url || '' if (!wasCapturedUrl(target) && !wasCapturedTab(download.tabId)) return await browser.downloads.cancel(downloadItem.id).catch(() => null) await browser.downloads.erase({ id: downloadItem.id }).catch(() => null) await browser.downloads.removeFile(downloadItem.id).catch(() => null) }) } function setupWebRequestInterceptor(): void { if (webRequestHooked) return webRequestHooked = true browser.webRequest.onSendHeaders.addListener( (details) => { pendingRequests.set(details.requestId, details as any) }, { urls: [''] }, ['requestHeaders', 'extraHeaders'] ) browser.webRequest.onErrorOccurred.addListener( (details) => { pendingRequests.delete(details.requestId) handledRequestIds.delete(String(details.requestId)) }, { urls: [''] } ) browser.webRequest.onCompleted.addListener( (details) => { pendingRequests.delete(details.requestId) handledRequestIds.delete(String(details.requestId)) }, { urls: [''] } ) browser.webRequest.onHeadersReceived.addListener( (details) => { void interceptByWebRequest(details) void interceptMediaByWebRequest(details) }, { urls: [''] }, ['responseHeaders'] ) } async function handleContextMenuClick(data: any, tab?: any): Promise { console.log('[gomdown-helper] context menu clicked', { menuItemId: data?.menuItemId, linkUrl: data?.linkUrl, srcUrl: data?.srcUrl, frameUrl: data?.frameUrl, pageUrl: data?.pageUrl, tabUrl: tab?.url, }) const clickedId = data?.menuItemId if (clickedId != null && String(clickedId) !== contextMenuId) return const linkUrl = String(data?.linkUrl || data?.srcUrl || '').trim() const pageUrl = String(data?.frameUrl || data?.pageUrl || tab?.url || '').trim() const targetUrl = linkUrl || pageUrl const url = String(targetUrl || '').trim() if (!url || /^(about:|chrome:|chrome-extension:|edge:|brave:)/i.test(url)) { await notify('다운로드 가능한 URL을 찾지 못했습니다.') return } const useYtDlp = !linkUrl && !!pageUrl const result = await transferUrlToGdown( url, String(data?.pageUrl || tab?.url || ''), useYtDlp ? 'yt-dlp' : 'aria2' ) if (!result.ok) { await notify(`전송 실패: ${result.error || 'unknown error'}`) return } await notify(useYtDlp ? '페이지 URL을 yt-dlp로 gdown에 전송했습니다.' : 'gdown으로 전송했습니다.') } function createContextMenuSafe(): void { if (typeof chrome === 'undefined' || !chrome.contextMenus?.create) { browser.contextMenus.create({ id: contextMenuId, title: 'Download with Gomdown', visible: true, contexts: ['all'], }) return } chrome.contextMenus.create( { id: contextMenuId, title: 'Download with Gomdown', contexts: ['all'], }, () => { void chrome.runtime.lastError } ) } function setupContextMenuClickListener(): void { if (contextMenuHooked) return if (typeof chrome !== 'undefined' && chrome.contextMenus?.onClicked) { chrome.contextMenus.onClicked.addListener((info, tab) => { void handleContextMenuClick(info, tab) }) } else { browser.contextMenus.onClicked.addListener((info: any, tab: any) => { void handleContextMenuClick(info, tab) }) } contextMenuHooked = true } async function createMenuItemInternal(): Promise { const settings = await getSettings() if (!settings.extensionStatus || !settings.showContextOption) { await browser.contextMenus.removeAll().catch(() => null) return } await browser.contextMenus.removeAll().catch(() => null) createContextMenuSafe() } function createMenuItem(): Promise { if (contextMenuUpdateInFlight) return contextMenuUpdateInFlight contextMenuUpdateInFlight = createMenuItemInternal().finally(() => { contextMenuUpdateInFlight = null }) return contextMenuUpdateInFlight } browser.runtime.onMessage.addListener((message: any, sender: any) => { if (message?.type === 'capture-link-download') { const url = String(message?.url || '').trim() if (!url) return Promise.resolve({ ok: false, error: 'url is empty' }) const senderTabId = Number(sender?.tab?.id) return transferUrlToGdown(url, String(message?.referer || '')).then((result) => { if (result.ok) { rememberCapturedUrl(url) rememberCapturedTab(senderTabId) } return result }) } if (message?.type === 'media:list') { return listMediaCandidates().then((items) => ({ ok: true, items })) } if (message?.type === 'media:clear') { return clearMediaCandidates().then(() => ({ ok: true })) } if (message?.type === 'media:enqueue') { const url = String(message?.url || '').trim() if (!url) return Promise.resolve({ ok: false, error: 'url is empty' }) return transferUrlToGdown(url, String(message?.referer || '')).then((result) => result) } if (message?.type === 'page:enqueue-ytdlp') { return browser.tabs .query({ active: true, currentWindow: true }) .then(async (tabs) => { const tab = tabs[0] const url = String(tab?.url || '').trim() if (!url) return { ok: false, error: 'active tab url is empty' } return transferUrlToGdown(url, url, 'yt-dlp') }) } return undefined }) browser.runtime.onInstalled.addListener(() => { console.log('[gomdown-helper] onInstalled') setupWebRequestInterceptor() setupDownloadCancelHook() setupContextMenuClickListener() void createMenuItem() void applyShelfVisibility() }) browser.runtime.onStartup.addListener(() => { console.log('[gomdown-helper] onStartup') setupWebRequestInterceptor() setupDownloadCancelHook() setupContextMenuClickListener() void createMenuItem() void applyShelfVisibility() }) browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'sync') return if (changes.hideChromeBar || changes.useNativeHost || changes.extensionStatus) { void applyShelfVisibility() void createMenuItem() } if (changes.showContextOption) { void createMenuItem() } }) setupWebRequestInterceptor() setupDownloadCancelHook() setupContextMenuClickListener() void createMenuItem() void applyShelfVisibility() console.log('[gomdown-helper] service worker initialized')