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 0000000..2e50ae6 Binary files /dev/null and b/Extensions/Firefox/icons/icon-128.png differ diff --git a/Extensions/Firefox/icons/icon-48.png b/Extensions/Firefox/icons/icon-48.png new file mode 100644 index 0000000..344542b Binary files /dev/null and b/Extensions/Firefox/icons/icon-48.png differ 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") + } } }