Files
Firelink/Sources/Firelink/FirelinkApp.swift
T
nimbold f887c62195 feat: enforce dynamic browser extension pairing security
- Generate random pairing token in AppSettings

- Update LocalExtensionServer to strict check token on non-OPTIONS endpoints

- Add GET /ping endpoint for extension connection verification

- Redesign Integration settings pane with modern step-by-step UI and Toast notifications

- Harden media extraction and aria2 engine paths

- Update IP resolution with getaddrinfo
2026-06-09 06:30:39 +03:30

214 lines
8.3 KiB
Swift

import SwiftUI
import Sparkle
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _updater: SPUUpdater?
var updater: SPUUpdater { _updater! }
@Published var isChecking = false
@Published var isDownloading = false
@Published var isExtracting = false
@Published var isReadyToInstall = false
@Published var downloadProgress: Double = 0.0
@Published var extractionProgress: Double = 0.0
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@Published var releaseNotes: AttributedString?
@Published var automaticallyChecksForUpdates: Bool = true {
didSet {
_updater?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
}
}
var expectedContentLength: UInt64 = 0
var receivedContentLength: UInt64 = 0
var cancellation: (() -> Void)?
var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)?
override init() {
super.init()
let driver = InlineUpdateUserDriver(updater: self)
let hostBundle = Bundle.main
self._updater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: driver, delegate: self)
do {
try self._updater?.start()
self.automaticallyChecksForUpdates = self._updater?.automaticallyChecksForUpdates ?? true
} catch {
print("Failed to start Sparkle updater: \(error)")
}
}
func checkForUpdates() {
guard updater.canCheckForUpdates else {
isChecking = false
updateStatus = "Update check is already in progress."
return
}
updater.checkForUpdates()
}
func resetState() {
isChecking = false
isDownloading = false
isExtracting = false
isReadyToInstall = false
downloadProgress = 0.0
extractionProgress = 0.0
updateStatus = nil
foundUpdateItem = nil
releaseNotes = nil
expectedContentLength = 0
receivedContentLength = 0
cancellation = nil
updateChoiceReply = nil
}
// Delegate methods can be left mostly empty or minimal since the UserDriver handles the UI state now.
func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
DispatchQueue.main.async {
self.isChecking = false
}
}
}
@main
struct FirelinkApp: App {
@StateObject private var sparkleUpdater: SparkleUpdater
@StateObject private var settings: AppSettings
@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() {
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater())
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
var body: some Scene {
WindowGroup(id: "main") {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.environmentObject(sparkleUpdater)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
if url.scheme == "firelink" {
if url.host == "add",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let link = queryItems.first(where: { $0.name == "url" })?.value {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = link
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
Window("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
}
}
.windowResizability(.contentSize)
.commands {
CommandGroup(after: .newItem) {
Button("Add Downloads...") {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
controller.startQueue()
}
.keyboardShortcut("r", modifiers: [.command])
Button("Stop Downloads") {
controller.pauseActiveDownloads()
}
.disabled(controller.activeCount == 0)
}
}
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
.environmentObject(sparkleUpdater)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 23, height: 23)
img.isTemplate = true
return img
}() {
Image(nsImage: nsImage)
} else {
Image(systemName: "arrow.down.circle")
}
}
}
private func menuBarIconURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeIcon = projectRoot
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
}
}