import { HOST_NAME, canonicalUrl, progressText, downloadSizeText, processingText, preferenceOptions, errorText, stabilizeDownloadSpeed, checkHelperCompatibility } from "./shared.js"; import { readSession, SESSION_PERMISSION } from "./session.js"; let nativePort = null; const pending = new Map(); const clients = new Set(); const jobContexts = new Map(); let jobs = {}; let helper = { status: "idle", message: "Помощник ещё не подключён.", hardware: null }; let helperStartTask = null; const notificationTimes = new Map(); const inspectionCache = new Map(); const ready = chrome.storage.local.get(["jobs"]).then(saved => { jobs = saved.jobs || {}; for (const [id, job] of Object.entries(jobs)) { jobContexts.set(id, { tabId: job.tabId, title: job.title }); if (job.status === "running") { jobs[id] = { ...job, status: "error", stage: "error", error: "Браузер или помощник был закрыт. Запустите загрузку снова." }; } } }); function broadcast(message) { for (const port of clients) { try { port.postMessage(message); } catch { clients.delete(port); } } } function helperState(status, message, hardware = null, compatibility = null) { helper = { status, message, hardware, compatibility, checkedAt: Date.now() }; broadcast({ event: "helper", helper }); return helper; } function saveJobs() { const recent = Object.values(jobs).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 30); jobs = Object.fromEntries(recent.map(job => [job.id, job])); chrome.storage.local.set({ jobs: Object.fromEntries(recent.filter(job => !job.transient).map(job => [job.id, job])) }); } function disconnect(error, status = "error") { inspectionCache.clear(); nativePort = null; helperState(status, error); for (const [id, request] of pending) { clearTimeout(request.timeout); request.reject(new Error(error)); pending.delete(id); } for (const job of Object.values(jobs)) { if (job.status === "running") onJob({ ...job, status: "error", stage: "error", error }); } } function connectHost() { if (nativePort) return nativePort; helperState("checking", "Запускаю и проверяю локальный помощник…"); nativePort = chrome.runtime.connectNative(HOST_NAME); const port = nativePort; port.onMessage.addListener(message => { if (message.event === "job") { onJob(message.job); return; } const request = pending.get(message.id); if (!request) return; pending.delete(message.id); clearTimeout(request.timeout); message.ok ? request.resolve(message.result) : request.reject(new Error(errorText(message.error || "Ошибка помощника"))); }); port.onDisconnect.addListener(() => { const detail = chrome.runtime.lastError?.message; if (nativePort === port) { const missing = /host.*not found|not.*registered|cannot find.*host/i.test(detail || ""); disconnect(missing ? "Помощник не установлен. Скачайте и запустите установщик, затем нажмите «Запустить»." : detail ? `Не удалось подключиться к помощнику: ${detail}` : "Помощник отключён. Нажмите «Запустить».", missing ? "missing" : "offline"); } }); return port; } function nativeRequest(action, payload = {}) { return new Promise((resolve, reject) => { const id = crypto.randomUUID(); const timeout = setTimeout(() => { pending.delete(id); reject(new Error(action === "pick_folder" ? "Диалог выбора папки закрыт по тайм-ауту." : "Помощник отвечает слишком долго. Попробуйте снова.")); }, action === "ping" ? 4000 : action === "pick_folder" ? 600_000 : action === "inspect" ? 150_000 : 90_000); pending.set(id, { resolve, reject, timeout }); let port; try { port = connectHost(); port.postMessage({ id, action, payload }); } catch (error) { clearTimeout(timeout); pending.delete(id); if (/disconnected port/i.test(error.message)) { const failure = new Error("Связь с помощником прервана. Подключаюсь заново…"); failure.code = "NATIVE_PORT_DISCONNECTED"; if (nativePort === port) { disconnect(failure.message, "offline"); try { port.disconnect(); } catch { /* Already disconnected. */ } } reject(failure); } else reject(error); } }); } function startHelper() { if (!helperStartTask) helperStartTask = startHelperNow().finally(() => { helperStartTask = null; }); return helperStartTask; } async function startHelperNow() { if (nativePort && helper.status !== "running" && !Object.values(jobs).some(job => job.status === "running")) { const stale = nativePort; nativePort = null; try { stale.disconnect(); } catch { /* Already disconnected. */ } } try { for (let attempt = 0; attempt < 2; attempt++) { try { const hardware = await nativeRequest("hello"); const ping = await nativeRequest("ping"); if (!ping.alive) throw new Error("Помощник не подтвердил связь."); const compatibility = checkHelperCompatibility(hardware, chrome.runtime.getManifest().version); if (!compatibility.compatible) { helperState("incompatible", compatibility.message, hardware, compatibility); throw new Error(compatibility.message); } helperState("running", "Помощник отвечает. Загрузка и обработка доступны.", hardware, compatibility); return hardware; } catch (error) { if (attempt === 0 && error.code === "NATIVE_PORT_DISCONNECTED") continue; throw error; } } } catch (error) { if (helper.status === "checking" || helper.status === "running") helperState("error", error.message); throw error; } } async function checkHelper() { if (helperStartTask) { try { await helperStartTask; } catch { /* The connection publishes its status. */ } return helper; } if (nativePort && helper.status === "running") { try { const ping = await nativeRequest("ping"); if (!ping.alive) throw new Error("Помощник не подтвердил связь."); helperState("running", "Помощник отвечает. Загрузка и обработка доступны.", helper.hardware, helper.compatibility); } catch (error) { if (helper.status === "running") helperState("error", error.message); } } if (!nativePort || helper.status !== "running") { try { await startHelper(); } catch { /* Missing/offline status is returned below. */ } } return helper; } async function notify(job) { const previous = notificationTimes.get(job.id) || 0; if (job.status === "running" && Date.now() - previous < 1500) return; notificationTimes.set(job.id, Date.now()); const id = `ytdl-${job.id}`; try { if (job.status === "running") { const options = { type: "progress", iconUrl: "icons/128.png", title: job.title || "YouTubeDL", message: [progressText(job), downloadSizeText(job) || processingText(job)].filter(Boolean).join("\n"), progress: Number.isFinite(job.percent) ? Math.round(job.percent) : 0, silent: true }; if (!await chrome.notifications.update(id, options)) await chrome.notifications.create(id, options); } else { await chrome.notifications.clear(id); if (job.status !== "cancelled") await chrome.notifications.create(id, { type: "basic", iconUrl: "icons/128.png", title: job.status === "complete" ? job.kind === "update" ? "Обновление компонентов" : job.kind === "dependency" ? "Установка завершена" : "Файл сохранён" : "Ошибка", message: job.result?.filename || job.result?.title || job.error || "Готово", silent: job.status !== "error", ...(job.status === "complete" && job.result?.path ? { buttons: [{ title: "Показать файл" }] } : {}), }); } } catch { /* The on-page toast remains available if OS notifications are disabled. */ } } async function onJob(update) { await ready; if (update.error) update = { ...update, error: errorText(update.error) }; const context = jobContexts.get(update.id) || {}; const job = stabilizeDownloadSpeed({ ...jobs[update.id], ...update, speed: update.speed, title: update.result?.title || context.title || update.title || jobs[update.id]?.title, tabId: context.tabId ?? jobs[update.id]?.tabId }, jobs[update.id]); jobs[job.id] = job; broadcast({ event: "job", job }); if (Number.isInteger(job.tabId)) chrome.tabs.sendMessage(job.tabId, { type: "JOB_PROGRESS", job }).catch(() => {}); if (job.status !== "running" || !job.savedAt || Date.now() - job.savedAt > 1000) { job.savedAt = Date.now(); saveJobs(); } notify(job); if (job.kind === "dependency" && job.status !== "running" && nativePort) startHelper().catch(() => {}); if (job.kind === "update" && job.status === "complete" && job.result?.restartRequired && nativePort) { const previous = nativePort; disconnect("Перезапускаю помощник с обновлёнными компонентами…", "checking"); previous.disconnect(); startHelper().catch(() => {}); } chrome.action.setBadgeText({ text: Object.values(jobs).some(item => item.status === "running") ? "↓" : "" }); chrome.action.setBadgeBackgroundColor({ color: "#20a9de" }); } async function handleUi(action, payload) { await ready; if (action === "session_status") return readSession(Number.isInteger(payload.tabId) ? payload.tabId : null); if (action === "session_enable") { if (payload.enabled && !await chrome.permissions.contains(SESSION_PERMISSION)) throw new Error("Разрешите использовать вход в YouTube в окне браузера."); await chrome.storage.local.set({ youtubeSession: payload.enabled === true }); inspectionCache.clear(); if (!payload.enabled) await chrome.permissions.remove(SESSION_PERMISSION); return readSession(Number.isInteger(payload.tabId) ? payload.tabId : null); } if (action === "state") return { jobs: Object.values(jobs), helper }; if (action === "open_history") { const theme = ["light", "dark"].includes(payload.theme) ? payload.theme : "auto"; await chrome.tabs.create({ url: chrome.runtime.getURL(`history.html?theme=${theme}`) }); return { opened: true }; } if (action === "helper_status") return checkHelper(); if (action === "hello" || action === "helper_start") return startHelper(); if (action === "update_settings") { const settings = await nativeRequest(action, { enabled: payload.enabled }); if (helper.hardware) helper.hardware.updateSettings = settings; helperState(helper.status, helper.message, helper.hardware, helper.compatibility); return settings; } if (action === "helper_remove") { if (!nativePort || helper.status !== "running") throw new Error("Сначала подключите помощник."); if (Object.values(jobs).some(job => job.status === "running")) throw new Error("Дождитесь завершения загрузок перед удалением помощника."); const result = await nativeRequest("helper_uninstall"); const old = nativePort; disconnect("Помощник удалён. Для установки скачайте и запустите установщик.", "missing"); old?.disconnect(); return result; } if (!["hello", "inspect", "pick_folder", "download", "dependency_install", "helper_update", "cancel", "reveal", "jobs"].includes(action)) throw new Error("Неизвестная операция"); if (["inspect", "download"].includes(action) && !canonicalUrl(payload.url)) throw new Error("Нужна ссылка на видео YouTube"); if (["inspect", "download", "dependency_install", "helper_update"].includes(action) && (!nativePort || helper.status !== "running")) await startHelper(); if (["inspect", "download"].includes(action)) payload = await mediaPayload(payload); if (action === "inspect") return inspectVideo(payload); if (action === "download" && payload.workFolder) { if (!nativePort || helper.status !== "running") await startHelper(); if (helper.hardware?.supportsWorkFolder !== true) throw new Error("Для отдельной рабочей папки установите помощник 1.3.1 или новее."); } if (["download", "dependency_install", "helper_update"].includes(action)) { const title = String(payload.title || (action === "helper_update" ? "Обновление компонентов" : "Видео")).slice(0, 300); const tabId = Number.isInteger(payload.tabId) ? payload.tabId : null; const result = await nativeRequest(action, payload); jobContexts.set(result.jobId, { title, tabId }); // Worker events may arrive immediately after the response, before this microtask. onJob({ ...jobs[result.jobId], id: result.jobId, title, tabId, url: payload.url, status: jobs[result.jobId]?.status || "running", stage: jobs[result.jobId]?.stage || "preparing", kind: action === "helper_update" ? "update" : action === "dependency_install" ? "dependency" : "download", ...(action === "helper_update" ? { canCancel: false } : {}), createdAt: jobs[result.jobId]?.createdAt || Date.now() / 1000 }); return result; } return nativeRequest(action, payload); } async function mediaPayload(payload) { // Ignore cookies supplied by callers; only the browser API is a session source. const { browserCookies: ignoredCookies, ...options } = payload; const session = await readSession(Number.isInteger(payload.tabId) ? payload.tabId : null, true); if (session.status === "connected" && !helper.hardware?.supportsBrowserCookies) { throw new Error("Обновите помощник для подключения входа: нажмите «Установить», запустите скачанный установщик и обновите расширение."); } return { ...options, ...(session.status === "connected" ? { browserCookies: session.cookies } : {}) }; } async function inspectVideo(payload) { // Only the summary is cached; URLs for downloading are refreshed by the worker. const fingerprint = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(payload.browserCookies || []))); const key = `${canonicalUrl(payload.url)}|${payload.tabId || ""}|${[...new Uint8Array(fingerprint)].map(byte => byte.toString(16).padStart(2, "0")).join("")}`; const cached = inspectionCache.get(key); if (cached && cached.expires > Date.now() && !payload.refresh) return cached.task; const entry = { expires: Date.now() + 45000, task: nativeRequest("inspect", payload) }; inspectionCache.delete(key); inspectionCache.set(key, entry); if (inspectionCache.size > 20) inspectionCache.delete(inspectionCache.keys().next().value); try { return await entry.task; } catch (error) { if (inspectionCache.get(key) === entry) inspectionCache.delete(key); throw error; } } async function quickDownload(url, tabId) { url = canonicalUrl(url); if (!url) throw new Error("Нужна ссылка на видео YouTube."); const { preferences = {} } = await chrome.storage.local.get("preferences"); if (!preferences.folder) return { needSetup: true, error: "Сначала выберите папку и сохраните настройки скачивания." }; if (Object.values(jobs).filter(job => job.status === "running").length >= 2) { return { needSetup: true, error: "Уже выполняются две операции. Дождитесь завершения одной." }; } const temporaryId = `preparing-${crypto.randomUUID()}`; jobContexts.set(temporaryId, { tabId, title: "Подготовка скачивания" }); await onJob({ id: temporaryId, url, status: "running", stage: "preparing", percent: null, canCancel: false, transient: true, createdAt: Date.now() / 1000 }); try { await startHelper(); if (helper.hardware?.ready === false) throw new Error("Установите недостающие компоненты через настройки скачивания."); const metadata = await handleUi("inspect", { url, tabId }); const result = await handleUi("download", { ...preferenceOptions(metadata, preferences), url, title: metadata.title, tabId }); delete jobs[temporaryId]; jobContexts.delete(temporaryId); broadcast({ event: "remove_job", jobId: temporaryId }); chrome.tabs.sendMessage(tabId, { type: "REMOVE_JOB", jobId: temporaryId }).catch(() => {}); chrome.notifications.clear(`ytdl-${temporaryId}`).catch(() => {}); saveJobs(); return { ok: true, jobId: result.jobId }; } catch (error) { await onJob({ id: temporaryId, status: "error", stage: "error", error: error.message, transient: false }); return { ok: false, needSetup: helper.status !== "running" || /папк|компонент|вход|авторизац|бот|помощник|проверку/i.test(error.message), error: error.message }; } } chrome.runtime.onConnect.addListener(port => { const pageUrl = port.sender?.url?.split(/[?#]/)[0]; if (port.name !== "ytdl-ui" || port.sender?.id !== chrome.runtime.id || !["downloader.html", "history.html"].some(page => pageUrl === chrome.runtime.getURL(page))) return; clients.add(port); ready.then(() => port.postMessage({ event: "state", jobs: Object.values(jobs), helper })).catch(() => {}); port.onDisconnect.addListener(() => clients.delete(port)); port.onMessage.addListener(async message => { try { port.postMessage({ id: message.id, ok: true, result: await handleUi(message.action, message.payload || {}) }); } catch (error) { // Save failures before a worker was created; worker failures arrive through onJob. const titles = { inspect: "Получение качества видео", download: "Подготовка скачивания", dependency_install: "Установка компонента", helper_update: "Обновление компонентов" }; if (titles[message.action]) { const payload = message.payload || {}; await onJob({ id: `error-${crypto.randomUUID()}`, title: String(payload.title || titles[message.action]).slice(0, 300), url: canonicalUrl(payload.url), status: "error", stage: "error", error: error.message, kind: message.action === "inspect" ? "inspection" : message.action === "helper_update" ? "update" : message.action === "dependency_install" ? "dependency" : "download", createdAt: Date.now() / 1000 }).catch(() => {}); } try { port.postMessage({ id: message.id, ok: false, error: error.message }); } catch {} } }); }); async function openWindow(url, tabId, theme = "auto") { const query = new URLSearchParams({ url: canonicalUrl(url) || "", tabId: String(tabId || ""), theme }); await chrome.windows.create({ url: chrome.runtime.getURL(`downloader.html?${query}`), type: "popup", width: 520, height: 820 }); } chrome.action.onClicked.addListener(tab => openWindow(tab.url, tab.id)); chrome.runtime.onMessage.addListener((message, sender, respond) => { if (message.type === "CLOSE_MENU" && sender.id === chrome.runtime.id && sender.tab && sender.url?.startsWith(chrome.runtime.getURL("downloader.html"))) { chrome.tabs.sendMessage(sender.tab.id, { type: "CLOSE_MENU" }) .then(() => respond({ ok: true }), () => respond({ ok: true })); return true; } let isYouTube = false; try { const origin = new URL(sender.url); isYouTube = origin.protocol === "https:" && ["www.youtube.com", "m.youtube.com"].includes(origin.hostname); } catch {} if (sender.id !== chrome.runtime.id || !sender.tab || !isYouTube) return false; const tabId = sender.tab.id; if (message.type === "QUICK_DOWNLOAD") { quickDownload(message.url, tabId).then(respond, error => respond({ needSetup: true, error: error.message })); return true; } if (message.type === "GET_CONTEXT") { respond({ tabId, jobs: Object.values(jobs).filter(job => job.tabId === tabId) }); return false; } if (message.type === "OPEN_WINDOW") { openWindow(sender.url, tabId).then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message })); return true; } if (["CANCEL_JOB", "REVEAL_JOB"].includes(message.type) && jobs[message.jobId]?.tabId === tabId) { nativeRequest(message.type === "CANCEL_JOB" ? "cancel" : "reveal", { jobId: message.jobId }) .then(result => respond({ ok: true, result }), error => respond({ ok: false, error: error.message })); return true; } return false; }); chrome.notifications.onButtonClicked.addListener((id, index) => { if (index === 0 && id.startsWith("ytdl-")) nativeRequest("reveal", { jobId: id.slice(5) }).catch(() => {}); });