2026-02-26 11:43:44 +09:00
|
|
|
import browser from 'webextension-polyfill'
|
|
|
|
|
import { isLikelyDownloadUrl, normalizeUrl } from '../lib/downloadIntent'
|
|
|
|
|
|
|
|
|
|
const CAPTURE_TTL_MS = 8000
|
|
|
|
|
const captureInFlight = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
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<boolean> {
|
|
|
|
|
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<void> {
|
|
|
|
|
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 {
|
|
|
|
|
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 (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 (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 (href && isLikelyDownloadUrl(href, window.location.href)) {
|
|
|
|
|
void sendCapture(href, document.referrer || window.location.href)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
originalAnchorClick.call(this)
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignored
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
installProgrammaticInterceptors()
|
2026-02-26 12:17:36 +09:00
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
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 (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()
|
2026-02-26 13:02:37 +09:00
|
|
|
|
|
|
|
|
let mediaToastRoot: HTMLDivElement | null = null
|
|
|
|
|
let mediaToastTimer: number | null = 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 {
|
|
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|