import browser from 'webextension-polyfill' import { isLikelyDownloadUrl, normalizeUrl } from '../lib/downloadIntent' import { getSettings } from '../lib/settings' const CAPTURE_TTL_MS = 8000 const captureInFlight = new Map() let extensionEnabled = false function pruneCaptureInFlight(): void { const now = Date.now() for (const [url, expiresAt] of captureInFlight.entries()) { if (expiresAt <= now) captureInFlight.delete(url) } } async function sendCapture(url: string, referer: string): Promise { if (!extensionEnabled) return false const normalized = normalizeUrl(url, window.location.href) if (!normalized) return false pruneCaptureInFlight() if (captureInFlight.has(normalized)) return true captureInFlight.set(normalized, Date.now() + CAPTURE_TTL_MS) try { const result = (await browser.runtime.sendMessage({ type: 'capture-link-download', url: normalized, referer: referer || document.referrer || window.location.href, })) as { ok?: boolean } if (result?.ok) return true } catch { // ignored } captureInFlight.delete(normalized) return false } function findAnchor(target: EventTarget | null): HTMLAnchorElement | null { if (!target) return null if (target instanceof HTMLAnchorElement) return target if (target instanceof Element) return target.closest('a[href]') as HTMLAnchorElement | null return null } function shouldIgnoreHotkey(event: MouseEvent | KeyboardEvent): boolean { return !!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) } async function interceptAnchorEvent(event: MouseEvent): Promise { if (!extensionEnabled) return if (event.defaultPrevented) return if (shouldIgnoreHotkey(event)) return const anchor = findAnchor(event.target) if (!anchor) return const href = anchor.href || '' if (!href || !isLikelyDownloadUrl(href, window.location.href)) return event.preventDefault() event.stopImmediatePropagation() event.stopPropagation() await sendCapture(href, document.referrer || window.location.href) } function interceptMouseLike(event: MouseEvent): void { if (!extensionEnabled) return const anchor = findAnchor(event.target) if (!anchor) return const href = anchor.href || '' if (!href || !isLikelyDownloadUrl(href, window.location.href)) return if (shouldIgnoreHotkey(event)) return event.preventDefault() event.stopImmediatePropagation() event.stopPropagation() void sendCapture(href, document.referrer || window.location.href) } document.addEventListener('pointerdown', (event: PointerEvent) => { if (event.button !== 0) return interceptMouseLike(event) }, true) document.addEventListener('mousedown', (event: MouseEvent) => { if (event.button !== 0) return interceptMouseLike(event) }, true) document.addEventListener('click', (event: MouseEvent) => { if (event.button !== 0) return void interceptAnchorEvent(event) }, true) document.addEventListener('keydown', (event: KeyboardEvent) => { if (!extensionEnabled) return if (event.key !== 'Enter') return if (event.defaultPrevented) return if (shouldIgnoreHotkey(event)) return const anchor = findAnchor(event.target) if (!anchor) return const href = anchor.href || '' if (!href || !isLikelyDownloadUrl(href, window.location.href)) return event.preventDefault() event.stopImmediatePropagation() event.stopPropagation() void sendCapture(href, document.referrer || window.location.href) }, true) document.addEventListener('auxclick', (event: MouseEvent) => { if (event.button !== 1) return void interceptAnchorEvent(event) }, true) function installProgrammaticInterceptors(): void { try { const originalOpen = window.open.bind(window) window.open = function gomdownInterceptOpen(url?: string | URL, target?: string, features?: string): Window | null { const raw = String(url || '').trim() if (extensionEnabled && raw && isLikelyDownloadUrl(raw, window.location.href)) { void sendCapture(raw, window.location.href) return null } return originalOpen(url as string, target, features) } } catch { // ignored } try { const originalAnchorClick = HTMLAnchorElement.prototype.click HTMLAnchorElement.prototype.click = function gomdownInterceptAnchorClick(): void { const href = this.href || this.getAttribute('href') || '' if (extensionEnabled && href && isLikelyDownloadUrl(href, window.location.href)) { void sendCapture(href, document.referrer || window.location.href) return } originalAnchorClick.call(this) } } catch { // ignored } } installProgrammaticInterceptors() let ytOverlayRoot: HTMLDivElement | null = null let ytOverlayStatus: HTMLDivElement | null = null let ytOverlayBusy = false let ytUrlWatcherTimer: number | null = null let lastObservedUrl = window.location.href function isYoutubeWatchPage(url: string): boolean { try { const parsed = new URL(url) if (parsed.hostname !== 'www.youtube.com' && parsed.hostname !== 'youtube.com') return false return parsed.pathname === '/watch' && parsed.searchParams.has('v') } catch { return false } } function setYtOverlayStatus(message: string, tone: 'ok' | 'error' | 'idle' = 'idle'): void { if (!ytOverlayStatus) return ytOverlayStatus.textContent = message if (tone === 'ok') ytOverlayStatus.style.color = '#8ff0a4' else if (tone === 'error') ytOverlayStatus.style.color = '#ff9b9b' else ytOverlayStatus.style.color = '#aeb7d8' } async function enqueueCurrentYoutubePage(): Promise { if (ytOverlayBusy) return ytOverlayBusy = true setYtOverlayStatus('gdown으로 전송 중...') try { const result = (await browser.runtime.sendMessage({ type: 'page:enqueue-ytdlp-url', url: window.location.href, referer: window.location.href, })) as { ok?: boolean; error?: string } if (result?.ok) { setYtOverlayStatus('다운로드 모달로 전송됨', 'ok') } else { setYtOverlayStatus(`전송 실패: ${result?.error || 'unknown error'}`, 'error') } } catch (error) { setYtOverlayStatus(`전송 실패: ${String(error)}`, 'error') } finally { ytOverlayBusy = false } } function removeYoutubeOverlay(): void { if (ytOverlayRoot) { ytOverlayRoot.remove() ytOverlayRoot = null ytOverlayStatus = null } } function ensureYoutubeOverlay(): void { if (!extensionEnabled) { removeYoutubeOverlay() return } if (window.top !== window.self) return if (!isYoutubeWatchPage(window.location.href)) { removeYoutubeOverlay() return } if (ytOverlayRoot) return const root = document.createElement('div') root.id = 'gomdown-youtube-overlay' root.style.position = 'fixed' root.style.right = '20px' root.style.bottom = '24px' root.style.zIndex = '2147483647' root.style.background = 'rgba(17, 21, 32, 0.94)' root.style.border = '1px solid rgba(133, 148, 195, 0.35)' root.style.borderRadius = '12px' root.style.padding = '10px' root.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.28)' root.style.backdropFilter = 'blur(6px)' root.style.width = '220px' root.style.fontFamily = 'ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif' root.style.color = '#e8edff' const title = document.createElement('div') title.textContent = 'Gdown Helper' title.style.fontSize = '12px' title.style.fontWeight = '700' title.style.marginBottom = '8px' const action = document.createElement('button') action.type = 'button' action.textContent = '이 영상 다운로드' action.style.width = '100%' action.style.height = '34px' action.style.border = '1px solid #5a69f0' action.style.borderRadius = '8px' action.style.background = '#5a69f0' action.style.color = '#ffffff' action.style.fontSize = '12px' action.style.fontWeight = '700' action.style.cursor = 'pointer' action.addEventListener('click', () => { void enqueueCurrentYoutubePage() }) const status = document.createElement('div') status.textContent = '클릭 시 gdown 다운로드 모달로 연결' status.style.fontSize = '11px' status.style.marginTop = '8px' status.style.lineHeight = '1.35' status.style.color = '#aeb7d8' root.appendChild(title) root.appendChild(action) root.appendChild(status) document.documentElement.appendChild(root) ytOverlayRoot = root ytOverlayStatus = status } function watchYoutubeRouteChanges(): void { if (ytUrlWatcherTimer !== null) return ytUrlWatcherTimer = window.setInterval(() => { const current = window.location.href if (current === lastObservedUrl) return lastObservedUrl = current ensureYoutubeOverlay() }, 800) window.addEventListener('popstate', () => { lastObservedUrl = window.location.href ensureYoutubeOverlay() }) document.addEventListener('yt-navigate-finish', () => { lastObservedUrl = window.location.href ensureYoutubeOverlay() }) } ensureYoutubeOverlay() watchYoutubeRouteChanges() let mediaToastRoot: HTMLDivElement | null = null let mediaToastTimer: number | null = null function hideMediaCapturedToast(): void { if (!mediaToastRoot) return mediaToastRoot.style.display = 'none' if (mediaToastTimer !== null) { window.clearTimeout(mediaToastTimer) mediaToastTimer = null } } function ensureMediaToastRoot(): HTMLDivElement { if (mediaToastRoot) return mediaToastRoot const root = document.createElement('div') root.id = 'gomdown-media-toast' root.style.position = 'fixed' root.style.left = '18px' root.style.bottom = '18px' root.style.zIndex = '2147483647' root.style.maxWidth = '360px' root.style.padding = '10px 12px' root.style.borderRadius = '10px' root.style.border = '1px solid rgba(128, 140, 180, 0.42)' root.style.background = 'rgba(18, 21, 31, 0.95)' root.style.color = '#dce4fa' root.style.fontSize = '12px' root.style.lineHeight = '1.35' root.style.fontFamily = 'ui-sans-serif, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif' root.style.boxShadow = '0 10px 24px rgba(0, 0, 0, 0.28)' root.style.display = 'none' document.documentElement.appendChild(root) mediaToastRoot = root return root } function showMediaCapturedToast(payload: { kind?: string; url?: string; suggestedOut?: string }): void { if (!extensionEnabled) return const root = ensureMediaToastRoot() const kind = String(payload?.kind || 'media').toUpperCase() const out = String(payload?.suggestedOut || '').trim() const shortUrl = String(payload?.url || '').trim().slice(0, 96) root.textContent = out ? `캡처됨 [${kind}] ${out}` : `캡처됨 [${kind}] ${shortUrl}${shortUrl.length >= 96 ? '…' : ''}` root.style.display = 'block' if (mediaToastTimer !== null) window.clearTimeout(mediaToastTimer) mediaToastTimer = window.setTimeout(() => { root.style.display = 'none' mediaToastTimer = null }, 2200) } browser.runtime.onMessage.addListener((message: any) => { if (message?.type === 'media:captured') { showMediaCapturedToast({ kind: message?.kind, url: message?.url, suggestedOut: message?.suggestedOut, }) } }) async function syncExtensionEnabled(): Promise { try { const settings = await getSettings() extensionEnabled = Boolean(settings.extensionStatus) } catch { extensionEnabled = false } if (!extensionEnabled) { removeYoutubeOverlay() hideMediaCapturedToast() } else { ensureYoutubeOverlay() } } void syncExtensionEnabled() browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== 'sync') return if (!changes.extensionStatus) return extensionEnabled = Boolean(changes.extensionStatus.newValue) if (!extensionEnabled) { removeYoutubeOverlay() hideMediaCapturedToast() return } ensureYoutubeOverlay() })