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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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")
+ }
}
}