From 0ddea52db8f409b15984e1b4ced3b2555c94863e Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:15:12 +0330 Subject: [PATCH] feat(extension): add Firefox browser extension and native integration - Implement Firefox extension to intercept downloads and scrape selected links via context menus - Create LocalExtensionServer over TCP (localhost:6412) to receive links from the extension - Automatically package extension to firelink.xpi in create_app_bundle.sh - Add Integrations tab in SettingsView with one-click installer for Firefox variants - Modify ContentView and TrayMenuView to automatically open the Add Downloads window on extension trigger --- Extensions/Firefox/background.js | 110 ++++++++ Extensions/Firefox/content.js | 37 +++ Extensions/Firefox/icons/icon-128.png | Bin 0 -> 18899 bytes Extensions/Firefox/icons/icon-48.png | Bin 0 -> 3737 bytes Extensions/Firefox/manifest.json | 30 +++ Extensions/Firefox/popup/popup.html | 62 +++++ Extensions/Firefox/popup/popup.js | 88 ++++++ Extensions/Firefox/popup/styles.css | 282 ++++++++++++++++++++ Scripts/create_app_bundle.sh | 3 + Sources/Firelink/ContentView.swift | 3 + Sources/Firelink/FirelinkApp.swift | 6 + Sources/Firelink/LocalExtensionServer.swift | 83 ++++++ Sources/Firelink/SettingsView.swift | 134 +++++++++- Sources/Firelink/TrayMenuView.swift | 3 + 14 files changed, 840 insertions(+), 1 deletion(-) create mode 100644 Extensions/Firefox/background.js create mode 100644 Extensions/Firefox/content.js create mode 100644 Extensions/Firefox/icons/icon-128.png create mode 100644 Extensions/Firefox/icons/icon-48.png create mode 100644 Extensions/Firefox/manifest.json create mode 100644 Extensions/Firefox/popup/popup.html create mode 100644 Extensions/Firefox/popup/popup.js create mode 100644 Extensions/Firefox/popup/styles.css create mode 100644 Sources/Firelink/LocalExtensionServer.swift diff --git a/Extensions/Firefox/background.js b/Extensions/Firefox/background.js new file mode 100644 index 0000000..d01ca1a --- /dev/null +++ b/Extensions/Firefox/background.js @@ -0,0 +1,110 @@ +// background.js +const FIRELINK_SERVER_URL = "http://localhost:6412/download"; + +// Default settings +const defaultSettings = { + globalCapture: false, + siteToggles: {} // hostname -> boolean (true if capture is disabled for this site) +}; + +// Initialize settings +chrome.runtime.onInstalled.addListener(() => { + chrome.storage.local.get(['globalCapture', 'siteToggles'], (result) => { + if (result.globalCapture === undefined) { + chrome.storage.local.set(defaultSettings); + } + }); + + // Create context menus + chrome.contextMenus.create({ + id: "download-with-firelink", + title: "Download with Firelink", + contexts: ["link"] + }); + + chrome.contextMenus.create({ + id: "download-selected-with-firelink", + title: "Download selected with Firelink", + contexts: ["selection"] + }); +}); + +// Function to send URLs to Firelink +async function sendToFirelink(urls, referer = "") { + try { + const payload = { + urls: Array.isArray(urls) ? urls : [urls], + referer: referer + }; + + // Attempt to send to Firelink local server + await fetch(FIRELINK_SERVER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + console.log("Successfully sent to Firelink:", payload); + } catch (error) { + console.error("Failed to send to Firelink:", error); + // Fallback logic could go here + } +} + +// Handle context menu clicks +chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId === "download-with-firelink") { + if (info.linkUrl) { + sendToFirelink([info.linkUrl], tab.url); + } + } else if (info.menuItemId === "download-selected-with-firelink") { + // We need to inject a script to get the selected HTML/links + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["content.js"] + }, () => { + // Send a message to the content script to perform the extraction + chrome.tabs.sendMessage(tab.id, { action: "extractSelectionLinks" }, (response) => { + if (response && response.links && response.links.length > 0) { + sendToFirelink(response.links, tab.url); + } else { + // Fallback: If no links were found by the content script, try to parse the raw text + if (info.selectionText) { + const urlRegex = /(https?:\/\/[^\s]+)/g; + const matches = info.selectionText.match(urlRegex); + if (matches && matches.length > 0) { + sendToFirelink(matches, tab.url); + } + } + } + }); + }); + } +}); + +// Listen for downloads +chrome.downloads.onCreated.addListener((downloadItem) => { + chrome.storage.local.get(['globalCapture', 'siteToggles'], (settings) => { + const globalCapture = settings.globalCapture || false; + const siteToggles = settings.siteToggles || {}; + + let hostname = ""; + try { + hostname = new URL(downloadItem.referrer || downloadItem.url).hostname; + } catch (e) { + // Invalid URL + } + + // Check if capture is disabled for this specific site + const siteCaptureDisabled = siteToggles[hostname] === true; + + if (globalCapture && !siteCaptureDisabled) { + // Cancel the browser download + chrome.downloads.cancel(downloadItem.id, () => { + // Send to Firelink + sendToFirelink([downloadItem.url], downloadItem.referrer); + }); + } + }); +}); diff --git a/Extensions/Firefox/content.js b/Extensions/Firefox/content.js new file mode 100644 index 0000000..661941a --- /dev/null +++ b/Extensions/Firefox/content.js @@ -0,0 +1,37 @@ +// content.js +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === "extractSelectionLinks") { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) { + sendResponse({ links: [] }); + return; + } + + const links = new Set(); + + // Extract a tags from the selected range + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + const container = document.createElement("div"); + container.appendChild(range.cloneContents()); + + const anchors = container.querySelectorAll("a"); + anchors.forEach(a => { + if (a.href) { + // ensure the link is a full URL by using the current document's base URI if it's relative + try { + const url = new URL(a.getAttribute('href'), document.baseURI).href; + links.add(url); + } catch (e) { + // Invalid URL, fallback to just checking if it looks like a valid protocol + if (a.href.startsWith("http")) { + links.add(a.href); + } + } + } + }); + } + + sendResponse({ links: Array.from(links) }); + } +}); diff --git a/Extensions/Firefox/icons/icon-128.png b/Extensions/Firefox/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..2e50ae623d6baf289bf554a3177d8f151e7674b2 GIT binary patch literal 18899 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 D*)!}s literal 0 HcmV?d00001 diff --git a/Extensions/Firefox/manifest.json b/Extensions/Firefox/manifest.json new file mode 100644 index 0000000..5675f68 --- /dev/null +++ b/Extensions/Firefox/manifest.json @@ -0,0 +1,30 @@ +{ + "manifest_version": 3, + "name": "Firelink", + "version": "1.0.0", + "description": "Capture downloads and links directly to Firelink download manager.", + "permissions": [ + "downloads", + "downloads.open", + "contextMenus", + "storage", + "activeTab", + "scripting" + ], + "background": { + "scripts": ["background.js"] + }, + "action": { + "default_popup": "popup/popup.html", + "default_title": "Firelink" + }, + "icons": { + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "firelink@nimbold.github.io" + } + } +} diff --git a/Extensions/Firefox/popup/popup.html b/Extensions/Firefox/popup/popup.html new file mode 100644 index 0000000..08ddfa5 --- /dev/null +++ b/Extensions/Firefox/popup/popup.html @@ -0,0 +1,62 @@ + + + + + + + +
+
+
+ +
+

