Files
gdown-helper/src/content/index.ts

385 lines
12 KiB
TypeScript

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<string, number>()
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<boolean> {
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<void> {
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<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 (!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<void> {
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()
})