mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db2b1f6516 | |||
| 336a50ed6c | |||
| f887c62195 | |||
| 109059e10c | |||
| 81b3e0877b | |||
| 6b2901bd50 | |||
| 9261385d59 | |||
| ef0ad42df3 | |||
| b71a58ae06 | |||
| 6dcb0e33e8 | |||
| 34847b6234 | |||
| ac7963e353 | |||
| a4936fa141 |
@@ -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
|
||||
@@ -102,17 +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
|
||||
test -s release_notes.md
|
||||
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file 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
|
||||
|
||||
@@ -5,6 +5,27 @@ 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
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ struct AddDownloadsView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -602,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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,20 +9,24 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
|
||||
// Firelink Companion 1.0.7+ sends this token. Keep accepted tokens here
|
||||
// when future store releases need a non-breaking local API transition.
|
||||
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?
|
||||
@@ -104,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")
|
||||
}
|
||||
|
||||
@@ -124,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 {
|
||||
@@ -138,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 {
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,19 @@ final class MediaEngineManager: ObservableObject {
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
for bundle in [Bundle.main, Bundle.module] {
|
||||
if let bundled = bundle.url(forResource: addon.binaryName, withExtension: nil),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ enum MediaExtractionEngine {
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -117,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: [
|
||||
@@ -129,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
|
||||
}
|
||||
|
||||
@@ -375,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 {
|
||||
@@ -421,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user