importScripts('i18n.js', 'provider-adapters.js'); const currentLanguage=async()=>NASDropI18n.resolve((await settings()).language,chrome.i18n.getUILanguage()); const translatedError=async error=>NASDropI18n.error(await currentLanguage(),error.message); const storageReady = chrome.storage.local.setAccessLevel({accessLevel: 'TRUSTED_CONTEXTS'}); const pageTransfers = new Map(); const SENDNOW_ARM_MS = 60000; const MENU_LINK = "nasdrop-send-link"; const MENU_PAGE = "nasdrop-send-page"; const ACTIVE_STATUSES = new Set([ "inspecting", "queued", "ready", "downloading", "waiting_processing", "verifying", "extracting", "publishing", "stopping", ]); const JOB_ALARM = 'nasdrop-job-monitor'; let jobsFlight = null, jobsCache = null, jobsCachedAt = 0, monitorFailures = 0; const popupPorts = new Set(); async function getJobs(force = false) { if (jobsFlight) return jobsFlight; if (!force && jobsCache && Date.now() - jobsCachedAt < 2000) return jobsCache; jobsFlight = (async () => { try { const result = await api('/api/jobs', {signal:AbortSignal.timeout(15000)}); if (!Array.isArray(result.jobs)) throw new Error('Invalid job list received.'); jobsCache = result; jobsCachedAt = Date.now(); monitorFailures = 0; await updateBadge(result.jobs); const previous = (await chrome.storage.session.get({passwordJobs:[]})).passwordJobs; const waiting = result.jobs.filter(job => job.status === 'password_required'); const announced = []; for (const job of waiting) { if (!previous.includes(job.id)) { try { const language=await currentLanguage(); await chrome.notifications.create(`nasdrop-password-${job.id}`, { type:'basic', iconUrl:'icons/nasdrop-256.png', title:NASDropI18n.t(language,'passwordTitle'), message:`${job.name}\n${NASDropI18n.t(language,'passwordMessage')}`, }); } catch { continue; } } announced.push(job.id); } await chrome.storage.session.set({passwordJobs:announced}); if (result.jobs.some(job => ACTIVE_STATUSES.has(job.status))) await chrome.alarms.create(JOB_ALARM,{delayInMinutes:1}); else await chrome.alarms.clear(JOB_ALARM); return result; } catch (error) { if (error.status === 401) { jobsCache = null; await chrome.alarms.clear(JOB_ALARM); } else await chrome.alarms.create(JOB_ALARM,{delayInMinutes:Math.min(5,2 ** monitorFailures++)}); throw error; } finally { jobsFlight = null; } })(); return jobsFlight; } chrome.runtime.onConnect.addListener(port => { if (port.name !== 'nasdrop-popup' || port.sender?.url !== chrome.runtime.getURL('popup.html')) return; popupPorts.add(port); port.onDisconnect.addListener(() => popupPorts.delete(port)); }); chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name !== JOB_ALARM) return; if (popupPorts.size) { chrome.alarms.create(JOB_ALARM,{delayInMinutes:1}); return; } getJobs().catch(() => {}); }); function normalizeBaseUrl(value) { const url = new URL(String(value || "").trim()); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('NASDrop address must use HTTP or HTTPS.'); if (url.username || url.password) throw new Error('Enter a server address without embedded credentials.'); url.hash = ''; url.search = ''; return url.href.replace(/\/$/, ''); } async function settings() { await storageReady; return chrome.storage.local.get({ baseUrl: '', token: '', autoExtract: true, username: '', language:'auto' }); } async function api(path, init = {}, requireAuth = true) { const saved = await settings(); if (!saved.baseUrl) throw new Error('Open the NASDrop extension and enter your server address.'); if (requireAuth && !saved.token) throw Object.assign(new Error('Sign in to NASDrop first.'), { status: 401 }); const headers = { 'content-type': 'application/json', ...(init.headers || {}) }; if (requireAuth) headers.authorization = `Bearer ${saved.token}`; let response; try { response = await fetch(`${normalizeBaseUrl(saved.baseUrl)}${path}`, { ...init, headers, redirect:'error', credentials:'omit' }); } catch (_) { throw new Error('Could not reach the NASDrop server. Check its address and network connection.'); } const payload = await response.json().catch(() => ({})); if (!response.ok) { if (response.status === 401 && requireAuth) await chrome.storage.local.remove('token'); const error = new Error(payload.error || `NASDrop request failed (${response.status}).`); error.status = response.status; error.code = payload.code || ''; throw error; } return payload; } async function updateBadge(jobs) { const count = (jobs || []).filter(job => ACTIVE_STATUSES.has(job.status)).length; await chrome.action.setBadgeBackgroundColor({ color: '#2563eb' }); await chrome.action.setBadgeText({ text: count ? String(Math.min(count, 99)) : '' }); } async function getState() { const saved = await settings(); if (!saved.baseUrl || !saved.token) return { ...saved, connected: false, jobs: [] }; try { const [status, jobResult] = await Promise.all([api('/api/status'), getJobs()]); await updateBadge(jobResult.jobs); return { ...saved, connected: true, status, jobs: jobResult.jobs }; } catch (error) { return { ...saved, token: error.status === 401 ? '' : saved.token, connected: false, jobs: [], passwordChangeRequired:error.code === 'password_change_required', error: error.message }; } } async function login({ baseUrl, username, password }) { const normalized = normalizeBaseUrl(baseUrl); const previous = await settings(); if (previous.baseUrl !== normalized) await chrome.storage.local.remove(['token', 'username']); jobsCache = null; await chrome.storage.session.remove('passwordJobs'); await chrome.storage.session.remove('sendNowArm'); await chrome.storage.local.set({ baseUrl: normalized }); const result = await api('/api/login', { method: 'POST', body: JSON.stringify({ username: String(username || '').trim(), password: String(password || '') }), }, false); await chrome.storage.local.set({ token: result.token, username: result.username || String(username || '').trim() }); if (result.password_change_required) { return { ...(await settings()), connected:false, jobs:[], passwordChangeRequired:true }; } return getState(); } async function logout() { try { await api('/api/logout', { method: 'POST', body: '{}' }); } catch (_) {} await chrome.storage.local.remove(['token', 'username']); jobsCache = null; await chrome.alarms.clear(JOB_ALARM); await chrome.storage.session.remove('passwordJobs'); await chrome.storage.session.remove('sendNowArm'); await updateBadge([]); return { ok: true }; } async function submitUrl(rawUrl, notify = false, handoffPage = '', options = {}) { let source; try { source = new URL(String(rawUrl || '').trim()); if (!['http:', 'https:'].includes(source.protocol)) throw new Error(); } catch (_) { throw new Error('Enter a valid HTTP or HTTPS download link.'); } const saved = await settings(); const sourceProvider = NASDropProviders.provider(source.href); const downloadKey = String(options.downloadKey || ''); if (downloadKey && sourceProvider !== 'gigafile') throw new Error('A GigaFile download key can only be used with a GigaFile link.'); if (downloadKey.length > 4) throw new Error('GigaFile download key must be 1–4 characters.'); const common = {target:'',extract:saved.autoExtract !== false,password:saved.autoExtract === false ? '' : String(options.password || '')}; let asyncEnqueueSupported = sourceProvider === 'gigafile'; if (sourceProvider === 'gofile') { if (typeof options.asyncEnqueueSupported === 'boolean') asyncEnqueueSupported=options.asyncEnqueueSupported; else { const status=await api('/api/status'); asyncEnqueueSupported=Array.isArray(status.async_enqueue_providers) && status.async_enqueue_providers.includes('gofile'); } } if (asyncEnqueueSupported) { const body={url:source.href,...common}; if (downloadKey) body.download_key=downloadKey; const started=await api('/api/enqueue',{method:'POST',body:JSON.stringify(body)}).catch(error=>{ if (!error.status) throw new Error('Result unknown. Check NASDrop jobs before retrying.'); throw error; }); let fallbackName=source.pathname.split('/').filter(Boolean).at(-1) || `${sourceProvider === 'gofile' ? 'GoFile' : 'GigaFile'} download`; try { fallbackName=decodeURIComponent(fallbackName); } catch (_) { /* Keep the encoded safe fallback. */ } const file={name:started.job?.name || fallbackName}; if (notify) await chrome.notifications.create({type:'basic',iconUrl:'icons/nasdrop-256.png',title:'NASDrop',message:started.count > 1 ? NASDropI18n.t(await currentLanguage(),'addedMany',{count:started.count}) : file.name}).catch(()=>{}); try { await getJobs(true); } catch (_) { /* The accepted job remains successful. */ } return {file,...started}; } const handoffProvider = handoffPage ? NASDropProviders.provider(handoffPage) : ''; const inspectBody = ['akirabox','vikingfile','sendnow','xshare'].includes(handoffProvider) ? {url:NASDropProviders.share(handoffPage), resolved_url:source.href, provider:handoffProvider} : {url:source.href}; const inspected = await api('/api/inspect', { method: 'POST', body: JSON.stringify(inspectBody), }); const startBody = { ...inspected.file, ...common }; const started = await api('/api/start', { method: 'POST', body: JSON.stringify(startBody), }).catch(error => { if (!error.status) throw new Error('Result unknown. Check NASDrop jobs before retrying.'); throw error; }); if (notify) { const title = NASDropI18n.t(await currentLanguage(),'addedMany',{count:started.count}); await chrome.notifications.create({ type: 'basic', iconUrl: 'icons/nasdrop-256.png', title: 'NASDrop', message: started.count > 1 ? title : inspected.file.name, }).catch(() => {}); } try { const jobResult = await getJobs(true); await updateBadge(jobResult.jobs); } catch (_) { /* An accepted job remains successful even if refresh fails. */ } return { file: inspected.file, ...started }; } async function notifyError(error) { await chrome.notifications.create({ type: 'basic', iconUrl: 'icons/nasdrop-256.png', title: 'NASDrop', message: await translatedError(error), }); } async function updateMenus() { const language=await currentLanguage(); await new Promise(resolve=>chrome.contextMenus.removeAll(resolve)); chrome.contextMenus.create({id:MENU_LINK,title:NASDropI18n.t(language,'menuLink'),contexts:['link']}); chrome.contextMenus.create({id:MENU_PAGE,title:NASDropI18n.t(language,'menuPage'),contexts:['page']}); } chrome.runtime.onInstalled.addListener(()=>{updateMenus().catch(()=>{});}); chrome.runtime.onStartup?.addListener(()=>{updateMenus().catch(()=>{});}); chrome.contextMenus.onClicked.addListener((info, tab) => { const url = info.menuItemId === MENU_LINK ? info.linkUrl : (info.pageUrl || tab?.url); submitUrl(url, true).catch(notifyError); }); async function activeSendNowArm() { const arm = (await chrome.storage.session.get({sendNowArm:null})).sendNowArm; if (!arm || arm.expiresAt <= Date.now()) { if (arm) await chrome.storage.session.remove('sendNowArm'); return null; } return arm; } async function sendNowResult(tabId, result) { await chrome.tabs.sendMessage(tabId,{type:'sendNowResult',...result}).catch(() => {}); } async function captureSendNowDownload(item) { const arm = await activeSendNowArm(); if (!arm) return; let referrer; try { referrer = new URL(item.referrer || ''); } catch { return; } if (referrer.protocol !== 'https:' || !['send.now','www.send.now'].includes(referrer.hostname) || referrer.pathname !== '/' || referrer.search || referrer.hash) return; const url = item.finalUrl || item.url || ''; if (!NASDropProviders.allowedSubmission(arm.source, url)) return; await chrome.storage.session.remove('sendNowArm'); await chrome.downloads.cancel(item.id).catch(() => {}); await chrome.downloads.erase({id:item.id}).catch(() => {}); try { await submitFromPage(arm.source, url); await sendNowResult(arm.tabId,{ok:true}); } catch (error) { await sendNowResult(arm.tabId,{ok:false,error:await translatedError(error)}); } } chrome.downloads.onCreated.addListener(item => { void captureSendNowDownload(item); }); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (sender.id !== chrome.runtime.id) return false; // Provider content scripts never receive settings, credentials or job metadata. if (sender.tab) { const page = sender.url || ''; if (sender.frameId !== 0) return false; let operation; if (message?.type === 'pageArmDownload') { let current, source; try { current=new URL(page); source=NASDropProviders.share(message.source); } catch { return false; } if (!source || NASDropProviders.provider(source) !== 'sendnow' || current.protocol !== 'https:' || !['send.now','www.send.now'].includes(current.hostname) || current.pathname !== '/' || current.search || current.hash || current.origin !== new URL(source).origin) return false; operation = pageReadiness(source).then(async readiness => { if (!readiness.ready) throw Object.assign(new Error('Sign in to NASDrop first.'), {code:'login'}); if (readiness.handoffSupported === false) throw Object.assign(new Error('This NASDrop server does not support browser handoff for this site yet.'), {code:'serverUnsupported'}); const active = await activeSendNowArm(); if (active && active.tabId !== sender.tab.id) throw new Error('Another Send.now handoff is already waiting for its download.'); await chrome.storage.session.set({sendNowArm:{tabId:sender.tab.id,source,expiresAt:Date.now()+SENDNOW_ARM_MS}}); return {armed:true,language:readiness.language}; }); } else if (message?.type === 'pageArmXShare') { const source=NASDropProviders.share(message.source); if (!source || source !== NASDropProviders.share(page) || NASDropProviders.provider(source) !== 'xshare') return false; operation = pageReadiness(source).then(readiness => { if (!readiness.ready) throw Object.assign(new Error('Sign in to NASDrop first.'), {code:'login'}); if (readiness.handoffSupported === false) throw Object.assign(new Error('This NASDrop server does not support browser handoff for this site yet.'), {code:'serverUnsupported'}); return {armed:true,language:readiness.language}; }); } else if (!NASDropProviders.share(page)) return false; else if (message?.type === 'pageReady') { operation = pageReadiness(page); } else if (message?.type === 'pageSubmit' && NASDropProviders.allowedSubmission(page, message.url)) { const key = `${sender.tab.id}:${message.url}`; if (pageTransfers.has(key)) operation = pageTransfers.get(key); else { operation = submitFromPage(page, message.url, {downloadKey:message.downloadKey}).then(result => ({count: result.count})); pageTransfers.set(key, operation); operation.finally(() => pageTransfers.delete(key)).catch(() => {}); } } else return false; operation.then(result => sendResponse({ok:true, result})).catch(async error => sendResponse({ok:false, error:await translatedError(error), code:error.code})); return true; } if (sender.url !== chrome.runtime.getURL('popup.html')) return false; const actions = { getState, getJobs: () => getJobs(Boolean(message.force)), clearCompleted: async () => { const result = await api('/api/jobs/completed/clear',{method:'POST',body:'{}'}); jobsCache = null; return result; }, jobPassword: () => updateJob(message, 'password'), jobDownloadKey: () => updateJob(message, 'download-key'), jobPause: () => controlJob(message, 'pause'), jobResume: () => controlJob(message, 'resume'), jobDelete: () => controlJob(message, 'delete'), jobProcessing: () => updateJob(message, 'processing'), login: () => login(message), logout, submit: () => submitFromPopup(message), savePreferences: async () => { const values={}; if(typeof message.autoExtract==='boolean')values.autoExtract=message.autoExtract; if(message.language!==undefined) { if(!['auto',...NASDropI18n.locales].includes(message.language))throw new Error('Invalid language.'); values.language=message.language; } await chrome.storage.local.set(values); if(values.language!==undefined)await updateMenus(); return { ok: true }; }, }; const action = actions[message?.type]; if (!action) return false; action().then(result => sendResponse({ ok: true, result })).catch(async error => { sendResponse({ ok: false, error: await translatedError(error), status: error.status || 0 }); }); return true; }); async function controlJob(message, action) { if (!/^[a-f0-9]{12}$/.test(message.id || '')) throw new Error('Invalid job ID.'); const path=action==='delete' ? '/api/jobs/delete' : `/api/jobs/${message.id}/${action}`; const result=await api(path,{method:'POST',body:JSON.stringify(action==='delete'?{ids:[message.id]}:{})}); jobsCache=null; await chrome.alarms.create(JOB_ALARM,{delayInMinutes:1}); return result; } async function updateJob(message, action) { if (!/^[a-f0-9]{12}$/.test(message.id || '')) throw new Error('Invalid job ID.'); let body; if (action === 'password') body = {password:String(message.password || '')}; else if (action === 'download-key') { const downloadKey=String(message.downloadKey || ''); if (downloadKey.length < 1 || downloadKey.length > 4) throw new Error('GigaFile download key must be 1–4 characters.'); body={download_key:downloadKey}; } else body = {extract:message.extract}; if (action === 'processing') { if (typeof body.extract !== 'boolean') throw new Error('Invalid extraction option.'); if (message.password) body.password = String(message.password); } const result = await api(`/api/jobs/${message.id}/${action}`,{method:'POST',body:JSON.stringify(body)}); jobsCache = null; await chrome.alarms.create(JOB_ALARM,{delayInMinutes:1}); return result; } async function pageReadiness(page) { const saved = await settings(); const ready = Boolean(saved.baseUrl && saved.token); const language=NASDropI18n.resolve(saved.language,chrome.i18n.getUILanguage()); const provider = NASDropProviders.provider(page); if (!ready || !['akirabox','vikingfile','sendnow','xshare','gigafile','gofile'].includes(provider)) return {ready,language}; const status = await api('/api/status'); if (provider === 'gigafile') return {ready:true,language,gigafileDownloadKeySupported:status.gigafile_download_key === true}; if (provider === 'gofile') return {ready:true,language,asyncEnqueueSupported:Array.isArray(status.async_enqueue_providers) && status.async_enqueue_providers.includes('gofile')}; return {ready:true, language,handoffSupported:Array.isArray(status.browser_handoff_providers) && status.browser_handoff_providers.includes(provider)}; } async function submitFromPopup(message) { if (message.downloadKey) { const status=await api('/api/status'); if (status.gigafile_download_key !== true) { const error=new Error('This NASDrop server does not support GigaFile download keys yet.'); error.code='serverUnsupported'; throw error; } } return submitUrl(message.url,false,'',message); } async function submitFromPage(page, url, options = {}) { const readiness = await pageReadiness(page); if (readiness.handoffSupported === false) { const error = new Error('This NASDrop server does not support browser handoff for this site yet.'); error.code = 'serverUnsupported'; throw error; } if (NASDropProviders.provider(page) === 'gigafile' && options.downloadKey && !readiness.gigafileDownloadKeySupported) { const error = new Error('This NASDrop server does not support GigaFile download keys yet.'); error.code = 'serverUnsupported'; throw error; } return submitUrl(url, false, page, {...options,asyncEnqueueSupported:readiness.asyncEnqueueSupported}); }