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
This commit is contained in:
nimbold
2026-06-03 22:15:12 +03:30
parent 93e6e11939
commit 0ddea52db8
14 changed files with 840 additions and 1 deletions
+3
View File
@@ -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
+6
View File
@@ -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 {
@@ -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)")
}
}
}
+133 -1
View File
@@ -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
}
}
}
+3
View File
@@ -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")
}
}
}