Firelink

+
+ +
+ +
+
+
+ Capture All Downloads + Intercept browser downloads +
+ +
+ +
+
+ Disable on site + ... +
+ +
+
+ +
+ + + diff --git a/Extensions/Firefox/popup/popup.js b/Extensions/Firefox/popup/popup.js new file mode 100644 index 0000000..d7b814c --- /dev/null +++ b/Extensions/Firefox/popup/popup.js @@ -0,0 +1,88 @@ +document.addEventListener('DOMContentLoaded', () => { + const globalToggle = document.getElementById('global-toggle'); + const siteToggle = document.getElementById('site-toggle'); + const hostnameSpan = document.getElementById('current-hostname'); + const themeToggleBtn = document.getElementById('theme-toggle'); + const sunIcon = document.getElementById('sun-icon'); + const moonIcon = document.getElementById('moon-icon'); + + // Apply theme function + const applyTheme = (theme) => { + document.documentElement.setAttribute('data-theme', theme); + if (theme === 'light') { + sunIcon.style.display = 'none'; + moonIcon.style.display = 'block'; + } else { + sunIcon.style.display = 'block'; + moonIcon.style.display = 'none'; + } + }; + + // Load current settings including theme + chrome.storage.local.get(['globalCapture', 'siteToggles', 'theme'], (result) => { + globalToggle.checked = result.globalCapture || false; + + // Determine initial theme + let currentTheme = result.theme; + if (!currentTheme) { + currentTheme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; + } + applyTheme(currentTheme); + + // Get current active tab + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs.length > 0 && tabs[0].url) { + try { + const url = new URL(tabs[0].url); + // Only show site toggle for valid http/https URLs + if (url.protocol.startsWith('http')) { + const hostname = url.hostname; + hostnameSpan.textContent = hostname; + + const siteToggles = result.siteToggles || {}; + // If the site is in siteToggles and value is true, it means capture is DISABLED for this site + siteToggle.checked = siteToggles[hostname] === true; + } else { + document.getElementById('site-setting-row').style.display = 'none'; + } + } catch (e) { + document.getElementById('site-setting-row').style.display = 'none'; + } + } + }); + }); + + // Handle global toggle change + globalToggle.addEventListener('change', (e) => { + chrome.storage.local.set({ globalCapture: e.target.checked }); + }); + + // Handle theme toggle change + themeToggleBtn.addEventListener('click', () => { + const currentTheme = document.documentElement.getAttribute('data-theme') || + (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); + const newTheme = currentTheme === 'light' ? 'dark' : 'light'; + applyTheme(newTheme); + chrome.storage.local.set({ theme: newTheme }); + }); + + // Handle site toggle change + siteToggle.addEventListener('change', (e) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs.length > 0 && tabs[0].url) { + try { + const url = new URL(tabs[0].url); + const hostname = url.hostname; + + chrome.storage.local.get(['siteToggles'], (result) => { + const siteToggles = result.siteToggles || {}; + siteToggles[hostname] = e.target.checked; + chrome.storage.local.set({ siteToggles: siteToggles }); + }); + } catch (error) { + console.error("Invalid URL"); + } + } + }); + }); +}); diff --git a/Extensions/Firefox/popup/styles.css b/Extensions/Firefox/popup/styles.css new file mode 100644 index 0000000..07d8815 --- /dev/null +++ b/Extensions/Firefox/popup/styles.css @@ -0,0 +1,282 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); + +:root { + /* Dark Theme Variables (Default fallback) */ + --bg-color: #0d0d0f; + --text-primary: #f5f5f7; + --text-secondary: #8e8e93; + --text-hover: #a1a1a6; + --row-bg: rgba(255, 255, 255, 0.03); + --row-border: rgba(255, 255, 255, 0.06); + --row-bg-hover: rgba(255, 255, 255, 0.05); + --row-border-hover: rgba(255, 255, 255, 0.1); + --header-border: rgba(255, 255, 255, 0.08); + --blob-color-1: rgba(255, 45, 85, 0.4); + --blob-color-2: rgba(255, 149, 0, 0.1); + --toggle-bg: rgba(255, 255, 255, 0.15); + --toggle-border: rgba(255, 255, 255, 0.05); + --toggle-knob: #ffffff; + --shadow-color: rgba(0, 0, 0, 0.2); + --icon-hover: rgba(255, 255, 255, 0.1); +} + +[data-theme="light"] { + --bg-color: #f2f2f7; + --text-primary: #1c1c1e; + --text-secondary: #636366; + --text-hover: #3a3a3c; + --row-bg: rgba(255, 255, 255, 0.6); + --row-border: rgba(0, 0, 0, 0.05); + --row-bg-hover: rgba(255, 255, 255, 0.9); + --row-border-hover: rgba(0, 0, 0, 0.1); + --header-border: rgba(0, 0, 0, 0.08); + --blob-color-1: rgba(255, 45, 85, 0.2); + --blob-color-2: rgba(255, 149, 0, 0.05); + --toggle-bg: rgba(0, 0, 0, 0.1); + --toggle-border: rgba(0, 0, 0, 0.05); + --toggle-knob: #ffffff; + --shadow-color: rgba(0, 0, 0, 0.05); + --icon-hover: rgba(0, 0, 0, 0.05); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg-color: #f2f2f7; + --text-primary: #1c1c1e; + --text-secondary: #636366; + --text-hover: #3a3a3c; + --row-bg: rgba(255, 255, 255, 0.6); + --row-border: rgba(0, 0, 0, 0.05); + --row-bg-hover: rgba(255, 255, 255, 0.9); + --row-border-hover: rgba(0, 0, 0, 0.1); + --header-border: rgba(0, 0, 0, 0.08); + --blob-color-1: rgba(255, 45, 85, 0.2); + --blob-color-2: rgba(255, 149, 0, 0.05); + --toggle-bg: rgba(0, 0, 0, 0.1); + --toggle-border: rgba(0, 0, 0, 0.05); + --toggle-knob: #ffffff; + --shadow-color: rgba(0, 0, 0, 0.05); + --icon-hover: rgba(0, 0, 0, 0.05); + } +} + +body { + width: 340px; + margin: 0; + padding: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + overflow: hidden; + position: relative; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Glassmorphic background blob animation */ +.background-blob { + position: absolute; + top: -50px; + right: -50px; + width: 200px; + height: 200px; + background: radial-gradient(circle, var(--blob-color-1) 0%, var(--blob-color-2) 50%, transparent 70%); + border-radius: 50%; + filter: blur(30px); + z-index: 0; + animation: pulse 6s infinite alternate cubic-bezier(0.4, 0, 0.2, 1); + transition: background 0.3s ease; +} + +@keyframes pulse { + 0% { transform: scale(1) translate(0, 0); opacity: 0.6; } + 100% { transform: scale(1.1) translate(-10px, 10px); opacity: 0.9; } +} + +.container { + position: relative; + z-index: 1; + padding: 20px; +} + +.header { + display: flex; + align-items: center; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--header-border); + transition: border-color 0.3s ease; +} + +.logo { + width: 28px; + height: 28px; + margin-right: 12px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(255, 45, 85, 0.2); + transition: transform 0.3s ease; +} + +.logo:hover { + transform: scale(1.05) rotate(2deg); +} + +.header-title-container { + flex: 1; +} + +h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.5px; + background: linear-gradient(90deg, #ff9500, #ff2d55); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.theme-toggle-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 6px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + transition: background-color 0.2s ease, color 0.2s ease; +} + +.theme-toggle-btn:hover { + background-color: var(--icon-hover); + color: var(--text-primary); +} + +.theme-toggle-btn svg { + width: 20px; + height: 20px; + fill: currentColor; +} + +.settings { + display: flex; + flex-direction: column; + gap: 16px; +} + +.setting-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 16px; + background: var(--row-bg); + border: 1px solid var(--row-border); + border-radius: 14px; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: all 0.2s ease; +} + +.setting-row:hover { + background: var(--row-bg-hover); + border-color: var(--row-border-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--shadow-color); +} + +.setting-info { + display: flex; + flex-direction: column; + flex: 1; + padding-right: 16px; +} + +.setting-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; + letter-spacing: -0.2px; + transition: color 0.3s ease; +} + +.setting-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + word-break: break-all; + transition: color 0.2s ease; +} + +.setting-row:hover .setting-desc { + color: var(--text-hover); +} + +#current-hostname { + color: #ff9500; + font-weight: 500; +} + +/* Modern Toggle Switch Styling */ +.switch { + position: relative; + display: inline-block; + width: 48px; + height: 28px; + flex-shrink: 0; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--toggle-bg); + transition: .4s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid var(--toggle-border); + box-sizing: border-box; +} + +.slider:before { + position: absolute; + content: ""; + height: 22px; + width: 22px; + left: 2px; + bottom: 2px; + background-color: var(--toggle-knob); + transition: .4s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 2px 5px rgba(0,0,0,0.3); +} + +input:checked + .slider { + background-color: #ff2d55; + border-color: #ff2d55; + box-shadow: 0 0 12px rgba(255, 45, 85, 0.4); +} + +input:focus + .slider { + outline: 2px solid rgba(255, 45, 85, 0.5); + outline-offset: 2px; +} + +input:checked + .slider:before { + transform: translateX(20px); +} + +.slider.round { + border-radius: 28px; +} + +.slider.round:before { + border-radius: 50%; +} diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 4436998..19e2b41 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -21,6 +21,9 @@ cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME" cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns" cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png" +echo "Packaging Firefox extension..." +(cd "$ROOT_DIR/Extensions/Firefox" && zip -r -q "$RESOURCES_DIR/firelink.xpi" . -x "*.DS_Store") + ARIA2C_PATH=$(which aria2c || true) if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then echo "Bundling aria2c from $ARIA2C_PATH..." diff --git a/Sources/Firelink/ContentView.swift b/Sources/Firelink/ContentView.swift index 9a0ed14..4261706 100644 --- a/Sources/Firelink/ContentView.swift +++ b/Sources/Firelink/ContentView.swift @@ -18,6 +18,9 @@ struct ContentView: View { detailView .themeBackground(settings.appTheme.theme.background) } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in + openWindow(id: "add-downloads") + } } @ViewBuilder diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 28fa581..989fa4e 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -6,6 +6,9 @@ struct FirelinkApp: App { @StateObject private var controller: DownloadController @StateObject private var schedulerController: SchedulerController @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true + + // Server must be retained to keep listening + private let extensionServer: LocalExtensionServer? init() { let settings = AppSettings() @@ -13,6 +16,9 @@ struct FirelinkApp: App { _settings = StateObject(wrappedValue: settings) _controller = StateObject(wrappedValue: controller) _schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller)) + + extensionServer = LocalExtensionServer(downloadController: controller) + extensionServer?.start() } var body: some Scene { diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift new file mode 100644 index 0000000..a9e2208 --- /dev/null +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -0,0 +1,83 @@ +import Foundation +import Network +import AppKit + +final class LocalExtensionServer: @unchecked Sendable { + private let listener: NWListener + private let downloadController: DownloadController + private let queue = DispatchQueue(label: "local.firelink.server") + + init?(downloadController: DownloadController) { + self.downloadController = downloadController + + let port = NWEndpoint.Port(rawValue: 6412)! + let parameters = NWParameters.tcp + + do { + listener = try NWListener(using: parameters, on: port) + } catch { + print("Failed to create listener: \(error)") + return nil + } + } + + func start() { + listener.newConnectionHandler = { [weak self] connection in + self?.handleConnection(connection) + } + listener.stateUpdateHandler = { state in + print("LocalExtensionServer state: \(state)") + } + listener.start(queue: queue) + } + + private func handleConnection(_ connection: NWConnection) { + connection.start(queue: queue) + + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in + if let data = data, let requestString = String(data: data, encoding: .utf8) { + self?.processRequest(requestString) + } + + let response = """ + HTTP/1.1 200 OK\r + Access-Control-Allow-Origin: *\r + Access-Control-Allow-Methods: POST, OPTIONS\r + Access-Control-Allow-Headers: Content-Type\r + Content-Length: 0\r + Connection: close\r + \r\n + """ + + connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + + private func processRequest(_ request: String) { + guard let range = request.range(of: "\r\n\r\n") else { return } + + let bodyString = request[range.upperBound...] + guard let data = bodyString.data(using: .utf8) else { return } + + struct Payload: Decodable { + let urls: [String] + let referer: String? + } + + do { + let payload = try JSONDecoder().decode(Payload.self, from: data) + Task { @MainActor in + let text = payload.urls.joined(separator: "\n") + if !text.isEmpty { + self.downloadController.pendingPasteboardText = text + NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) + NSApp.activate(ignoringOtherApps: true) + } + } + } catch { + print("Failed to parse local request JSON: \(error)") + } + } +} diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift index 9a8527c..9309019 100644 --- a/Sources/Firelink/SettingsView.swift +++ b/Sources/Firelink/SettingsView.swift @@ -9,6 +9,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { case siteLogins = "Site Logins" case power = "Power" case engine = "Engine" + case integration = "Integrations" case about = "About" static let orderedCases: [SettingsSection] = [ @@ -19,6 +20,7 @@ private enum SettingsSection: String, CaseIterable, Hashable { .siteLogins, .power, .engine, + .integration, .about ] @@ -31,13 +33,14 @@ private enum SettingsSection: String, CaseIterable, Hashable { case .siteLogins: "key.fill" case .power: "moon.zzz" case .engine: "terminal" + case .integration: "puzzlepiece.extension" case .about: "info.circle" } } var groupTitle: String { switch self { - case .engine, .about: + case .engine, .integration, .about: "App" default: "Preferences" @@ -113,6 +116,8 @@ struct SettingsView: View { PowerSettingsPane() case .engine: EngineSettingsPane() + case .integration: + IntegrationSettingsPane() case .about: AboutSettingsPane() } @@ -663,3 +668,130 @@ private struct PowerSettingsPane: View { .formStyle(.grouped) } } + +private struct IntegrationSettingsPane: View { + @State private var isInstalling = false + @State private var firefoxApps: [URL] = [] + @State private var showAppPicker = false + + var body: some View { + Form { + Section { + HStack(alignment: .center, spacing: 14) { + Image(systemName: "safari") + .resizable() + .frame(width: 48, height: 48) + .foregroundStyle(.orange) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text("Firefox Extension") + .font(.title2.weight(.semibold)) + Text("Capture downloads directly from your browser.") + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + Section { + HStack { + Button { + handleInstallClick() + } label: { + Label("Install to Firefox", systemImage: "puzzlepiece.extension.fill") + } + .buttonStyle(.borderedProminent) + .disabled(isInstalling) + .confirmationDialog("Select Firefox Edition", isPresented: $showAppPicker, titleVisibility: .visible) { + ForEach(firefoxApps, id: \.self) { appURL in + Button(appURL.deletingPathExtension().lastPathComponent) { + installExtension(with: appURL) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Multiple Firefox installations were found. Which one would you like to install the extension to?") + } + + Button { + showExtensionInFinder() + } label: { + Label("Show in Finder", systemImage: "folder") + } + } + + Text("Note: Mozilla strictly enforces add-on signing. Standard Firefox and Beta will block unsigned extensions. You must either use Firefox Developer Edition/Nightly (with xpinstall.signatures.required set to false) or load it temporarily via about:debugging.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Permissions & Privacy") { + Text("The Firelink extension requests minimal permissions. It only reads your current tab when you explicitly click 'Download with Firelink' from the right-click menu, keeping your browsing history completely private.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } + + private func showExtensionInFinder() { + guard let xpiURL = Bundle.main.url(forResource: "firelink", withExtension: "xpi") else { return } + NSWorkspace.shared.activateFileViewerSelecting([xpiURL]) + } + + private func handleInstallClick() { + let apps = findFirefoxApps() + if apps.isEmpty { + // Fallback to default macOS open behavior + installExtension(with: nil) + } else if apps.count == 1 { + installExtension(with: apps[0]) + } else { + firefoxApps = apps.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) + showAppPicker = true + } + } + + private func findFirefoxApps() -> [URL] { + let directories = [ + URL(fileURLWithPath: "/Applications"), + URL(fileURLWithPath: NSHomeDirectory() + "/Applications") + ] + + var apps: [URL] = [] + for dir in directories { + if let urls = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) { + for url in urls where url.pathExtension == "app" && url.lastPathComponent.lowercased().contains("firefox") { + apps.append(url) + } + } + } + return apps + } + + private func installExtension(with appURL: URL?) { + guard let xpiURL = Bundle.main.url(forResource: "firelink", withExtension: "xpi") else { + print("Failed to find firelink.xpi in app bundle.") + return + } + + isInstalling = true + Task { + do { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + if let appURL { + process.arguments = ["-a", appURL.path, xpiURL.path] + } else { + process.arguments = [xpiURL.path] // Default open + } + try process.run() + process.waitUntilExit() + } catch { + print("Failed to launch Firefox to install extension: \(error)") + } + isInstalling = false + } + } +} diff --git a/Sources/Firelink/TrayMenuView.swift b/Sources/Firelink/TrayMenuView.swift index 5e90dfb..1f2dad6 100644 --- a/Sources/Firelink/TrayMenuView.swift +++ b/Sources/Firelink/TrayMenuView.swift @@ -34,5 +34,8 @@ struct TrayMenuView: View { Button("Exit") { NSApplication.shared.terminate(nil) } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in + openWindow(id: "add-downloads") + } } }