mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 00:48:05 +00:00
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:
@@ -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,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user