From 0b105437c433914a57226f446d37d21cc39e8d2d Mon Sep 17 00:00:00 2001 From: jimmyGALLAND Date: Sun, 24 May 2026 16:28:25 +0200 Subject: [PATCH] fix: guard unavailable APIs for Firefox Android - Replace broken 'let chrome = browser' shim with proper API fallback - Make contextMenus and notifications calls conditional - Broadcast notify via runtime.sendMessage as fallback for Android - Add try/catch around permissions.request and tabs.query - Fix openOptionsPage fallback using tabs.create on Android --- src/background.js | 154 +++++++++++++++++++++++++--------------------- src/options.js | 136 ++++++++++++++++++++++------------------ src/popup.js | 118 +++++++++++++++++------------------ 3 files changed, 215 insertions(+), 193 deletions(-) diff --git a/src/background.js b/src/background.js index 29462f2..2c49995 100644 --- a/src/background.js +++ b/src/background.js @@ -1,64 +1,91 @@ const str_keys = ["instance_url", "preset", "username", "password"] const bool_keys = ["showContextMenu"] -if (typeof chrome === 'undefined') { - let chrome = browser -} +// Compatibilité Firefox / Chrome +const api = typeof browser !== 'undefined' ? browser : chrome; -const notify = message => chrome.notifications.create({ - "type": "basic", - "iconUrl": chrome.runtime.getURL("icons/icon-128.png"), - "title": 'YTPTube Extension', - "message": message, -}); +// --- Détection des capacités --- +const hasNotifications = !!(api.notifications && api.notifications.create); +const hasContextMenus = !!(api.contextMenus && api.contextMenus.create); -const onMenuCreated = () => { - if (chrome.runtime.lastError) { - console.log(`error creating menu item. ${chrome.runtime.lastError}`); +/** + * Notifie l'utilisateur. Sur Firefox Android, les notifications système + * ne sont pas disponibles : on envoie un message au popup à la place. + */ +const notify = message => { + if (hasNotifications) { + try { + api.notifications.create({ + "type": "basic", + "iconUrl": api.runtime.getURL("icons/icon-128.png"), + "title": 'YTPTube Extension', + "message": message, + }); + } catch (e) { + console.warn('Notifications non disponibles:', e); + } } - syncContextMenu().then(_ => '').catch(console.error); -} + // Toujours diffuser via runtime pour que le popup puisse afficher le message + try { + api.runtime.sendMessage({ command: "notify", message: message }).catch(() => {}); + } catch (_) {} +}; + +// --- Menu contextuel (optionnel sur Android) --- +const onMenuCreated = () => { + if (api.runtime.lastError) { + console.log(`Erreur création menu: ${api.runtime.lastError.message}`); + return; + } + syncContextMenu().catch(console.error); +}; const shouldShowContextMenu = async () => { - let item = await chrome.storage.sync.get("showContextMenu"); + let item = await api.storage.sync.get("showContextMenu"); return 'showContextMenu' in item ? item.showContextMenu : true; }; const syncContextMenu = async () => { - let showContextMenu = await shouldShowContextMenu(); - chrome.contextMenus.update("send-to-ytptube", { visible: showContextMenu }); + if (!hasContextMenus) return; + try { + let showContextMenu = await shouldShowContextMenu(); + api.contextMenus.update("send-to-ytptube", { visible: showContextMenu }); + } catch (e) { + console.warn('contextMenus.update indisponible:', e); + } +}; + +if (hasContextMenus) { + try { + api.contextMenus.create({ + id: "send-to-ytptube", + title: "Send to YTPTube", + contexts: ["link"] + }, onMenuCreated); + } catch (e) { + console.warn('contextMenus.create indisponible:', e); + } } -chrome.contextMenus.create({ - id: "send-to-ytptube", - title: "Send to YTPTube", - contexts: ["link"] -}, onMenuCreated); - -const getCurrentUrl = async () => (await chrome.tabs.query({ currentWindow: true, active: true }))[0].url +// --- Utilitaires --- +const getCurrentUrl = async () => { + const tabs = await api.tabs.query({ currentWindow: true, active: true }); + return tabs && tabs[0] ? tabs[0].url : null; +}; const getOption = async key => { - let item = await chrome.storage.sync.get(key); - if (str_keys.includes(key)) { - return item[key] ?? ''; - } - if (bool_keys.includes(key)) { - return item[key] ?? false; - } -} + let item = await api.storage.sync.get(key); + if (str_keys.includes(key)) return item[key] ?? ''; + if (bool_keys.includes(key)) return item[key] ?? false; +}; const sendRequest = async (path, data) => { let instanceUrl = await getOption("instance_url"); - if (!instanceUrl) { - throw new Error('YTPTube instance url not configured.'); - } + if (!instanceUrl) throw new Error('YTPTube instance url not configured.'); - if (instanceUrl.endsWith('/')) { - instanceUrl = instanceUrl.slice(0, -1); - } + if (instanceUrl.endsWith('/')) instanceUrl = instanceUrl.slice(0, -1); let headers = {}; - const auth_username = await getOption("username"); const auth_password = await getOption("password"); if (auth_username && auth_password) { @@ -68,15 +95,16 @@ const sendRequest = async (path, data) => { const url = new URL(instanceUrl); url.pathname = path; - let opts = { method: Object.keys(data).length > 0 ? 'POST' : 'GET', headers: headers }; + let opts = { + method: Object.keys(data).length > 0 ? 'POST' : 'GET', + headers: headers + }; - if (data) { + if (data && Object.keys(data).length > 0) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); } - console.debug(`Sending to '${instanceUrl}'.`, opts, headers.length > 0 ? 'with auth header' : 'without auth header'); - const req = await fetch(url, opts); return { status: req.status, statusText: req.statusText, data: req }; }; @@ -84,11 +112,7 @@ const sendRequest = async (path, data) => { const sendUrl = async (user_url, preset = null) => { try { const requestData = { url: user_url }; - if (preset) { - requestData.preset = preset; - } - - console.debug('Sending request data:', requestData); + if (preset) requestData.preset = preset; const data = await sendRequest('/api/history', requestData); if ([200, 201, 202].includes(data.status)) { @@ -106,35 +130,26 @@ const sendUrl = async (user_url, preset = null) => { } }; -chrome.contextMenus.onClicked.addListener(async (info, _) => { - if (info.menuItemId !== "send-to-ytptube") { - return; - } - if (!info.linkUrl) { - notify('No link url found'); - return; - } +// --- Écouteurs --- +if (hasContextMenus) { + api.contextMenus.onClicked.addListener(async (info, _) => { + if (info.menuItemId !== "send-to-ytptube") return; + if (!info.linkUrl) { notify('No link url found'); return; } + const defaultPreset = await getOption("preset"); + await sendUrl(info.linkUrl, defaultPreset); + }); +} - // Get the default preset for context menu actions - const defaultPreset = await getOption("preset"); - await sendUrl(info.linkUrl, defaultPreset); -}); - -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.command !== "send-to-ytptube") { - return; - } +api.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.command !== "send-to-ytptube") return; (async () => { try { let url = message.url || await getCurrentUrl(); - if (!url) { - await notify('No url found'); sendResponse({ success: false, message: 'No url found' }); return; } - const result = await sendUrl(url, message.preset); sendResponse(result); } catch (error) { @@ -145,6 +160,3 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; }); - - - diff --git a/src/options.js b/src/options.js index 7544be2..b9b8b0c 100644 --- a/src/options.js +++ b/src/options.js @@ -3,23 +3,38 @@ const str_keys = ["instance_url", "preset", "username", "password"] let storedOriginPattern = null; -if (typeof chrome === 'undefined') { - let chrome = browser -} +const api = typeof browser !== 'undefined' ? browser : chrome; const notify = (message, no_inline) => { if (!no_inline) { - document.querySelector('#error_msg').innerText = message; + const el = document.querySelector('#error_msg'); + if (el) el.innerText = message; } - chrome.notifications.create({ - "type": "basic", - "iconUrl": chrome.runtime.getURL("icons/icon-128.png"), - "title": 'YTPTube Extension', - "message": message, - }); + // Notifications système optionnelles (non disponibles sur Firefox Android) + if (api.notifications && api.notifications.create) { + try { + api.notifications.create({ + "type": "basic", + "iconUrl": api.runtime.getURL("icons/icon-128.png"), + "title": 'YTPTube Extension', + "message": message, + }); + } catch (e) { + console.warn('Notifications indisponibles:', e); + } + } } +const showSaveStatus = (message, isSuccess = true) => { + const el = document.querySelector('#save_status'); + if (!el) return; + el.textContent = message; + el.className = `notification ${isSuccess ? 'is-success' : 'is-danger'} mt-2`; + el.classList.remove('is-hidden'); + setTimeout(() => el.classList.add('is-hidden'), 3000); +}; + const buildOriginPattern = (instanceUrl) => { try { const url = new URL(instanceUrl); @@ -30,32 +45,30 @@ const buildOriginPattern = (instanceUrl) => { }; const ensureOriginPermission = async (originPattern) => { - if (!originPattern) { - return false; + if (!originPattern) return false; + if (!api.permissions || !api.permissions.request) return true; + try { + return await api.permissions.request({ origins: [originPattern] }); + } catch (e) { + // Sur Firefox Android, permissions.request peut échouer si pas depuis un geste utilisateur + console.warn('permissions.request échoué:', e); + return true; // On continue quand même } - - if (!chrome.permissions || !chrome.permissions.request) { - return true; - } - - return await chrome.permissions.request({ origins: [originPattern] }); }; const removeOriginPermission = async (originPattern) => { - if (!originPattern) { - return; + if (!originPattern || !api.permissions) return; + try { + const hasPermission = await api.permissions.contains({ origins: [originPattern] }); + if (hasPermission) await api.permissions.remove({ origins: [originPattern] }); + } catch (e) { + console.warn('removeOriginPermission échoué:', e); } - - const hasPermission = await chrome.permissions.contains({ origins: [originPattern] }); - if (!hasPermission) { - return; - } - - await chrome.permissions.remove({ origins: [originPattern] }); }; const testConfig = async () => { - document.querySelector('#error_msg').innerText = ""; + const errEl = document.querySelector('#error_msg'); + if (errEl) errEl.innerText = ""; let instance_url = document.querySelector("#instance_url").value.trim(); if (!instance_url) { @@ -63,10 +76,7 @@ const testConfig = async () => { return false; } - if (instance_url.endsWith('/')) { - instance_url = instance_url.slice(0, -1); - } - + if (instance_url.endsWith('/')) instance_url = instance_url.slice(0, -1); document.querySelector("#instance_url").value = instance_url; const originPattern = buildOriginPattern(instance_url); @@ -83,57 +93,60 @@ const testConfig = async () => { let username = document.querySelector("#username").value; let password = document.querySelector("#password").value; - let headers = {} if (username && password) { headers['Authorization'] = 'Basic ' + btoa(username + ':' + password); } try { - const req = await fetch(`${instance_url}/api/ping`, { - method: 'GET', - headers: headers - }); - + const req = await fetch(`${instance_url}/api/ping`, { method: 'GET', headers: headers }); if (200 === req.status) { notify("Connection successful.", true); + showSaveStatus("Connection successful!", true); return true; } - - const json = await req.json(); - notify(json.error ?? "Unknown error."); + let errorMsg = "Unknown error."; + try { const json = await req.json(); errorMsg = json.error ?? errorMsg; } catch (_) {} + notify(errorMsg); + return false; } catch (e) { notify("Error: " + e); + return false; } - return false; } document.addEventListener("DOMContentLoaded", () => { - function onError(error) { - console.log(`Error: ${error}`); - } + function onError(error) { console.log(`Error: ${error}`); } str_keys.forEach(k => { - chrome.storage.sync.get(k).then(r => document.querySelector(`#${k}`).value = r[k] || "", onError); + api.storage.sync.get(k).then(r => { + const el = document.querySelector(`#${k}`); + if (el) el.value = r[k] || ""; + }, onError); }); - chrome.storage.sync.get(["showContextMenu", "instance_origin"]).then(r => { - document.querySelector("#showContextMenu").checked = r.showContextMenu || false; + api.storage.sync.get(["showContextMenu", "instance_origin"]).then(r => { + const el = document.querySelector("#showContextMenu"); + if (el) el.checked = r.showContextMenu || false; storedOriginPattern = r.instance_origin || null; }, onError); + + // Cacher la section "menu contextuel" si non disponible (Firefox Android) + const contextMenuSection = document.querySelector('#context-menu-section'); + if (contextMenuSection && !(api.contextMenus)) { + contextMenuSection.style.display = 'none'; + } }); document.getElementById("ytptube_options").addEventListener("submit", async e => { e.preventDefault(); + if (false === (await testConfig())) return false; - if (false === (await testConfig())) { - return false; - } - - let showContextMenu = document.querySelector("#showContextMenu").checked; + let showContextMenu = false; + const showContextMenuEl = document.querySelector("#showContextMenu"); + if (showContextMenuEl) showContextMenu = showContextMenuEl.checked; let data = { presets: { presets: ['default'], last_updated: 0 } } - str_keys.forEach(key => data[key] = document.querySelector(`#${key}`).value) data["showContextMenu"] = showContextMenu; @@ -145,17 +158,18 @@ document.getElementById("ytptube_options").addEventListener("submit", async e => data.instance_origin = newOriginPattern; storedOriginPattern = newOriginPattern; - chrome.storage.sync.set(data); - chrome.contextMenus.update("send-to-ytptube", { visible: showContextMenu }); + await api.storage.sync.set(data); - notify("Options saved.", true); - - setTimeout(() => { + if (api.contextMenus && api.contextMenus.update) { try { - window.close() + api.contextMenus.update("send-to-ytptube", { visible: showContextMenu }); } catch (e) { + console.warn('contextMenus.update indisponible:', e); } - }, 1000); + } + + showSaveStatus("Options saved.", true); + setTimeout(() => { try { window.close() } catch (e) {} }, 1500); }); document.getElementById("test_config").addEventListener("click", async e => { diff --git a/src/popup.js b/src/popup.js index 1f5c79f..53ceb35 100644 --- a/src/popup.js +++ b/src/popup.js @@ -2,25 +2,23 @@ const $ = s => document.querySelector(s) -if (typeof chrome === 'undefined') { - let chrome = browser; -} +const api = typeof browser !== 'undefined' ? browser : chrome; const getOption = async (key, default_data) => { - let item = await chrome.storage.sync.get(key); - return item[key] || default_data; + let item = await api.storage.sync.get(key); + return item[key] ?? default_data; } -const notify = message => chrome.notifications.create({ - "type": "basic", - "iconUrl": chrome.runtime.getURL("icons/icon-128.png"), - "title": 'YTPTube Extension', - "message": message +// Écoute les notifications du background (remplace chrome.notifications sur Android) +api.runtime.onMessage.addListener((message) => { + if (message.command === 'notify') { + // On ne sait pas si c'est succès ou erreur ici, on affiche neutre + showStatusMessage(message.message, !message.message.toLowerCase().includes('fail') && !message.message.toLowerCase().includes('error')); + } }); const showLoading = (isLoading) => { const submitBtn = $('#submit-btn') - if (isLoading) { submitBtn.disabled = true submitBtn.classList.add('is-loading') @@ -35,11 +33,7 @@ const showStatusMessage = (message, isSuccess = true) => { statusDiv.textContent = message statusDiv.className = `notification ${isSuccess ? 'is-success' : 'is-danger'}` statusDiv.classList.remove('is-hidden') - - // Hide the message after 3 seconds - setTimeout(() => { - statusDiv.classList.add('is-hidden') - }, 3000) + setTimeout(() => statusDiv.classList.add('is-hidden'), 4000) } $("#ytptube_popup").addEventListener("submit", async (e) => { @@ -54,107 +48,109 @@ $("#ytptube_popup").addEventListener("submit", async (e) => { showLoading(true) try { - const response = await chrome.runtime.sendMessage({ command: 'send-to-ytptube', url: url, preset: preset }) - + const response = await api.runtime.sendMessage({ command: 'send-to-ytptube', url: url, preset: preset }) if (response && response.success) { showStatusMessage('Request sent successfully!', true) } else { showStatusMessage(response?.message || 'Failed to send request.', false) } } catch (error) { - showStatusMessage('Error sending request.', false) + showStatusMessage('Error sending request: ' + error.message, false) console.error('Error:', error) } finally { showLoading(false) } }) -const getCurrentUrl = async () => (await chrome.tabs.query({ currentWindow: true, active: true }))[0].url +const getCurrentUrl = async () => { + try { + const tabs = await api.tabs.query({ currentWindow: true, active: true }); + return tabs && tabs[0] ? tabs[0].url : null; + } catch (e) { + console.warn('tabs.query failed:', e); + return null; + } +} addEventListener('DOMContentLoaded', async _ => { let url = await getCurrentUrl() - - if (url && !url.startsWith('http')) { - url = "" - } - + if (url && !url.startsWith('http')) url = "" $('#user_url').value = url || '' const defaultPreset = await getOption("preset") - const cache = await getOption('presets', { - presets: [], - last_updated: 0 - }) + const cache = await getOption('presets', { presets: [], last_updated: 0 }) if (Date.now() - cache.last_updated > 1000 * 60 * 60) { cache.presets = await getPresets() cache.last_updated = Date.now() - await chrome.storage.sync.set({ presets: cache }) + await api.storage.sync.set({ presets: cache }) } const s = $('#preset') + while (s.firstChild) s.removeChild(s.lastChild); - // -- remove existing options - while (s.firstChild) { - s.removeChild(s.lastChild); - } - - cache.presets.forEach(p => { + // S'assurer qu'il y a toujours au moins "default" + const presets = cache.presets.length > 0 ? cache.presets : ['default']; + presets.forEach(p => { let option = document.createElement('option'); option.value = p; option.text = p; - // -- set the default preset - if (p === defaultPreset) { - option.selected = true; - } + if (p === defaultPreset) option.selected = true; s.appendChild(option); }) }) $('#go-to-options').addEventListener('click', function () { - if (chrome.runtime.openOptionsPage) { - chrome.runtime.openOptionsPage(); + if (api.runtime.openOptionsPage) { + api.runtime.openOptionsPage(); } else { - window.open(chrome.runtime.getURL('options.html')); + // Sur Firefox Android, openOptionsPage peut être absent + const optionsUrl = api.runtime.getURL('options.html'); + api.tabs.create({ url: optionsUrl }).catch(() => window.open(optionsUrl)); } }); -$('#preset').addEventListener('change', async e => await chrome.storage.sync.set({ preset: e.target.value })) +$('#preset').addEventListener('change', async e => await api.storage.sync.set({ preset: e.target.value })) const getPresets = async () => { let presets = [] - const instanceUrl = await getOption("instance_url"); if (!instanceUrl) { - presets.push('YTPTube instance url not configured.'); + presets.push('default'); return presets } let headers = {}; - const auth_username = await getOption('username'); const auth_password = await getOption('password'); if (auth_username && auth_password) { headers['Authorization'] = 'Basic ' + btoa(`${auth_username}:${auth_password}`); } - const url = new URL(instanceUrl); - url.pathname = '/api/presets'; - url.search = 'filter=name'; + try { + const url = new URL(instanceUrl); + url.pathname = '/api/presets'; + url.search = 'filter=name'; - const req = await fetch(url, { method: 'GET', headers: { ...headers, 'Accept': 'application/json' } }); - if (200 !== req.status) { - presets.push('Error fetching presets from YTPTube.'); - return presets - } + const req = await fetch(url, { method: 'GET', headers: { ...headers, 'Accept': 'application/json' } }); + if (200 !== req.status) { + presets.push('default'); + return presets + } - const data = await req.json() - if (data.items && Array.isArray(data.items)) { - data.items.forEach(preset => presets.push(preset.name)) - } else { - data.forEach(preset => presets.push(preset.name)) + const data = await req.json() + if (data.items && Array.isArray(data.items)) { + data.items.forEach(preset => presets.push(preset.name)) + } else if (Array.isArray(data)) { + data.forEach(preset => presets.push(preset.name)) + } else { + presets.push('default'); + } + } catch (e) { + console.error('Error fetching presets:', e); + presets.push('default'); } return presets -}; \ No newline at end of file +};