Compare commits

...

17 Commits

Author SHA1 Message Date
nimbold db2b1f6516 chore(release): bump version to 0.6.3 and update changelog 2026-06-09 06:58:22 +03:30
nimbold 336a50ed6c fix(integration): resolve CORS preflight bug and secure pairing token storage
- Allow GET method in LocalExtensionServer CORS preflight response
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore
- Upgrade token generation to use SecRandomCopyBytes
- Update IntegrationSettingsPane UI to be browser-agnostic with a Regenerate token action
2026-06-09 06:48:06 +03:30
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
nimbold 109059e10c fix: resolve pipe race condition and optimize yt-dlp arguments
- Used DispatchGroup to fix stdout/stderr truncation race condition
- Added --force-ipv4 to prevent metadata fetching hang
- Removed hardcoded youtube:player_client to fix throttling issues
- Combined --js-runtimes flags as a comma-separated list
2026-06-08 21:42:14 +03:30
nimbold 81b3e0877b docs: add missing credits for yt-dlp, ffmpeg, and sparkle in README and About page 2026-06-08 21:24:56 +03:30
nimbold 6b2901bd50 feat(updater): upgrade sparkle to 2.9.3 and enhance integration
- Update Sparkle dependency to 2.9.3

- Replace brittle HTML parsing with native NSAttributedString parsing

- Fix dangling updater callbacks by handling view disappear events

- Add 'Remind Me Later' button in update prompt

- Add toggle for automatic background update checks
2026-06-08 21:18:36 +03:30
nimbold 9261385d59 fix(ui): clear add downloads view state on disappear to prevent old link auto-pasting 2026-06-08 21:18:36 +03:30
github-actions[bot] ef0ad42df3 chore(release): update appcast for 0.6.2 2026-06-08 13:15:06 +00:00
nimbold b71a58ae06 fix: resolve duplicate resolution window not closing
- Dispatch executeAddDownloads asynchronously after closing the sheet to avoid swallowing dismiss()
2026-06-08 16:42:11 +03:30
github-actions[bot] 6dcb0e33e8 chore(release): update appcast for 0.6.2 2026-06-08 12:54:49 +00:00
nimbold 34847b6234 fix: resolve duplicate resolution shortcuts, update freeze, and process id flashing
- Fix keyboard shortcut collision causing main window to intercept Enter/Escape keys when duplicate resolution sheet is open
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously
- Improve Sparkle changelog formatting by converting HTML tags to clean Markdown
- Change internal Process ID status message to Starting... when queueing downloads
- Fix EXC_BREAKPOINT crash on app launch by prioritizing Bundle.main over Bundle.module
- Update CHANGELOG.md for 0.6.2 release
2026-06-08 16:22:05 +03:30
github-actions[bot] ac7963e353 chore(release): update appcast for 0.6.1 2026-06-08 12:05:49 +00:00
nimbold a4936fa141 ci: automate Sparkle appcast updates 2026-06-08 15:33:43 +03:30
nimbold 9af9edbfd4 chore(release): prepare 0.6.1 2026-06-08 15:08:16 +03:30
nimbold 9f8e01839f fix: package bundled media engines 2026-06-08 14:57:07 +03:30
nimbold c1d97f31fb ci: automate fetching yt-dlp and ffmpeg binaries during release build 2026-06-08 14:25:18 +03:30
nimbold 02abef1443 feat(engines): bundle yt-dlp and ffmpeg directly in the app bundle
- Move yt-dlp and ffmpeg binaries into Sources/Firelink and update Package.swift to copy them as bundle resources.
- Remove dynamic downloading logic for yt-dlp and ffmpeg from MediaEngineManager.swift and delete obsolete BinaryDownloader.swift and GatekeeperConfig.swift.
- Update EngineSettingsPane.swift to remove the auto-updater UI, verify icons, and spinners.
- Fix a bug where calling Add Downloads via the browser extension would open two duplicate windows by changing the window group to a single-instance Window.
- Adjust minHeight of AddDownloadsView to prevent the preview section from being cut out without scrolling.
2026-06-08 14:16:38 +03:30
28 changed files with 770 additions and 951 deletions
+98 -6
View File
@@ -31,11 +31,14 @@ jobs:
run: |
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
VERSION="${GITHUB_REF_NAME#v}"
TAG_NAME="${GITHUB_REF_NAME}"
else
VERSION="${{ inputs.version }}"
TAG_NAME="v${VERSION}"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Show build environment
@@ -60,6 +63,22 @@ jobs:
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Fetch media engines
run: |
mkdir -p Sources/Firelink
# Download latest yt-dlp for macOS
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
chmod +x Sources/Firelink/yt-dlp
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
unzip -q -o ffmpeg.zip -d Sources/Firelink
rm ffmpeg.zip
chmod +x Sources/Firelink/ffmpeg
test -x Sources/Firelink/yt-dlp
test -x Sources/Firelink/ffmpeg
- name: Build app bundle
env:
MARKETING_VERSION: ${{ steps.version.outputs.version }}
@@ -86,15 +105,88 @@ jobs:
if-no-files-found: error
- name: Publish GitHub release
if: github.ref_type == 'tag'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="${{ steps.version.outputs.version }}"
TAG_NAME="${{ steps.version.outputs.tag_name }}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
test -s release_notes.md
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
gh release upload "$TAG_NAME" dist/*.dmg --clobber
gh release edit "$TAG_NAME" --notes-file release_notes.md
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
fi
- name: Update Sparkle appcast
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
if [[ -z "${SPARKLE_ED_PRIVATE_KEY}" ]]; then
echo "Missing SPARKLE_ED_PRIVATE_KEY repository secret; cannot update appcast.xml" >&2
exit 1
fi
VERSION="${{ steps.version.outputs.version }}"
TAG_NAME="${{ steps.version.outputs.tag_name }}"
BUILD_NUMBER="${{ github.run_number }}"
DMG_PATH="dist/Firelink-${VERSION}-mac-arm64.dmg"
DMG_URL="https://github.com/nimbold/Firelink/releases/download/${TAG_NAME}/Firelink-${VERSION}-mac-arm64.dmg"
RELEASE_NOTES_URL="https://github.com/nimbold/Firelink/releases/tag/${TAG_NAME}"
PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
SIGN_OUTPUT="$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | .build/artifacts/sparkle/Sparkle/bin/sign_update --ed-key-file - "$DMG_PATH")"
ED_SIGNATURE="$(printf '%s\n' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' | head -1)"
LENGTH="$(stat -f%z "$DMG_PATH" 2>/dev/null || stat -c%s "$DMG_PATH")"
if [[ -z "$ED_SIGNATURE" || -z "$LENGTH" ]]; then
echo "Failed to extract Sparkle signature or DMG length" >&2
printf '%s\n' "$SIGN_OUTPUT" >&2
exit 1
fi
git fetch origin main
git checkout main
git pull --ff-only origin main
cat > appcast.xml <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Firelink Updates</title>
<link>https://github.com/nimbold/Firelink</link>
<description>Most recent updates for Firelink</description>
<language>en</language>
<item>
<title>Version ${VERSION}</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>${RELEASE_NOTES_URL}</sparkle:releaseNotesLink>
<pubDate>${PUB_DATE}</pubDate>
<enclosure url="${DMG_URL}"
sparkle:version="${BUILD_NUMBER}"
sparkle:shortVersionString="${VERSION}"
length="${LENGTH}"
type="application/octet-stream"
sparkle:edSignature="${ED_SIGNATURE}" />
</item>
</channel>
</rss>
XML
xmllint --noout appcast.xml
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add appcast.xml
if git diff --cached --quiet; then
echo "appcast.xml already up to date"
else
git commit -m "chore(release): update appcast for ${VERSION}"
git push origin main
fi
+1
View File
@@ -23,3 +23,4 @@ SparklePrivateKey*
private-key*
private_key*
yt-dlp
ffmpeg
+42
View File
@@ -5,6 +5,48 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.3] - 2026-06-09
### Improvements
- Upgrade pairing token generation to use a 32-byte cryptographically secure random sequence.
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore for enhanced security.
- Redesign the "Connect Browser Extension" settings pane to be browser-agnostic with links to both Firefox and Chrome extension stores.
- Add a "Regenerate" button to instantly invalidate and recreate the pairing token.
### Fixes
- Fix CORS preflight failures for the new `/ping` extension connection check by allowing `GET` methods in the local server.
## [0.6.2] - 2026-06-08
### Fixes
- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
- Improve Sparkle changelog formatting by converting HTML tags to clean Markdown instead of stripping them into an unreadable block of text.
- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
## [0.6.1] - 2026-06-08
### New Features
- No new user-facing features in this patch release.
### Improvements
- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
### Changes
- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
- Remove stale media add-on update language now that media engines are bundled with the app.
- Update Firelink Companion to `1.0.8`.
### Fixes
- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
## [0.6.0] - 2026-06-08
### New features
+3 -3
View File
@@ -1,13 +1,13 @@
{
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
"originHash" : "c1cb50a392a5949f6fb77fb4800ef2ea6c811268af19d41dd83b3be29f0321a8",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
}
],
+4 -2
View File
@@ -11,7 +11,7 @@ let package = Package(
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3")
],
targets: [
.executableTarget(
@@ -21,7 +21,9 @@ let package = Package(
],
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
.process("Assets.xcassets"),
.copy("yt-dlp"),
.copy("ffmpeg")
]
)
]
+2 -1
View File
@@ -44,7 +44,7 @@
## ✨ Features
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitterbacked securely by `yt-dlp` and `ffmpeg` via our Add-on Gatekeeper.
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
@@ -85,6 +85,7 @@ Firelink stands on the shoulders of giants. A massive thank you to the contribut
- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
- **[Sparkle](https://sparkle-project.org/)** - A secure and reliable software update framework for macOS.
---
+10
View File
@@ -24,6 +24,16 @@ 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"
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
for media_engine in yt-dlp ffmpeg; do
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
if [[ -x "$media_engine_path" ]]; then
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
chmod +x "$RESOURCES_DIR/$media_engine"
else
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
fi
done
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
+16 -4
View File
@@ -50,13 +50,15 @@ struct AddDownloadsView: View {
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
.sheet(isPresented: $showingDuplicates) {
DuplicateResolutionView(
conflicts: $conflictingDownloads,
onConfirm: {
showingDuplicates = false
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
}
},
onCancel: {
showingDuplicates = false
@@ -82,6 +84,16 @@ struct AddDownloadsView: View {
}
.onDisappear {
metadataTask?.cancel()
linkText = ""
pendingDownloads = []
headerText = ""
cookieText = ""
mirrorText = ""
useAuthorization = false
authUsername = ""
authPassword = ""
checksumEnabled = false
checksumValue = ""
}
}
@@ -312,7 +324,7 @@ struct AddDownloadsView: View {
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
Button("Add to Queue") {
addDownloads(start: false)
@@ -324,7 +336,7 @@ struct AddDownloadsView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.keyboardShortcut(.defaultAction)
.keyboardShortcut(showingDuplicates ? nil : .defaultAction)
}
}
+22
View File
@@ -183,6 +183,12 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var extensionPairingToken: String {
didSet {
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
}
@Published var message = ""
private let defaults: UserDefaults
@@ -219,6 +225,13 @@ final class AppSettings: ObservableObject {
downloadDirectories = Self.defaultDirectories()
}
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
downloadDirectories[category] = Self.defaultDirectory(for: category).path
}
@@ -374,6 +387,15 @@ final class AppSettings: ObservableObject {
return host == normalizedPattern
}
private static func generateSecureToken() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else {
return UUID().uuidString
}
return Data(bytes).base64EncodedString()
}
private static func defaultDirectories() -> [DownloadCategory: String] {
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
}
+2 -9
View File
@@ -56,21 +56,14 @@ final class Aria2DownloadEngine {
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
"/usr/bin/aria2c"
"/usr/bin/aria2c",
"/opt/local/bin/aria2c"
]
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return URL(fileURLWithPath: found)
}
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
for folder in path.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
if FileManager.default.isExecutableFile(atPath: candidate.path) {
return candidate
}
}
return nil
}
-192
View File
@@ -1,192 +0,0 @@
import Foundation
import CryptoKit
enum BinaryDownloaderError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case downloadFailed(Error?)
case moveFailed(Error)
case permissionFailed(Error)
case unzipFailed
case unsupportedDownloadURL
case missingChecksum
case checksumMismatch
var errorDescription: String? {
switch self {
case .invalidResponse:
"The add-on server returned an invalid response."
case .httpError(let statusCode):
"The add-on download failed with HTTP \(statusCode)."
case .downloadFailed(let error):
error?.localizedDescription ?? "The add-on download failed."
case .moveFailed(let error):
error.localizedDescription
case .permissionFailed(let error):
"Could not mark the add-on executable: \(error.localizedDescription)"
case .unzipFailed:
"Could not extract the downloaded add-on archive."
case .unsupportedDownloadURL:
"The add-on URL must be HTTP or HTTPS."
case .missingChecksum:
"The add-on configuration is missing a SHA-256 checksum."
case .checksumMismatch:
"The downloaded add-on did not match the expected SHA-256 checksum."
}
}
}
final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
private let url: URL
private let destination: URL
private let expectedSHA256: String?
private let onProgress: @Sendable (Double) -> Void
private let session: URLSession
private let continuation: CheckedContinuation<Void, Error>
init(
url: URL,
destination: URL,
expectedSHA256: String?,
onProgress: @escaping @Sendable (Double) -> Void,
continuation: CheckedContinuation<Void, Error>
) {
self.url = url
self.destination = destination
self.expectedSHA256 = expectedSHA256
self.onProgress = onProgress
self.continuation = continuation
let config = URLSessionConfiguration.ephemeral
self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below
super.init()
}
static func download(
from url: URL,
to destination: URL,
expectedSHA256: String? = nil,
onProgress: @escaping @Sendable (Double) -> Void
) async throws {
try await withCheckedThrowingContinuation { continuation in
let downloader = BinaryDownloader(
url: url,
destination: destination,
expectedSHA256: expectedSHA256,
onProgress: onProgress,
continuation: continuation
)
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
let task = session.downloadTask(with: url)
task.resume()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
defer { session.finishTasksAndInvalidate() }
guard let response = downloadTask.response as? HTTPURLResponse else {
continuation.resume(throwing: BinaryDownloaderError.invalidResponse)
return
}
guard (200...299).contains(response.statusCode) else {
continuation.resume(throwing: BinaryDownloaderError.httpError(statusCode: response.statusCode))
return
}
do {
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
throw BinaryDownloaderError.unsupportedDownloadURL
}
let isZip = url.pathExtension.lowercased() == "zip"
let stagingURL = destination
.deletingLastPathComponent()
.appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString).staged")
var cleanupURLs: [URL] = [stagingURL]
defer {
for cleanupURL in cleanupURLs {
try? FileManager.default.removeItem(at: cleanupURL)
}
}
if isZip {
let tempZip = location.appendingPathExtension("zip")
try FileManager.default.moveItem(at: location, to: tempZip)
cleanupURLs.append(tempZip)
let extractDir = tempZip.deletingLastPathComponent().appendingPathComponent("extracted_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
cleanupURLs.append(extractDir)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-q", tempZip.path, "-d", extractDir.path]
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw BinaryDownloaderError.unzipFailed
}
let expectedName = destination.lastPathComponent
var foundBinary: URL?
if let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: nil) {
for case let fileURL as URL in enumerator {
if fileURL.lastPathComponent == expectedName || fileURL.lastPathComponent == expectedName + "c" {
foundBinary = fileURL
break
}
}
}
guard let foundBinary = foundBinary else {
throw BinaryDownloaderError.unzipFailed
}
try FileManager.default.moveItem(at: foundBinary, to: stagingURL)
} else {
try FileManager.default.moveItem(at: location, to: stagingURL)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stagingURL.path)
if let expectedSHA256 {
let actualSHA256 = try Self.sha256Hex(for: stagingURL)
guard actualSHA256.caseInsensitiveCompare(expectedSHA256.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame else {
throw BinaryDownloaderError.checksumMismatch
}
}
try installStagedBinary(stagingURL, at: destination)
continuation.resume()
} catch {
continuation.resume(throwing: BinaryDownloaderError.moveFailed(error))
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
onProgress(progress)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
session.finishTasksAndInvalidate()
continuation.resume(throwing: BinaryDownloaderError.downloadFailed(error))
}
}
private func installStagedBinary(_ stagedURL: URL, at destination: URL) throws {
if FileManager.default.fileExists(atPath: destination.path) {
_ = try FileManager.default.replaceItemAt(destination, withItemAt: stagedURL)
} else {
try FileManager.default.moveItem(at: stagedURL, to: destination)
}
}
private static func sha256Hex(for url: URL) throws -> String {
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}
+2 -22
View File
@@ -208,26 +208,6 @@ final class DownloadController: ObservableObject {
}
}
func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) {
var item = item
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
item.category = FileClassifier.category(forFileName: item.fileName)
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
if let password = item.credentials?.password, !password.isEmpty {
KeychainCredentialStore.setPassword(password, for: item.id)
}
downloads.append(item)
engineMessage = "Added \(item.fileName) to \(item.category.rawValue)."
saveDownloads()
if startImmediately {
startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID)
}
}
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
@@ -534,7 +514,7 @@ final class DownloadController: ObservableObject {
do {
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Checking media add-ons..."
$0.message = "Checking bundled media engines..."
}
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
@@ -622,7 +602,7 @@ final class DownloadController: ObservableObject {
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Process \(handle.processIdentifier)"
$0.message = "Starting..."
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
+72 -15
View File
@@ -134,23 +134,80 @@ enum DownloadMetadataFetcher {
if h == "localhost" || h.hasSuffix(".local") { return true }
if !h.contains(".") && !h.contains(":") { return true }
let parts = h.split(separator: ".")
if parts.count == 4, let first = Int(parts[0]), let second = Int(parts[1]) {
if first == 127 || first == 10 || (first == 192 && second == 168) {
return true
}
if first == 172 && (16...31).contains(second) {
return true
}
if first == 169 && second == 254 {
return true
}
}
var hints = addrinfo(
ai_flags: 0,
ai_family: AF_UNSPEC,
ai_socktype: SOCK_STREAM,
ai_protocol: 0,
ai_addrlen: 0,
ai_canonname: nil,
ai_addr: nil,
ai_next: nil
)
if h.contains(":") {
if h == "[::1]" || h.hasPrefix("[fc") || h.hasPrefix("[fd") || h.hasPrefix("[fe8") || h.hasPrefix("[fe9") || h.hasPrefix("[fea") || h.hasPrefix("[feb") {
return true
var res: UnsafeMutablePointer<addrinfo>?
if getaddrinfo(host, nil, &hints, &res) == 0 {
var current = res
while let info = current {
let family = info.pointee.ai_family
if family == AF_INET {
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
let first = (ip >> 24) & 0xFF
let second = (ip >> 16) & 0xFF
if first == 127 || first == 10 || (first == 192 && second == 168) {
freeaddrinfo(res)
return true
}
if first == 172 && (16...31).contains(second) {
freeaddrinfo(res)
return true
}
if first == 169 && second == 254 {
freeaddrinfo(res)
return true
}
} else if family == AF_INET6 {
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
let isULA = (bytes.0 & 0xFE) == 0xFC
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
if isLoopback || isULA || isLinkLocal {
freeaddrinfo(res)
return true
}
if isIPv4Mapped {
let first = bytes.12
let second = bytes.13
if first == 127 || first == 10 || (first == 192 && second == 168) {
freeaddrinfo(res)
return true
}
if first == 172 && (16...31).contains(second) {
freeaddrinfo(res)
return true
}
if first == 169 && second == 254 {
freeaddrinfo(res)
return true
}
}
}
current = info.pointee.ai_next
}
freeaddrinfo(res)
}
return false
}
+9 -3
View File
@@ -14,7 +14,12 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@Published var releaseNotes: String?
@Published var releaseNotes: AttributedString?
@Published var automaticallyChecksForUpdates: Bool = true {
didSet {
_updater?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
}
}
var expectedContentLength: UInt64 = 0
var receivedContentLength: UInt64 = 0
@@ -28,6 +33,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
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)")
}
@@ -87,7 +93,7 @@ struct FirelinkApp: App {
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
@@ -126,7 +132,7 @@ struct FirelinkApp: App {
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
Window("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
-48
View File
@@ -1,48 +0,0 @@
import Foundation
struct AddonConfig: Codable, Equatable, Sendable {
let version: String
let macArm64: URL?
let macX64: URL?
let macArm64SHA256: String?
let macX64SHA256: String?
enum CodingKeys: String, CodingKey {
case version
case macArm64 = "mac-arm64"
case macX64 = "mac-x64"
case macArm64SHA256 = "mac-arm64-sha256"
case macX64SHA256 = "mac-x64-sha256"
}
/// Returns the appropriate download URL for the current system architecture
var currentArchURL: URL? {
#if arch(arm64)
return macArm64
#elseif arch(x86_64)
return macX64
#else
return nil
#endif
}
var currentArchSHA256: String? {
#if arch(arm64)
return macArm64SHA256
#elseif arch(x86_64)
return macX64SHA256
#else
return nil
#endif
}
}
struct GatekeeperConfig: Codable, Equatable, Sendable {
let ytDlp: AddonConfig?
let ffmpeg: AddonConfig?
enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp"
case ffmpeg
}
}
+49 -1
View File
@@ -30,7 +30,8 @@ enum KeychainCredentialStore {
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecValueData as String: Data(password.utf8)
kSecValueData as String: Data(password.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
@@ -44,6 +45,53 @@ enum KeychainCredentialStore {
kSecAttrAccount as String: id.uuidString
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
private static let extensionTokenService = "local.firelink.extension-token"
private static let extensionTokenAccount = "pairing-token"
static func extensionToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: extensionTokenService,
kSecAttrAccount as String: extensionTokenAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
@discardableResult
static func setExtensionToken(_ token: String) -> Bool {
deleteExtensionToken()
let attributes: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: extensionTokenService,
kSecAttrAccount as String: extensionTokenAccount,
kSecValueData as String: Data(token.utf8),
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess
}
@discardableResult
static func deleteExtensionToken() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: extensionTokenService,
kSecAttrAccount as String: extensionTokenAccount
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
+25 -20
View File
@@ -8,25 +8,25 @@ final class LocalExtensionServer: @unchecked Sendable {
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
// IMPORTANT(Backward Compatibility):
// Extension updates on Mozilla/Chrome stores can take several days to be approved.
// Therefore, we MUST NOT introduce breaking changes to the LocalExtensionServer API
// without maintaining backward compatibility for older extensions.
// If you need to update the API (e.g., changing the payload structure), add the new
// token here and handle both versions in `processRequest`.
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
// Firelink Companion sends this token.
// We now use a dynamic token generated in AppSettings, but fallback to this
// for backward compatibility during the extension rollout if needed, though
// we'll enforce the dynamic token strictly in the processRequest method.
static let legacyExtensionToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
private let listener: NWListener
private let downloadController: DownloadController
private let settings: AppSettings
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
init?(downloadController: DownloadController) {
init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController
self.settings = settings
let parameters = NWParameters.tcp
var createdListener: NWListener?
@@ -108,7 +108,7 @@ final class LocalExtensionServer: @unchecked Sendable {
if let origin, isAllowedExtensionOrigin(origin) {
headers.append("Access-Control-Allow-Origin: \(origin)")
headers.append("Vary: Origin")
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
}
@@ -128,10 +128,6 @@ final class LocalExtensionServer: @unchecked Sendable {
}
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
guard request.path == "/download" else {
return .notFound
}
let host = request.header(named: "host") ?? ""
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
@@ -142,13 +138,22 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
guard request.method == "POST" else {
return .methodNotAllowed
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
guard let token = request.header(named: Constants.extensionRequestHeader),
token == expectedToken else {
return .forbidden
}
guard let token = request.header(named: Constants.extensionRequestHeader),
Constants.supportedExtensionTokens.contains(token) else {
return .forbidden
if request.path == "/ping" {
return request.method == "GET" ? .ok : .methodNotAllowed
}
guard request.path == "/download" else {
return .notFound
}
guard request.method == "POST" else {
return .methodNotAllowed
}
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
+33 -19
View File
@@ -29,11 +29,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
@@ -44,7 +44,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
var arguments = [
"--newline",
"--ffmpeg-location", ffmpegURL.path,
"--extractor-args", "youtube:player_client=ios,tv",
"--force-ipv4",
"-o", item.destinationPath
]
@@ -97,30 +97,44 @@ final class MediaDownloadEngine: @unchecked Sendable {
messageUpdate: messageUpdate
)
let group = DispatchGroup()
group.enter() // output
group.enter() // error
group.enter() // process
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
outputHandler.handle(text)
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
}
if finishedProcess.terminationStatus == 0 {
process.terminationHandler = { _ in
group.leave()
}
group.notify(queue: .global()) {
if process.terminationStatus == 0 {
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else {
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
}
}
+22 -141
View File
@@ -3,7 +3,6 @@ import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case downloading(progress: Double)
case installed(version: String)
case failed(error: String)
}
@@ -12,10 +11,6 @@ enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp"
case ffmpeg
var defaultsKey: String {
return "Firelink.AddonVersion.\(self.rawValue)"
}
var binaryName: String {
switch self {
case .ytDlp: return "yt-dlp"
@@ -31,116 +26,56 @@ final class MediaEngineManager: ObservableObject {
@Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled
private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")!
private var addonsDirectory: URL {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
}
private var installTasks: [AddonType: Task<Void, Error>] = [:]
private init() {
checkLocalInstallation()
}
func binaryPath(for addon: AddonType) -> URL {
return addonsDirectory.appendingPathComponent(addon.binaryName)
func binaryPath(for addon: AddonType) -> URL? {
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
#if SWIFT_PACKAGE
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
#endif
}
return nil
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
guard installTasks[addon] == nil else { continue }
let path = binaryPath(for: addon)
if FileManager.default.isExecutableFile(atPath: path.path) {
if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
setState(for: addon, to: .installed(version: version))
} else {
setState(for: addon, to: .installed(version: "Unknown"))
}
if binaryPath(for: addon) != nil {
setState(for: addon, to: .installed(version: "Bundled"))
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func fetchLatestConfig() async throws -> GatekeeperConfig {
var request = URLRequest(url: configURL)
request.cachePolicy = .reloadIgnoringLocalCacheData
request.timeoutInterval = 30
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
}
func ensureInstalled(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
let task = installationTask(for: addon, from: config)
group.addTask {
try await task.value
}
}
try await group.waitForAll()
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed:
return false
case .downloading, .notInstalled, .failed:
case .notInstalled, .failed:
return true
}
}
guard !missingAddons.isEmpty else { return }
try await ensureInstalled(addons: missingAddons)
}
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
let state: AddonState
let configVersion: String?
switch addon {
case .ytDlp:
state = ytDlpState
configVersion = config.ytDlp?.version
case .ffmpeg:
state = ffmpegState
configVersion = config.ffmpeg?.version
for missing in missingAddons {
setState(for: missing, to: .failed(error: "Bundled executable missing"))
}
switch state {
case .notInstalled, .failed:
return true
case .downloading:
return true
case .installed(let version):
guard let configVersion else { return false }
return version != configVersion
}
}
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
if let task = installTasks[addon] {
return task
}
let task = Task { @MainActor in
defer { self.installTasks[addon] = nil }
try await self.install(addon: addon, from: config)
}
installTasks[addon] = task
return task
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
}
private func state(for addon: AddonType) -> AddonState {
@@ -150,60 +85,6 @@ final class MediaEngineManager: ObservableObject {
}
}
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
setState(for: addon, to: .downloading(progress: 0))
let addonConfig: AddonConfig? = {
switch addon {
case .ytDlp: return config.ytDlp
case .ffmpeg: return config.ffmpeg
}
}()
guard let addonConfig = addonConfig else {
setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)"))
throw URLError(.badURL)
}
guard let downloadURL = addonConfig.currentArchURL else {
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
throw URLError(.badURL)
}
guard downloadURL.scheme?.lowercased() == "https" else {
setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS"))
throw URLError(.badURL)
}
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSHA256.isEmpty else {
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
throw BinaryDownloaderError.missingChecksum
}
do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon)
try await BinaryDownloader.download(
from: downloadURL,
to: destination,
expectedSHA256: expectedSHA256
) { progress in
Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress))
}
}
UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey)
setState(for: addon, to: .installed(version: addonConfig.version))
} catch {
setState(for: addon, to: .failed(error: error.localizedDescription))
throw error
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
+58 -36
View File
@@ -51,7 +51,7 @@ enum MediaExtractionEngine {
case .processFailed(let msg): return "Extraction failed: \(msg)"
case .invalidOutput: return "Invalid output from media engine."
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
case .timedOut: return "Fetching metadata timed out. Try again, update yt-dlp, or change the selected browser cookie source."
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
}
}
}
@@ -62,12 +62,13 @@ enum MediaExtractionEngine {
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
) async throws -> (MediaMetadata, [CleanFormatOption]) {
let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path
guard FileManager.default.isExecutableFile(atPath: ytDlpPath) else {
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw ExtractionError.processFailed("yt-dlp binary not found.")
}
let ytDlpPath = ytDlpURL.path
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,tv"]
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--force-ipv4"]
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
args.append(url.absoluteString)
@@ -116,11 +117,12 @@ enum MediaExtractionEngine {
}
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
var runtimes: [String] = []
if let denoPath = executablePath(named: "deno", candidates: [
"/opt/homebrew/bin/deno",
"/usr/local/bin/deno"
]) {
args.append(contentsOf: ["--js-runtimes", "deno:\(denoPath)"])
runtimes.append("deno:\(denoPath)")
}
if let nodePath = executablePath(named: "node", candidates: [
@@ -128,23 +130,28 @@ enum MediaExtractionEngine {
"/usr/local/bin/node",
"/usr/bin/node"
]) {
args.append(contentsOf: ["--js-runtimes", "node:\(nodePath)"])
runtimes.append("node:\(nodePath)")
}
if !runtimes.isEmpty {
args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
}
}
private static func executablePath(named name: String, candidates: [String]) -> String? {
if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
return path
}
var safeCandidates = candidates
safeCandidates.append(contentsOf: [
"/opt/homebrew/bin/\(name)",
"/usr/local/bin/\(name)",
"/usr/bin/\(name)",
"/opt/local/bin/\(name)"
])
let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? ""
for directory in pathEnvironment.split(separator: ":") {
let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path
for candidate in safeCandidates {
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
@@ -374,43 +381,57 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
process.standardError = errorPipe
process.standardInput = nil
let group = DispatchGroup()
group.enter() // output
group.enter() // error
group.enter() // process
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
outputBuffer.append(data)
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
outputBuffer.append(data)
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
errorBuffer.append(data)
if data.isEmpty {
handle.readabilityHandler = nil
group.leave()
} else {
errorBuffer.append(data)
}
}
lock.withLock {
self.process = process
}
process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
process.terminationHandler = { _ in
group.leave()
}
if finishedProcess.terminationStatus == 0 {
group.notify(queue: .global()) {
if process.terminationStatus == 0 {
continuation.resume(returning: outputBuffer.data)
return
}
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let message = [stderr, stdout]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
continuation.resume(
throwing: MediaExtractionEngine.ExtractionError.processFailed(
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
} else {
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let message = [stderr, stdout]
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
continuation.resume(
throwing: MediaExtractionEngine.ExtractionError.processFailed(
message.isEmpty ? "Exit code \(process.terminationStatus)" : message
)
)
)
}
}
do {
@@ -420,6 +441,7 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
} catch {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
// We do not care about the DispatchGroup if we throw immediately here
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
}
}
@@ -1,268 +0,0 @@
import SwiftUI
struct MediaInspectorInlineView: View {
let url: URL
let cookieSource: BrowserCookieSource
let credentials: DownloadCredentials?
let transferOptions: DownloadTransferOptions
let onCancel: () -> Void
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
@ObservedObject private var engineManager = MediaEngineManager.shared
@State private var isLoading = true
@State private var statusText = "Checking Media Engine..."
@State private var metadata: MediaMetadata?
@State private var options: [CleanFormatOption] = []
@State private var errorMessage: String?
@State private var loadTask: Task<Void, Never>?
enum MediaType: String, CaseIterable, Identifiable {
case video = "Video"
case audio = "Audio"
var id: String { rawValue }
}
@State private var selectedType: MediaType = .video
@State private var selectedVideoQuality: String = "Best"
@State private var selectedVideoFormat: String = "MP4"
@State private var selectedAudioFormat: String = "MP3"
var body: some View {
HStack(spacing: 16) {
if isLoading {
ProgressView()
.controlSize(.regular)
let ytState = engineManager.ytDlpState
let ffState = engineManager.ffmpegState
if case let .downloading(p) = ytState, p > 0 {
Text("Downloading yt-dlp: \(Int(p * 100))%")
.foregroundStyle(.secondary)
} else if case let .downloading(p) = ffState, p > 0 {
Text("Downloading ffmpeg: \(Int(p * 100))%")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 2) {
Text(statusText)
.foregroundStyle(.secondary)
cookieStatusLabel
}
}
Spacer()
} else if let errorMessage {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(errorMessage)
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer()
Button("Retry") {
loadMetadata()
}
} else if let metadata {
if let thumbnail = metadata.thumbnail {
AsyncImage(url: thumbnail) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 80, height: 50)
}
}
VStack(alignment: .leading, spacing: 4) {
Text(metadata.title ?? "Unknown Title")
.font(.subheadline.weight(.medium))
.lineLimit(1)
HStack(spacing: 12) {
Picker("Type", selection: $selectedType) {
ForEach(availableTypes) { type in
Text(type.rawValue).tag(type)
}
}
.labelsHidden()
.frame(width: 80)
if selectedType == .video {
Picker("Quality", selection: $selectedVideoQuality) {
ForEach(availableVideoQualities, id: \.self) { q in
Text(q).tag(q)
}
}
.labelsHidden()
.frame(width: 90)
Picker("Format", selection: $selectedVideoFormat) {
ForEach(availableVideoFormats, id: \.self) { f in
Text(f).tag(f)
}
}
.labelsHidden()
.frame(width: 80)
} else {
Picker("Format", selection: $selectedAudioFormat) {
ForEach(availableAudioFormats, id: \.self) { f in
Text(f).tag(f)
}
}
.labelsHidden()
.frame(width: 90)
}
}
if let selected = resolveSelectedOption() {
Text(selected.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 16)
Button("Cancel") {
onCancel()
}
.keyboardShortcut(.cancelAction)
Button("Extract") {
if let selected = resolveSelectedOption() {
onDownload(selected, metadata)
}
}
.buttonStyle(.borderedProminent)
.disabled(resolveSelectedOption() == nil)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(.quaternary.opacity(0.35))
)
.onAppear {
loadMetadata()
}
.onDisappear {
loadTask?.cancel()
loadTask = nil
}
.onChange(of: url) { _, _ in loadMetadata() }
.onChange(of: cookieSource) { _, _ in loadMetadata() }
.onChange(of: credentials) { _, _ in loadMetadata() }
.onChange(of: transferOptions) { _, _ in loadMetadata() }
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
.onChange(of: options) { _, _ in ensureValidSelection() }
}
private var availableTypes: [MediaType] {
var types: [MediaType] = []
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
return types
}
private var availableVideoQualities: [String] {
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
return NSOrderedSet(array: qualities).array as? [String] ?? []
}
private var availableVideoFormats: [String] {
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
return NSOrderedSet(array: formats).array as? [String] ?? []
}
private var availableAudioFormats: [String] {
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
return NSOrderedSet(array: formats).array as? [String] ?? []
}
private func ensureValidSelection() {
if !availableTypes.contains(selectedType), let first = availableTypes.first {
selectedType = first
}
if selectedType == .video {
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
selectedVideoQuality = first
}
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
selectedVideoFormat = first
}
} else {
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
selectedAudioFormat = first
}
}
}
private func resolveSelectedOption() -> CleanFormatOption? {
if selectedType == .video {
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
} else {
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
}
}
@ViewBuilder
private var cookieStatusLabel: some View {
if let browserName = cookieSource.ytDlpBrowserName {
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
} else {
Label("Browser cookies off", systemImage: "circle")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func loadMetadata() {
loadTask?.cancel()
isLoading = true
errorMessage = nil
metadata = nil
options = []
loadTask = Task {
do {
await MainActor.run { statusText = "Checking yt-dlp..." }
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
guard !Task.isCancelled else { return }
await MainActor.run { statusText = "Fetching Metadata..." }
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
for: url,
cookieSource: cookieSource,
credentials: credentials,
transferOptions: transferOptions
)
guard !Task.isCancelled else { return }
await MainActor.run {
if fetchedOptions.isEmpty {
self.errorMessage = "No downloadable media formats were found."
} else {
self.metadata = fetchedMetadata
self.options = fetchedOptions
self.ensureValidSelection()
}
self.loadTask = nil
withAnimation { self.isLoading = false }
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
self.loadTask = nil
withAnimation { self.isLoading = false }
}
}
}
}
}
@@ -7,6 +7,9 @@ struct AboutSettingsPane: View {
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
private let sparkleURL = URL(string: "https://sparkle-project.org/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
@@ -85,7 +88,9 @@ struct AboutSettingsPane: View {
}
Button {
sparkleUpdater.updateChoiceReply?(.install)
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Label("Install and Relaunch", systemImage: "sparkles")
.frame(maxWidth: .infinity)
@@ -108,7 +113,7 @@ struct AboutSettingsPane: View {
}
}
if let notes = sparkleUpdater.releaseNotes, !notes.isEmpty {
if let notes = sparkleUpdater.releaseNotes {
DisclosureGroup("What's New") {
ScrollView {
Text(notes)
@@ -125,14 +130,24 @@ struct AboutSettingsPane: View {
HStack(spacing: 12) {
Button {
sparkleUpdater.updateChoiceReply?(.install)
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Text("Download & Install")
}
.buttonStyle(.borderedProminent)
Button("Remind Me Later") {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.dismiss)
}
Button("Skip This Version") {
sparkleUpdater.updateChoiceReply?(.skip)
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.skip)
}
}
}
@@ -204,6 +219,11 @@ struct AboutSettingsPane: View {
}
}
}
Divider()
.padding(.vertical, 4)
Toggle("Automatically check for updates", isOn: $sparkleUpdater.automaticallyChecksForUpdates)
}
.padding(.vertical, 8)
.animation(.easeInOut, value: sparkleUpdater.isChecking)
@@ -236,7 +256,15 @@ struct AboutSettingsPane: View {
HStack {
Text("Powered by")
Link("aria2", destination: aria2URL)
HStack(spacing: 4) {
Link("aria2", destination: aria2URL)
Text("").foregroundStyle(.secondary)
Link("yt-dlp", destination: ytDlpURL)
Text("").foregroundStyle(.secondary)
Link("ffmpeg", destination: ffmpegURL)
Text("").foregroundStyle(.secondary)
Link("Sparkle", destination: sparkleURL)
}
Spacer()
Link("MIT License", destination: licenseURL)
}
@@ -250,5 +278,11 @@ struct AboutSettingsPane: View {
}
}
.formStyle(.grouped)
.onDisappear {
if let reply = sparkleUpdater.updateChoiceReply {
sparkleUpdater.updateChoiceReply = nil
reply(.dismiss)
}
}
}
}
@@ -6,9 +6,6 @@ struct EngineSettingsPane: View {
@StateObject private var engineManager = MediaEngineManager.shared
@State private var version = "Checking..."
@State private var isCheckingForUpdates = false
@State private var updateCheckResult: String?
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
@@ -16,23 +13,13 @@ struct EngineSettingsPane: View {
var body: some View {
Form {
Section {
LabeledContent("Status") {
if executableURL != nil {
Label("Ready", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
} else {
Label("Missing", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
LabeledContent("Binary Path") {
Text(executableURL?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
@@ -54,40 +41,10 @@ struct EngineSettingsPane: View {
}
Section {
LabeledContent("Updates") {
HStack(spacing: 8) {
Button {
checkMediaEngineUpdates()
} label: {
Text("Check for Updates")
}
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
if isCheckingForUpdates {
ProgressView().controlSize(.small)
Text("Checking...")
.foregroundStyle(.secondary)
.font(.subheadline)
} else if let result = updateCheckResult {
if result == "Up to date" || result == "Updated successfully" {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(result)
.foregroundStyle(.secondary)
.font(.subheadline)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(result)
.foregroundStyle(.red)
.font(.subheadline)
}
}
}
}
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
LabeledContent("Browser Cookies") {
Picker("", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
@@ -97,14 +54,12 @@ struct EngineSettingsPane: View {
.labelsHidden()
.frame(maxWidth: 200)
}
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState)
} header: {
Text("Media Extractors")
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Powers video and audio extraction from supported sites.")
if settings.mediaCookieSource != .none {
Text(settings.mediaCookieSource.statusDetail)
}
@@ -117,63 +72,30 @@ struct EngineSettingsPane: View {
}
}
private func checkMediaEngineUpdates() {
Task {
isCheckingForUpdates = true
updateCheckResult = nil
// Brief visual feedback delay
try? await Task.sleep(nanoseconds: 800_000_000)
do {
let wasDownloading = isDownloadingMediaEngines
try await engineManager.ensureInstalled()
if wasDownloading || isDownloadingMediaEngines {
updateCheckResult = "Updated successfully"
} else {
updateCheckResult = "Up to date"
}
} catch {
updateCheckResult = "Update failed: \(error.localizedDescription)"
}
isCheckingForUpdates = false
try? await Task.sleep(nanoseconds: 4_000_000_000)
if !isCheckingForUpdates {
withAnimation {
updateCheckResult = nil
}
}
}
}
private var isDownloadingMediaEngines: Bool {
if case .downloading = engineManager.ytDlpState { return true }
if case .downloading = engineManager.ffmpegState { return true }
return false
}
@ViewBuilder
private func addonStatusRow(title: String, state: AddonState) -> some View {
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
LabeledContent(title) {
switch state {
case .notInstalled:
Label("Missing", systemImage: "xmark.circle.fill")
.foregroundStyle(.orange)
case .downloading(let progress):
HStack(spacing: 6) {
ProgressView(value: progress)
.frame(width: 60)
Text("\(Int(progress * 100))%")
.monospacedDigit()
VStack(alignment: .trailing) {
switch state {
case .notInstalled:
Text("Missing")
.foregroundStyle(.red)
case .installed(let version):
Text(version)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Text("Error")
.foregroundStyle(.red)
.help(error)
}
case .installed(let version):
Label("v\(version)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Label("Failed", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
.help(error)
Text(path?.path ?? "Not found")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
.textSelection(.enabled)
}
}
}
@@ -33,20 +33,22 @@ class InlineUpdateUserDriver: NSObject, SPUUserDriver {
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
DispatchQueue.main.async {
if let htmlString = String(data: downloadData.data, encoding: .utf8) {
self.updater?.releaseNotes = self.stripHTML(htmlString)
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let nsAttrString = try? NSMutableAttributedString(data: downloadData.data, options: options, documentAttributes: nil) {
let range = NSRange(location: 0, length: nsAttrString.length)
nsAttrString.removeAttribute(.foregroundColor, range: range)
nsAttrString.removeAttribute(.font, range: range)
if let attrString = try? AttributedString(nsAttrString, including: \.appKit) {
self.updater?.releaseNotes = attrString
}
}
}
}
private func stripHTML(_ string: String) -> String {
guard let data = string.data(using: .utf8) else { return string }
if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) {
return attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
}
return string
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {
}
@@ -3,51 +3,94 @@ import SwiftUI
struct IntegrationSettingsPane: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@State private var showToast = false
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
ScrollView {
VStack(spacing: 24) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
Text("Connect Browser Extension")
.font(.title.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.body)
}
Spacer()
}
.padding(.vertical, 4)
}
.padding(.bottom, 8)
Section("Installation") {
VStack(alignment: .leading, spacing: 16) {
Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.")
.foregroundStyle(.secondary)
// Step 1: Copy Token
StepCardView(
stepNumber: 1,
title: "Copy Pairing Token",
description: "This secure token authorizes your browser extension.",
icon: "doc.on.clipboard.fill",
iconColor: .blue,
actionText: "Copy Token",
action: {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
withAnimation {
showToast = true
}
},
secondaryActionText: "Regenerate",
secondaryAction: {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
}
)
Button {
// Step 2: Get Extension
StepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your favorite browser.",
icon: "globe",
iconColor: .orange,
actionText: "Firefox Add-ons",
action: {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url)
}
} label: {
Label("Install on Firefox", systemImage: "arrow.down.app")
.font(.headline)
.padding(.horizontal, 12)
.padding(.vertical, 4)
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
.tint(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) // Firefox Orange
.controlSize(.large)
}
.padding(.vertical, 8)
}
)
Section("Diagnostics") {
LabeledContent("Local receiver") {
// Step 3: Paste and Save
StepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
Divider()
// Diagnostics
HStack {
Text("Diagnostics:")
.foregroundStyle(.secondary)
Spacer()
if let port = controller.extensionServerPort {
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
@@ -56,14 +99,107 @@ struct IntegrationSettingsPane: View {
.foregroundStyle(.orange)
}
}
}
.font(.footnote)
.padding(.top, 8)
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(32)
}
.formStyle(.grouped)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
struct StepCardView: View {
let stepNumber: Int
let title: String
let description: String
let icon: String
let iconColor: Color
let actionText: String?
let action: (() -> Void)?
var secondaryActionText: String? = nil
var secondaryAction: (() -> Void)? = nil
var body: some View {
HStack(spacing: 16) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 32, height: 32)
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
Text("\(stepNumber)")
.font(.system(.headline, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
// Icon
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(iconColor.opacity(0.15))
.frame(width: 48, height: 48)
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(iconColor)
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
// Action Button
HStack(spacing: 8) {
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(8)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -0,0 +1,45 @@
import SwiftUI
struct ToastNotification: ViewModifier {
var message: String
@Binding var isShowing: Bool
func body(content: Content) -> some View {
ZStack(alignment: .bottom) {
content
if isShowing {
VStack {
Spacer()
Text(message)
.font(.subheadline.weight(.medium))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Capsule()
.fill(Color.black.opacity(0.8))
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 24)
}
.zIndex(1)
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
isShowing = false
}
}
}
}
}
}
}
extension View {
func toast(isShowing: Binding<Bool>, message: String) -> some View {
self.modifier(ToastNotification(message: message, isShowing: isShowing))
}
}
+8 -8
View File
@@ -6,17 +6,17 @@
<description>Most recent updates for Firelink</description>
<language>en</language>
<item>
<title>Version 0.5.7</title>
<title>Version 0.6.2</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.5.7</sparkle:releaseNotesLink>
<pubDate>Sat, 06 Jun 2026 11:36:06 +0000</pubDate>
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.5.7/Firelink-0.5.7-mac-arm64.dmg"
sparkle:version="22"
sparkle:shortVersionString="0.5.7"
length="8871979"
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.6.2</sparkle:releaseNotesLink>
<pubDate>Mon, 08 Jun 2026 13:15:04 +0000</pubDate>
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.6.2/Firelink-0.6.2-mac-arm64.dmg"
sparkle:version="27"
sparkle:shortVersionString="0.6.2"
length="75883565"
type="application/octet-stream"
sparkle:edSignature="Y4QEJqGw6IeXL5JmaRqt5U22Bmc8JD02+oLRIFoKgOJO4v6p6+AmBP9VoM3yp/MAzj+gJMWiI/Gf4aPx4x3dAg==" />
sparkle:edSignature="ZxDVZryK1xisBGEsvhORR3B09LxWbrzXqo/IppR2CY694JvbKzWMKPHxaZk6x68V9YxeyiiyeyBAsV8oHdeQBQ==" />
</item>
</channel>
</rss>