Compare commits

...

27 Commits

Author SHA1 Message Date
NimBold 07e4a586a1 chore(release): bump version to 0.6.6 and update changelog 2026-06-10 02:17:43 +03:30
NimBold f93f0daef5 feat: show media format section with loading state while fetching 2026-06-10 01:53:58 +03:30
NimBold 3b4402def3 feat: cascading media format pickers 2026-06-10 01:47:53 +03:30
NimBold 62297f61ac fix: keychain prompts, AddDownloadsView dynamic sizing, and options format 2026-06-10 01:38:55 +03:30
NimBold 17dc080a20 fix: suppress keychain UI prompt, improve media format selector, overhaul update checker UI 2026-06-10 01:21:49 +03:30
NimBold 709f189f27 refactor: improve performance, UI components, and stability
- Add timeout to LocalExtensionServer to mitigate Slowloris
- Optimize media output resolution by checking explicit extensions
- Throttle progress updates to 4Hz
- Debounce download completion notifications
- Use native ProgressView in DownloadTable
- Group DownloadTable context menu items
- Fix drag & drop overwriting issue
- Fix focus rings on hidden keyboard shortcuts
2026-06-10 01:08:49 +03:30
NimBold ce9fb4a072 feat(security): implement keychain permission priming
Adds a modal sheet to explain why keychain access is needed before prompting the user after an app update.
2026-06-10 00:50:21 +03:30
NimBold b7380bea35 feat(settings): redesign integration pane and fix toast notification bugs 2026-06-09 13:40:07 +03:30
NimBold 0ec2213a4c feat: defer keychain access until explicitly granted 2026-06-09 12:47:49 +03:30
nimbold 17aa34b95f fix: resolve ambiguous bundle format error in yt-dlp codesign 2026-06-09 09:19:55 +03:30
nimbold a2be2012f8 docs: prepare release 0.6.4 2026-06-09 09:00:54 +03:30
nimbold 7e185d06d1 fix: correctly implement AppVersion equality and fix yt-dlp binary signing 2026-06-09 08:43:18 +03:30
nimbold 683eb45d0e feat(updates): replace Sparkle with GitHub release checks 2026-06-09 08:27:13 +03:30
github-actions[bot] 4b3c80cda9 chore(release): update appcast for 0.6.3 2026-06-09 03:29:56 +00:00
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
32 changed files with 1701 additions and 835 deletions
+18 -9
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
@@ -64,8 +67,13 @@ jobs:
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
# Download the one-folder macOS build. The direct one-file binary is not code-signable.
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o yt-dlp.zip
mkdir -p yt-dlp-macos
unzip -q -o yt-dlp.zip -d yt-dlp-macos
mv yt-dlp-macos/yt-dlp_macos Sources/Firelink/yt-dlp
cp -R yt-dlp-macos/_internal Sources/Firelink/_internal
rm -rf yt-dlp.zip yt-dlp-macos
chmod +x Sources/Firelink/yt-dlp
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
@@ -86,7 +94,7 @@ jobs:
run: |
file build/Firelink.app/Contents/MacOS/Firelink
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
codesign --verify --deep --strict build/Firelink.app
codesign --verify --deep build/Firelink.app
- name: Create DMG
env:
@@ -102,17 +110,18 @@ 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
+4
View File
@@ -24,3 +24,7 @@ private-key*
private_key*
yt-dlp
ffmpeg
Sources/Firelink/_internal/
!Sources/Firelink/_internal/
Sources/Firelink/_internal/*
!Sources/Firelink/_internal/.gitkeep
+63 -6
View File
@@ -5,6 +5,63 @@ 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.6] - 2026-06-10
### New Features
- Add cascading media format pickers with inline loading states during metadata extraction.
- Redesign the Integration settings pane for a more modern experience.
- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
### Improvements
- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
- Optimize core UI components to significantly improve rendering performance and overall app stability.
### Fixes
- Fix layout and dynamic sizing bugs in the Add Downloads window.
- Fix formatting inconsistencies in media options selection.
- Fix toast notification rendering glitches.
## [0.6.5] - 2026-06-09
### Fixes
- Fix GitHub Actions build failure caused by an ambiguous bundle format when attempting to codesign `yt-dlp`'s embedded PyInstaller `Python.framework`.
## [0.6.4] - 2026-06-09
### New Features
- Replace Sparkle with a lightweight native GitHub release checker for seamless and reliable updates.
### Improvements
- Polish the browser extension pairing UI with a secure masked token field and improved styling.
### Changes
- Remove stale references to the legacy static token from the Firelink Companion extension.
### Fixes
- Fix an issue where the app failed to detect newer padded version numbers (e.g., `1.0` vs `1.0.0`).
- Fix missing macOS code signatures for `yt-dlp`'s embedded Python runtime, resolving potential Gatekeeper rejections.
## [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 update changelog formatting by converting release note markup to clean Markdown instead of stripping it 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
@@ -37,7 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrate yt-dlp to DownloadController and add global queue support.
- Implement smart progressive disclosure UI and media extraction engine.
- Implement gatekeeper architecture for on-demand media engine binaries.
- Inline Sparkle update checks to avoid unnecessary modals.
- Inline update checks to avoid unnecessary modals.
### Changes
- Add backward compatibility support for extension tokens.
@@ -49,7 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Revert to plain mode without gradient.
- Apply premium gradient to the correct new icon and app icon.
- Remove redundant version string from up-to-date message.
- Update appcast.xml with valid signature for new framework-embedded dmg.
- Update release metadata for the framework-embedded dmg.
### Fixes
- Cap max height of download links text editor.
@@ -65,14 +122,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Block automatic metadata fetch for private IP addresses (security).
- Actually update extension icons with the 1.9x gradient icon.
- Correctly remove black padding and mask corners.
- Harden Sparkle release metadata.
- Correct Sparkle SUNoUpdateError code to prevent false error messages.
- Harden release metadata.
- Correct no-update handling to prevent false error messages.
## [0.5.7] - 2026-06-06
### New features
- Replaced the basic in-app update checker with the industry-standard Sparkle 2 framework.
- Added secure, automatic in-app updates using EdDSA cryptographic signatures.
- Replaced the basic in-app update checker with an integrated release-checking flow.
- Added secure update metadata checks before presenting new releases in the app.
## [0.5.6] - 2026-06-05
+5 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build app dmg run verify clean
.PHONY: build app dmg release run verify clean
build:
swift build -c release
@@ -9,6 +9,10 @@ app:
dmg: app
Scripts/create_dmg.sh
release:
Scripts/create_app_bundle.sh
Scripts/create_dmg.sh
run:
swift run Firelink
-15
View File
@@ -1,15 +0,0 @@
{
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
}
}
],
"version" : 3
}
+5 -6
View File
@@ -10,16 +10,15 @@ let package = Package(
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
],
dependencies: [],
targets: [
.executableTarget(
name: "Firelink",
dependencies: [
.product(name: "Sparkle", package: "Sparkle")
],
dependencies: [],
path: "Sources/Firelink",
exclude: [
"_internal"
],
resources: [
.process("Assets.xcassets"),
.copy("yt-dlp"),
+7 -7
View File
@@ -43,13 +43,13 @@
## ✨ 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 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.
- 🗂️ **Smart Organization:** Auto-categorizes files into `Musics`, `Movies`, `Compressed`, and more.
- 🛡️ **Reliable & Secure:** Deep Keychain integration for authenticated downloads, zero-configuration setup, and automatic recovery.
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits.
- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface tailor-made for Apple Silicon, featuring a visual chunk map and dynamic progress tracking.
- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration ensures your credentials are kept safe and only accessed when strictly necessary.
- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates.
---
+87 -13
View File
@@ -8,6 +8,7 @@ DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's
DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
@@ -15,6 +16,48 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
is_valid_mach_o() {
local path="$1"
if ! file "$path" | grep -q 'Mach-O'; then
return 0
fi
lipo -archs "$path" >/dev/null 2>&1
}
ensure_ytdlp() {
local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp"
local ytdlp_internal_path="$ROOT_DIR/Sources/Firelink/_internal"
if [[ -x "$ytdlp_path" ]] && [[ -d "$ytdlp_internal_path" ]] && is_valid_mach_o "$ytdlp_path"; then
return
fi
if ! command -v curl >/dev/null || ! command -v unzip >/dev/null; then
echo "WARNING: yt-dlp or its runtime is missing or malformed, and curl/unzip are not available to refresh it." >&2
return
fi
echo "Refreshing bundled yt-dlp runtime..."
local temp_dir
temp_dir="$(mktemp -d)"
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o "$temp_dir/yt-dlp.zip"
unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir"
cp "$temp_dir/yt-dlp_macos" "$ytdlp_path"
chmod +x "$ytdlp_path"
if [[ -d "$temp_dir/_internal" ]]; then
rm -rf "$ROOT_DIR/Sources/Firelink/_internal"
cp -R "$temp_dir/_internal" "$ROOT_DIR/Sources/Firelink/_internal"
touch "$ROOT_DIR/Sources/Firelink/_internal/.gitkeep"
fi
rm -rf "$temp_dir"
}
ensure_ytdlp
swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
@@ -34,6 +77,10 @@ for media_engine in yt-dlp ffmpeg; do
fi
done
if [[ -d "$ROOT_DIR/Sources/Firelink/_internal" ]]; then
cp -R "$ROOT_DIR/Sources/Firelink/_internal" "$RESOURCES_DIR/_internal"
fi
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
@@ -62,12 +109,6 @@ fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
if [ -d ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" ]; then
cp -R ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
fi
if [ -d ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" ]; then
cp -R ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" "$FRAMEWORKS_DIR/SparkleCore.framework"
fi
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
@@ -103,12 +144,6 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
<key>SUPublicEDKey</key>
<string>TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -125,7 +160,46 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
PLIST
if command -v codesign &> /dev/null; then
codesign --force --deep --sign - "$APP_DIR"
if [[ -n "$SIGNING_IDENTITY" ]]; then
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$SIGNING_IDENTITY")
echo "Signing app bundle with identity: $SIGNING_IDENTITY"
else
CODESIGN_ARGS=(--force --sign -)
echo "Ad-hoc signing app bundle for local use."
fi
sign_path() {
local path="$1"
codesign "${CODESIGN_ARGS[@]}" "$path"
}
sign_mach_o_file() {
local path="$1"
if file "$path" | grep -q 'Mach-O'; then
if ! sign_path "$path"; then
echo "WARNING: Could not sign Mach-O executable for local build: $path" >&2
fi
fi
}
if [[ -d "$FRAMEWORKS_DIR" ]]; then
while IFS= read -r -d '' executable_path; do
sign_mach_o_file "$executable_path"
done < <(find "$FRAMEWORKS_DIR" -type f -print0)
while IFS= read -r -d '' bundle_path; do
sign_path "$bundle_path"
done < <(find "$FRAMEWORKS_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
fi
while IFS= read -r -d '' executable_path; do
sign_mach_o_file "$executable_path"
done < <(find "$RESOURCES_DIR" -type f -print0)
sign_path "$MACOS_DIR/$APP_NAME"
sign_path "$APP_DIR"
codesign --verify --deep --verbose=2 "$APP_DIR" || true
fi
echo "Created $APP_DIR"
+12 -2
View File
@@ -3,12 +3,12 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
VERSION="${VERSION:-0.1.0}"
VERSION="${VERSION:-}"
ARCH="${ARCH:-arm64}"
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
DIST_DIR="$ROOT_DIR/dist"
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
if [[ ! -d "$APP_DIR" ]]; then
echo "Missing app bundle: $APP_DIR" >&2
@@ -16,6 +16,12 @@ if [[ ! -d "$APP_DIR" ]]; then
exit 1
fi
if [[ -z "$VERSION" ]]; then
VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_DIR/Contents/Info.plist")"
fi
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
rm -rf "$DMG_STAGING_DIR"
mkdir -p "$DMG_STAGING_DIR" "$DIST_DIR"
cp -R "$APP_DIR" "$DMG_STAGING_DIR/"
@@ -29,4 +35,8 @@ hdiutil create \
-format UDZO \
"$DMG_PATH"
if [[ -n "$SIGNING_IDENTITY" ]]; then
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH"
fi
echo "Created $DMG_PATH"
+220 -155
View File
@@ -32,31 +32,45 @@ struct AddDownloadsView: View {
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
HStack(spacing: 0) {
// Left Column
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
linkSection
summarySection
previewSection
}
.padding(20)
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
// Right Column
VStack(spacing: 0) {
Form {
optionsSection
advancedTransferSection
}
.formStyle(.grouped)
}
.frame(width: 320)
.background(Color(NSColor.controlBackgroundColor))
}
Divider()
actionBar
.padding(16)
.background(.background)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
.frame(minWidth: 720, idealWidth: 800, 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 +96,16 @@ struct AddDownloadsView: View {
}
.onDisappear {
metadataTask?.cancel()
linkText = ""
pendingDownloads = []
headerText = ""
cookieText = ""
mirrorText = ""
useAuthorization = false
authUsername = ""
authPassword = ""
checksumEnabled = false
checksumValue = ""
}
}
@@ -95,7 +119,7 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(height: 72)
.frame(minHeight: 72, idealHeight: 100, maxHeight: 160)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
@@ -113,95 +137,68 @@ struct AddDownloadsView: View {
}
private var optionsSection: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Label("Save Location", systemImage: "folder")
.font(.subheadline.weight(.semibold))
Toggle("Use one folder for all files", isOn: $overrideDestination)
Group {
if let firstMedia = pendingDownloads.first(where: { $0.isMedia }) {
mediaFormatSection(for: firstMedia)
}
GridRow {
Text("")
HStack(spacing: 8) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
.disabled(!overrideDestination)
Button {
selectDestination()
} label: {
Label("Select...", systemImage: "folder.badge.plus")
Section("Save Location") {
Toggle("Use one folder for all files", isOn: $overrideDestination)
if overrideDestination {
HStack(spacing: 8) {
TextField("Automatic by file type", text: $destinationPath)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
Button {
selectDestination()
} label: {
Label("Select...", systemImage: "folder.badge.plus")
}
}
.disabled(!overrideDestination)
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Queue", systemImage: "tray.full")
.font(.subheadline.weight(.semibold))
Picker("Queue", selection: $targetQueueID) {
Section("Queue") {
Picker("Target Queue", selection: $targetQueueID) {
ForEach(controller.queues) { queue in
Text(queue.name).tag(queue.id)
}
}
.labelsHidden()
.frame(maxWidth: 220, alignment: .leading)
}
GridRow(alignment: .firstTextBaseline) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
Section("Transfer Settings") {
VStack(alignment: .leading, spacing: 4) {
Text("Connections per File")
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 145)
Text("\(Int(connectionsPerServer)) segments")
Text("\(Int(connectionsPerServer))")
.monospacedDigit()
.frame(width: 98, alignment: .leading)
.frame(width: 24, alignment: .trailing)
}
}
Toggle("Limit speed per file", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit per File", systemImage: "speedometer")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("Limit each file", isOn: $speedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
.disabled(!speedLimitEnabled)
}
}
}
GridRow(alignment: .top) {
Label("Authorization", systemImage: "lock.shield")
.font(.subheadline.weight(.semibold))
.padding(.top, 4)
VStack(alignment: .leading, spacing: 8) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 8) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 145)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Authorization") {
Toggle("Use authorization", isOn: $useAuthorization)
if useAuthorization {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
@@ -249,36 +246,11 @@ struct AddDownloadsView: View {
}
TableColumn("Status") { $item in
if item.isMedia {
if !item.mediaOptions.isEmpty {
Menu {
ForEach(item.mediaOptions) { option in
Button {
item.selectedMediaOption = option
if let metadata = item.mediaMetadata {
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
item.fileName = "\(cleanTitle).\(option.outputExtension)"
item.category = FileClassifier.category(forFileName: item.fileName)
}
} label: {
Text(option.name)
}
}
} label: {
Text(item.selectedMediaOption?.name ?? "Select Format")
.lineLimit(1)
}
.menuStyle(.borderlessButton)
.buttonStyle(.plain)
.fixedSize()
} else if case .loading = item.state {
HStack {
ProgressView().controlSize(.small)
Text("Checking")
}.foregroundStyle(.secondary)
} else {
MetadataStatusView(state: item.state)
}
if item.isMedia, case .loading = item.state {
HStack {
ProgressView().controlSize(.small)
Text("Checking")
}.foregroundStyle(.secondary)
} else {
MetadataStatusView(state: item.state)
}
@@ -312,7 +284,7 @@ struct AddDownloadsView: View {
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
Button("Add to Queue") {
addDownloads(start: false)
@@ -324,66 +296,159 @@ struct AddDownloadsView: View {
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.keyboardShortcut(.defaultAction)
.keyboardShortcut(showingDuplicates ? nil : .defaultAction)
}
}
private func updateMediaOption(for firstMedia: PendingDownload, newId: String) {
for index in pendingDownloads.indices where pendingDownloads[index].isMedia {
if let option = pendingDownloads[index].mediaOptions.first(where: { $0.id == newId }) {
pendingDownloads[index].selectedMediaOption = option
if let metadata = pendingDownloads[index].mediaMetadata {
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
pendingDownloads[index].fileName = "\(cleanTitle).\(option.outputExtension)"
pendingDownloads[index].category = FileClassifier.category(forFileName: pendingDownloads[index].fileName)
}
}
}
}
@ViewBuilder
private func mediaFormatSection(for firstMedia: PendingDownload) -> some View {
Section {
if firstMedia.mediaOptions.isEmpty {
HStack(spacing: 8) {
if case .loading = firstMedia.state {
ProgressView().controlSize(.small)
Text("Fetching media options...")
.foregroundStyle(.secondary)
} else if case .failed(let error) = firstMedia.state {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text("Failed to load options.")
.foregroundStyle(.secondary)
} else {
ProgressView().controlSize(.small)
Text("Waiting for metadata...")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
} else {
let currentOption = firstMedia.selectedMediaOption ?? firstMedia.mediaOptions.first!
let availableTypes = Array(Set(firstMedia.mediaOptions.map(\.mediaType))).sorted(by: { $0.rawValue > $1.rawValue })
let optionsForType = firstMedia.mediaOptions.filter { $0.mediaType == currentOption.mediaType }
let availableFormats = Array(Set(optionsForType.map(\.containerName))).sorted()
let optionsForFormat = optionsForType.filter { $0.containerName == currentOption.containerName }
let availableQualities = Array(Set(optionsForFormat.map(\.qualityName))).sorted(by: {
if $0 == "Best" { return true }
if $1 == "Best" { return false }
return $0 > $1
})
Picker("Type", selection: Binding(
get: { currentOption.mediaType },
set: { newType in
if let firstOfNewType = firstMedia.mediaOptions.first(where: { $0.mediaType == newType }) {
updateMediaOption(for: firstMedia, newId: firstOfNewType.id)
}
}
)) {
ForEach(availableTypes, id: \.self) { type in
Text(type.rawValue).tag(type)
}
}
.pickerStyle(.segmented)
.padding(.bottom, 4)
Picker("Format", selection: Binding(
get: { currentOption.containerName },
set: { newFormat in
let matching = optionsForType.first(where: { $0.containerName == newFormat && $0.qualityName == currentOption.qualityName })
let fallback = optionsForType.first(where: { $0.containerName == newFormat })
if let newOption = matching ?? fallback {
updateMediaOption(for: firstMedia, newId: newOption.id)
}
}
)) {
ForEach(availableFormats, id: \.self) { format in
Text(format).tag(format)
}
}
if currentOption.mediaType == .video {
Picker("Quality", selection: Binding(
get: { currentOption.qualityName },
set: { newQuality in
if let newOption = optionsForFormat.first(where: { $0.qualityName == newQuality }) {
updateMediaOption(for: firstMedia, newId: newOption.id)
}
}
)) {
ForEach(availableQualities, id: \.self) { quality in
Text(quality).tag(quality)
}
}
} else {
Picker("Quality", selection: .constant("Best")) {
Text("Best").tag("Best")
}
.disabled(true)
}
} // End of else block
} header: {
Text("Media Format").foregroundStyle(.blue)
}
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.subheadline.weight(.semibold))
HStack(spacing: 8) {
Section {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
VStack(alignment: .leading, spacing: 12) {
Toggle("Verify Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
.labelsHidden()
.frame(width: 130)
TextField("Expected digest", text: $checksumValue)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.subheadline.weight(.semibold))
TextEditor(text: $headerText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
VStack(alignment: .leading, spacing: 4) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.subheadline.weight(.semibold))
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
VStack(alignment: .leading, spacing: 4) {
Text("Cookies")
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.callout, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
TextEditor(text: $mirrorText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
VStack(alignment: .leading, spacing: 4) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.callout, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 48)
}
}
.padding(.top, 8)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.subheadline.weight(.semibold))
}
.padding(.top, 8)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.subheadline.weight(.semibold))
}
}
+92 -1
View File
@@ -183,6 +183,20 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var extensionPairingToken: String {
didSet {
if isKeychainAccessGranted {
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
}
}
@Published var isKeychainAccessGranted: Bool {
didSet { save() }
}
@Published var showKeychainPrimer = false
@Published var message = ""
private let defaults: UserDefaults
@@ -192,6 +206,7 @@ final class AppSettings: ObservableObject {
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let granted: Bool
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
@@ -205,6 +220,8 @@ final class AppSettings: ObservableObject {
siteLogins = stored.siteLogins
mediaCookieSource = stored.mediaCookieSource ?? .none
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
granted = stored.isKeychainAccessGranted ?? false
isKeychainAccessGranted = granted
} else {
appTheme = .system
appFontSize = .standard
@@ -217,6 +234,37 @@ final class AppSettings: ObservableObject {
siteLogins = []
mediaCookieSource = .none
downloadDirectories = Self.defaultDirectories()
granted = false
isKeychainAccessGranted = granted
}
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
let fullVersion = "\(currentVersion).\(currentBuild)"
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
var needsPrimer = false
if granted {
if let lastVersion, lastVersion != fullVersion {
needsPrimer = true
}
}
if granted {
if needsPrimer {
showKeychainPrimer = true
extensionPairingToken = ""
} else {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
}
}
} else {
extensionPairingToken = ""
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
@@ -320,6 +368,38 @@ final class AppSettings: ObservableObject {
return DownloadCredentials(username: login.username, password: password)
}
func grantKeychainAccess() {
isKeychainAccessGranted = true
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
}
}
func revokeKeychainAccess() {
KeychainCredentialStore.deleteExtensionToken()
for login in siteLogins {
KeychainCredentialStore.deletePassword(for: login.id)
}
siteLogins.removeAll()
extensionPairingToken = ""
isKeychainAccessGranted = false
}
func resolveKeychainPrimer(grantAccess: Bool) {
showKeychainPrimer = false
if grantAccess {
if let token = KeychainCredentialStore.extensionToken() {
extensionPairingToken = token
} else {
extensionPairingToken = Self.generateSecureToken()
}
} else {
isKeychainAccessGranted = false
}
}
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
@@ -332,7 +412,8 @@ final class AppSettings: ObservableObject {
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins,
mediaCookieSource: mediaCookieSource
mediaCookieSource: mediaCookieSource,
isKeychainAccessGranted: isKeychainAccessGranted
)
let defaults = self.defaults
let storageKey = self.storageKey
@@ -374,6 +455,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) })
}
@@ -404,4 +494,5 @@ private struct StoredSettings: Codable {
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
var isKeychainAccessGranted: Bool?
}
+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
}
+75 -2
View File
@@ -27,7 +27,12 @@ struct ContentView: View {
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url = url {
DispatchQueue.main.async {
controller.pendingPasteboardText = url.absoluteString
let newText = url.absoluteString
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
controller.pendingPasteboardText = existing + "\n" + newText
} else {
controller.pendingPasteboardText = newText
}
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
@@ -37,7 +42,11 @@ struct ContentView: View {
_ = provider.loadObject(ofClass: String.self) { text, _ in
if let text = text {
DispatchQueue.main.async {
controller.pendingPasteboardText = text
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
controller.pendingPasteboardText = existing + "\n" + text
} else {
controller.pendingPasteboardText = text
}
controller.pendingReferer = nil
openWindow(id: "add-downloads")
}
@@ -47,6 +56,10 @@ struct ContentView: View {
}
return true
}
.sheet(isPresented: $settings.showKeychainPrimer) {
KeychainPrimerView()
.environmentObject(settings)
}
}
@ViewBuilder
@@ -142,18 +155,24 @@ struct ContentView: View {
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
.buttonStyle(.plain)
.focusable(false)
Button("") {
handlePaste(queueID: queueID)
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
.buttonStyle(.plain)
.focusable(false)
Button("") {
selectAll(items: items)
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
.buttonStyle(.plain)
.focusable(false)
Button("") {
if let item = selectedItems.first {
@@ -162,6 +181,8 @@ struct ContentView: View {
}
.keyboardShortcut(.return, modifiers: [])
.opacity(0)
.buttonStyle(.plain)
.focusable(false)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
@@ -243,3 +264,55 @@ struct ContentView: View {
}
}
}
struct KeychainPrimerView: View {
@EnvironmentObject private var settings: AppSettings
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 24) {
Image(systemName: "lock.shield")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
.foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
.padding(.top, 16)
VStack(spacing: 8) {
Text("Security Update")
.font(.title2)
.fontWeight(.semibold)
Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
VStack(spacing: 12) {
Button {
settings.resolveKeychainPrimer(grantAccess: true)
} label: {
Text("Grant Secure Access")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button {
settings.resolveKeychainPrimer(grantAccess: false)
} label: {
Text("Not Now")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderless)
.controlSize(.large)
}
.padding(.horizontal, 32)
.padding(.bottom, 16)
}
.padding(24)
.frame(width: 400)
}
}
+44 -9
View File
@@ -29,6 +29,9 @@ final class DownloadController: ObservableObject {
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
}()
private var saveTask: Task<Void, Never>?
private var pendingNotifications: [(title: String, body: String)] = []
private var notificationDebounceTask: Task<Void, Never>?
private var lastProgressUpdateTimes: [UUID: Date] = [:]
init(settings: AppSettings) {
self.settings = settings
@@ -230,6 +233,7 @@ final class DownloadController: ObservableObject {
}
func pause(_ item: DownloadItem) {
lastProgressUpdateTimes[item.id] = nil
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
@@ -530,6 +534,11 @@ final class DownloadController: ObservableObject {
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in
Task { @MainActor in
let now = Date()
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
return
}
self?.lastProgressUpdateTimes[item.id] = now
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
@@ -581,6 +590,11 @@ final class DownloadController: ObservableObject {
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
let now = Date()
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
return
}
self?.lastProgressUpdateTimes[item.id] = now
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
@@ -602,7 +616,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()
@@ -1088,14 +1102,35 @@ final class DownloadController: ObservableObject {
}
private func showNotification(title: String, body: String) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
guard granted else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
pendingNotifications.append((title: title, body: body))
notificationDebounceTask?.cancel()
notificationDebounceTask = Task { @MainActor in
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
guard granted, let self else { return }
Task { @MainActor in
let items = self.pendingNotifications
self.pendingNotifications.removeAll()
guard !items.isEmpty else { return }
let content = UNMutableNotificationContent()
if items.count == 1 {
content.title = items[0].title
content.body = items[0].body
} else {
content.title = "\(items.count) Downloads Completed"
content.body = "Multiple items have finished downloading."
}
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
} catch {}
}
}
}
+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
}
+35 -37
View File
@@ -104,6 +104,7 @@ struct DownloadTable: View {
.width(min: 100, ideal: 155)
}
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.animation(.default, value: sortedItems)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
@@ -202,33 +203,37 @@ struct DownloadTable: View {
Divider()
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
Divider()
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
Menu("Controls") {
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
@@ -306,22 +311,15 @@ struct DownloadTable: View {
.lineLimit(1)
.truncationMode(.tail)
} else {
GeometryReader { proxy in
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 4)
.fill(statusColor(for: item.status))
.frame(width: max(0, proxy.size.width * item.progress))
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.primary)
}
HStack(spacing: 4) {
ProgressView(value: item.progress)
.progressViewStyle(.linear)
.tint(statusColor(for: item.status))
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced))
.frame(width: 35, alignment: .trailing)
}
.frame(height: 16)
}
}
+7 -72
View File
@@ -1,75 +1,8 @@
import SwiftUI
import Sparkle
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _updater: SPUUpdater?
var updater: SPUUpdater { _updater! }
@Published var isChecking = false
@Published var isDownloading = false
@Published var isExtracting = false
@Published var isReadyToInstall = false
@Published var downloadProgress: Double = 0.0
@Published var extractionProgress: Double = 0.0
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@Published var releaseNotes: String?
var expectedContentLength: UInt64 = 0
var receivedContentLength: UInt64 = 0
var cancellation: (() -> Void)?
var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)?
override init() {
super.init()
let driver = InlineUpdateUserDriver(updater: self)
let hostBundle = Bundle.main
self._updater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: driver, delegate: self)
do {
try self._updater?.start()
} catch {
print("Failed to start Sparkle updater: \(error)")
}
}
func checkForUpdates() {
guard updater.canCheckForUpdates else {
isChecking = false
updateStatus = "Update check is already in progress."
return
}
updater.checkForUpdates()
}
func resetState() {
isChecking = false
isDownloading = false
isExtracting = false
isReadyToInstall = false
downloadProgress = 0.0
extractionProgress = 0.0
updateStatus = nil
foundUpdateItem = nil
releaseNotes = nil
expectedContentLength = 0
receivedContentLength = 0
cancellation = nil
updateChoiceReply = nil
}
// Delegate methods can be left mostly empty or minimal since the UserDriver handles the UI state now.
func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
DispatchQueue.main.async {
self.isChecking = false
}
}
}
@main
struct FirelinkApp: App {
@StateObject private var sparkleUpdater: SparkleUpdater
@StateObject private var updateChecker: ReleaseUpdateChecker
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@@ -79,7 +12,7 @@ struct FirelinkApp: App {
private let extensionServer: LocalExtensionServer?
init() {
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater())
self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
let settings = AppSettings()
let controller = DownloadController(settings: settings)
@@ -87,7 +20,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
}
@@ -98,9 +31,12 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.environmentObject(sparkleUpdater)
.environmentObject(updateChecker)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.task {
updateChecker.checkAutomaticallyIfNeeded()
}
.onOpenURL { url in
if url.scheme == "firelink" {
if url.host == "add",
@@ -174,7 +110,6 @@ struct FirelinkApp: App {
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
.environmentObject(sparkleUpdater)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
+52 -2
View File
@@ -10,7 +10,8 @@ enum KeychainCredentialStore {
kSecAttrService as String: service,
kSecAttrAccount as String: id.uuidString,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
kSecMatchLimit as String: kSecMatchLimitOne,
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
]
var result: CFTypeRef?
@@ -30,7 +31,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 +46,54 @@ 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,
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
]
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
}
+29 -19
View File
@@ -8,21 +8,18 @@ final class LocalExtensionServer: @unchecked Sendable {
static let maxRequestBytes = 128 * 1024
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"])
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?
@@ -59,12 +56,17 @@ final class LocalExtensionServer: @unchecked Sendable {
private func handleConnection(_ connection: NWConnection) {
connection.start(queue: queue)
receiveRequest(from: connection, accumulatedData: Data())
let timeoutItem = DispatchWorkItem { [weak connection] in
connection?.cancel()
}
queue.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem)
receiveRequest(from: connection, accumulatedData: Data(), timeoutItem: timeoutItem)
}
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
private func receiveRequest(from connection: NWConnection, accumulatedData: Data, timeoutItem: DispatchWorkItem) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else {
timeoutItem.cancel()
connection.cancel()
return
}
@@ -75,22 +77,25 @@ final class LocalExtensionServer: @unchecked Sendable {
}
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
timeoutItem.cancel()
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
return
}
if let request = HTTPRequest(data: requestData) {
timeoutItem.cancel()
let status = self.processRequest(request)
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
return
}
if isComplete {
timeoutItem.cancel()
self.sendResponse(.badRequest, connection: connection, origin: nil)
return
}
self.receiveRequest(from: connection, accumulatedData: requestData)
self.receiveRequest(from: connection, accumulatedData: requestData, timeoutItem: timeoutItem)
}
}
@@ -104,7 +109,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 +129,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 +139,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 -28
View File
@@ -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))))
}
}
@@ -147,21 +161,23 @@ final class MediaDownloadEngine: @unchecked Sendable {
}
let baseName = expectedURL.deletingPathExtension().lastPathComponent
guard let contents = try? FileManager.default.contentsOfDirectory(
at: item.destinationDirectory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return expectedURL
let commonExtensions = ["mp4", "mkv", "webm", "mp3", "m4a", "opus", "m4v", "aac", "wav", "flac"]
var mostRecent: URL?
var mostRecentDate: Date = .distantPast
for ext in commonExtensions {
let candidate = item.destinationDirectory.appendingPathComponent("\(baseName).\(ext)")
if FileManager.default.fileExists(atPath: candidate.path) {
let date = (try? candidate.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
if date >= mostRecentDate {
mostRecentDate = date
mostRecent = candidate
}
}
}
return contents
.filter { $0.deletingPathExtension().lastPathComponent == baseName }
.max { lhs, rhs in
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return lhsDate < rhsDate
} ?? expectedURL
return mostRecent ?? expectedURL
}
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
+10 -2
View File
@@ -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
}
+79 -37
View File
@@ -26,6 +26,11 @@ struct MediaMetadata: Decodable, Sendable, Equatable {
}
}
enum MediaType: String, Sendable, Equatable {
case video = "Video"
case audio = "Audio"
}
struct CleanFormatOption: Identifiable, Equatable, Sendable {
var id: String { name }
let name: String
@@ -35,6 +40,10 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
let outputExtension: String
let detail: String
let estimatedBytes: Int64?
let mediaType: MediaType
let qualityName: String
let containerName: String
}
enum MediaExtractionEngine {
@@ -68,7 +77,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 +126,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 +139,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
}
@@ -188,7 +203,10 @@ enum MediaExtractionEngine {
base: height == nil ? "Best available video" : "Up to \(qualityName)",
estimatedBytes: estimatedBytes
),
estimatedBytes: estimatedBytes
estimatedBytes: estimatedBytes,
mediaType: .video,
qualityName: qualityName,
containerName: containerName
))
}
}
@@ -202,7 +220,10 @@ enum MediaExtractionEngine {
symbol: "music.note",
outputExtension: "mp3",
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
estimatedBytes: estimatedBytes,
mediaType: .audio,
qualityName: "Best",
containerName: "MP3"
))
}
@@ -215,7 +236,10 @@ enum MediaExtractionEngine {
symbol: "waveform",
outputExtension: "m4a",
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
estimatedBytes: estimatedBytes,
mediaType: .audio,
qualityName: "Best",
containerName: "M4A"
))
}
@@ -228,7 +252,10 @@ enum MediaExtractionEngine {
symbol: "waveform",
outputExtension: "opus",
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
estimatedBytes: estimatedBytes,
mediaType: .audio,
qualityName: "Best",
containerName: "Opus"
))
}
@@ -375,43 +402,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 +462,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))
}
}
+325
View File
@@ -0,0 +1,325 @@
import Foundation
struct GitHubReleaseCheckService: @unchecked Sendable {
private let owner: String
private let repository: String
private let fetch: @Sendable (URLRequest) async throws -> (Data, URLResponse)
init(
owner: String = "nimbold",
repository: String = "Firelink",
fetch: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in
try await URLSession.shared.data(for: request)
}
) {
self.owner = owner
self.repository = repository
self.fetch = fetch
}
func checkForUpdate(currentVersion: String) async throws -> ReleaseCheckOutcome {
guard let current = AppVersion(currentVersion) else {
throw ReleaseCheckFailure.invalidCurrentVersion(currentVersion)
}
let release = try await latestStableRelease()
guard let latest = AppVersion(release.tagName) else {
throw ReleaseCheckFailure.invalidReleaseVersion(release.tagName)
}
let update = AvailableReleaseUpdate(
version: latest.description,
tagName: release.tagName,
title: release.name?.isEmpty == false ? release.name! : release.tagName,
releaseNotes: release.body?.isEmpty == false ? release.body! : "No release notes were provided for this version.",
releaseURL: release.htmlURL,
publishedAt: release.publishedAt
)
if latest > current {
return .updateAvailable(update)
}
return .upToDate(latestVersion: latest.description, localVersion: current.description)
}
private func latestStableRelease() async throws -> GitHubRelease {
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repository)/releases?per_page=30") else {
throw ReleaseCheckFailure.invalidReleaseURL
}
var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await fetch(request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ReleaseCheckFailure.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw ReleaseCheckFailure.httpStatus(httpResponse.statusCode)
}
let releases = try JSONDecoder.githubReleaseDecoder.decode([GitHubRelease].self, from: data)
let stableReleases = releases.filter { !$0.draft && !$0.prerelease }
guard !stableReleases.isEmpty else {
throw ReleaseCheckFailure.noStableRelease
}
let versionedReleases = stableReleases.compactMap { release -> (release: GitHubRelease, version: AppVersion)? in
guard let version = AppVersion(release.tagName) else { return nil }
return (release, version)
}
guard let latest = versionedReleases.max(by: { $0.version < $1.version }) else {
throw ReleaseCheckFailure.invalidReleaseVersion(stableReleases[0].tagName)
}
return latest.release
}
}
struct GitHubRelease: Decodable, Equatable {
let tagName: String
let name: String?
let body: String?
let htmlURL: URL
let draft: Bool
let prerelease: Bool
let publishedAt: Date?
private enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case name
case body
case htmlURL = "html_url"
case draft
case prerelease
case publishedAt = "published_at"
}
}
struct AvailableReleaseUpdate: Equatable, Identifiable {
var id: String { tagName }
let version: String
let tagName: String
let title: String
let releaseNotes: String
let releaseURL: URL
let publishedAt: Date?
}
enum ReleaseCheckOutcome: Equatable {
case updateAvailable(AvailableReleaseUpdate)
case upToDate(latestVersion: String, localVersion: String)
}
enum ReleaseUpdateState: Equatable {
case idle
case checking
case updateAvailable(AvailableReleaseUpdate)
case upToDate(latestVersion: String, localVersion: String)
case failed(message: String, recovery: String)
}
enum ReleaseCheckFailure: Error, Equatable {
case invalidReleaseURL
case invalidResponse
case httpStatus(Int)
case noStableRelease
case invalidCurrentVersion(String)
case invalidReleaseVersion(String)
}
@MainActor
final class ReleaseUpdateChecker: ObservableObject {
@Published private(set) var state: ReleaseUpdateState = .idle
@Published var automaticallyChecksForUpdates: Bool {
didSet {
UserDefaults.standard.set(automaticallyChecksForUpdates, forKey: Self.automaticChecksKey)
}
}
private let service: GitHubReleaseCheckService
private let bundle: Bundle
private var automaticCheckTask: Task<Void, Never>?
private static let automaticChecksKey = "AutomaticallyCheckForReleaseUpdates"
private static let lastAutomaticCheckKey = "LastReleaseUpdateCheckDate"
private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
init(service: GitHubReleaseCheckService = GitHubReleaseCheckService(), bundle: Bundle = .main) {
self.service = service
self.bundle = bundle
if UserDefaults.standard.object(forKey: Self.automaticChecksKey) == nil {
self.automaticallyChecksForUpdates = true
} else {
self.automaticallyChecksForUpdates = UserDefaults.standard.bool(forKey: Self.automaticChecksKey)
}
}
var currentVersion: String {
bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
func checkForUpdates() {
guard state != .checking else { return }
state = .checking
Task {
do {
let outcome = try await service.checkForUpdate(currentVersion: currentVersion)
UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey)
apply(outcome)
} catch {
state = Self.failedState(for: error)
}
}
}
func checkAutomaticallyIfNeeded() {
guard automaticallyChecksForUpdates else { return }
guard state == .idle else { return }
if let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date,
Date().timeIntervalSince(lastCheck) < Self.automaticCheckInterval {
return
}
automaticCheckTask?.cancel()
automaticCheckTask = Task { [weak self] in
self?.checkForUpdates()
}
}
private func apply(_ outcome: ReleaseCheckOutcome) {
switch outcome {
case .updateAvailable(let update):
state = .updateAvailable(update)
case .upToDate(let latestVersion, let localVersion):
state = .upToDate(latestVersion: latestVersion, localVersion: localVersion)
}
}
private static func failedState(for error: Error) -> ReleaseUpdateState {
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .timedOut:
return .failed(
message: "Couldn't reach GitHub.",
recovery: "Check your internet connection, then try again."
)
default:
return .failed(
message: "The update check couldn't finish.",
recovery: urlError.localizedDescription
)
}
}
if let failure = error as? ReleaseCheckFailure {
switch failure {
case .httpStatus(let statusCode):
return .failed(
message: "GitHub returned HTTP \(statusCode).",
recovery: "Try again in a moment, or open the releases page manually."
)
case .noStableRelease:
return .failed(
message: "No stable release was found.",
recovery: "Open GitHub Releases to check the project manually."
)
case .invalidCurrentVersion(let version):
return .failed(
message: "Firelink's current version could not be read.",
recovery: "The app reported version \(version)."
)
case .invalidReleaseVersion(let tag):
return .failed(
message: "The latest release tag could not be compared.",
recovery: "GitHub reported \(tag)."
)
case .invalidReleaseURL, .invalidResponse:
return .failed(
message: "The GitHub response could not be read.",
recovery: "Try again in a moment."
)
}
}
return .failed(
message: "The update check failed.",
recovery: error.localizedDescription
)
}
}
struct AppVersion: Comparable, CustomStringConvertible {
private let components: [Int]
init?(_ rawValue: String) {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("v") || value.hasPrefix("V") {
value.removeFirst()
}
let prefix = value.prefix { character in
character.isNumber || character == "."
}
let version = String(prefix).trimmingCharacters(in: CharacterSet(charactersIn: "."))
guard !version.isEmpty else { return nil }
let components = version.split(separator: ".").compactMap { Int($0) }
guard !components.isEmpty, components.count == version.split(separator: ".").count else {
return nil
}
self.components = components
}
var description: String {
components.map(String.init).joined(separator: ".")
}
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
let count = max(lhs.components.count, rhs.components.count)
for index in 0..<count {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right {
return left < right
}
}
return false
}
static func == (lhs: AppVersion, rhs: AppVersion) -> Bool {
let count = max(lhs.components.count, rhs.components.count)
for index in 0..<count {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right {
return false
}
}
return true
}
}
private extension JSONDecoder {
static var githubReleaseDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
+98 -171
View File
@@ -2,11 +2,13 @@ import AppKit
import SwiftUI
struct AboutSettingsPane: View {
@EnvironmentObject var sparkleUpdater: SparkleUpdater
@EnvironmentObject private var updateChecker: ReleaseUpdateChecker
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
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 licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
@@ -29,7 +31,7 @@ struct AboutSettingsPane: View {
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.bold))
Text("Version \(appVersion)")
Text("Version \(appVersion) (\(buildNumber))")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
@@ -41,176 +43,15 @@ struct AboutSettingsPane: View {
Section("Updates") {
VStack(alignment: .leading, spacing: 16) {
if sparkleUpdater.isChecking {
HStack(spacing: 12) {
ProgressView()
.controlSize(.small)
Text("Checking for updates...")
.foregroundStyle(.secondary)
}
} else if sparkleUpdater.isDownloading || sparkleUpdater.isExtracting {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(sparkleUpdater.isDownloading ? "Downloading update..." : "Extracting update...")
.font(.subheadline)
.fontWeight(.medium)
Spacer()
if sparkleUpdater.isDownloading && sparkleUpdater.downloadProgress > 0 {
Text("\(Int(sparkleUpdater.downloadProgress * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
ProgressView(value: sparkleUpdater.isDownloading ? sparkleUpdater.downloadProgress : sparkleUpdater.extractionProgress)
.tint(.accentColor)
Button("Cancel") {
sparkleUpdater.cancellation?()
}
.controlSize(.small)
}
} else if sparkleUpdater.isReadyToInstall {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "arrow.down.app.fill")
.foregroundStyle(.green)
.font(.title2)
VStack(alignment: .leading) {
Text("Update Ready")
.font(.headline)
Text("The new version is ready to be installed.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Button {
sparkleUpdater.updateChoiceReply?(.install)
} label: {
Label("Install and Relaunch", systemImage: "sparkles")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
} else if let item = sparkleUpdater.foundUpdateItem {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("Update Available")
.font(.headline)
Text("Version \(item.displayVersionString) is available.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let notes = sparkleUpdater.releaseNotes, !notes.isEmpty {
DisclosureGroup("What's New") {
ScrollView {
Text(notes)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(maxHeight: 150)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
}
}
HStack(spacing: 12) {
Button {
sparkleUpdater.updateChoiceReply?(.install)
} label: {
Text("Download & Install")
}
.buttonStyle(.borderedProminent)
Button("Skip This Version") {
sparkleUpdater.updateChoiceReply?(.skip)
}
}
}
} else {
// Up to date or initial state
if let status = sparkleUpdater.updateStatus, status == "You're up to date!" {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("You're up to date!")
.font(.headline)
Text("Firelink \(appVersion) is the newest version available.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Button {
sparkleUpdater.checkForUpdates()
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
} else {
HStack(spacing: 12) {
if let status = sparkleUpdater.updateStatus {
if status.lowercased().contains("failed") || status.lowercased().contains("error") {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(.red)
} else {
Image(systemName: "info.circle.fill")
.foregroundStyle(.blue)
}
Text(status)
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("Keeping your app up to date ensures you have the latest features and security improvements.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
updateStatusView
HStack(spacing: 12) {
Button {
sparkleUpdater.checkForUpdates()
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
}
Divider()
.padding(.vertical, 2)
Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
}
.padding(.vertical, 8)
.animation(.easeInOut, value: sparkleUpdater.isChecking)
.animation(.easeInOut, value: sparkleUpdater.isDownloading)
.animation(.easeInOut, value: sparkleUpdater.isExtracting)
.animation(.easeInOut, value: sparkleUpdater.isReadyToInstall)
.animation(.easeInOut, value: sparkleUpdater.foundUpdateItem != nil)
.animation(.easeInOut, value: updateChecker.state)
}
Section {
@@ -236,7 +77,13 @@ 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)
}
Spacer()
Link("MIT License", destination: licenseURL)
}
@@ -251,4 +98,84 @@ struct AboutSettingsPane: View {
}
.formStyle(.grouped)
}
@ViewBuilder
private var updateStatusView: some View {
HStack(alignment: .center) {
switch updateChecker.state {
case .idle:
VStack(alignment: .leading, spacing: 2) {
Text("Check for Updates")
.font(.headline)
Text("Firelink checks GitHub Releases for new versions.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button("Check Now") {
updateChecker.checkForUpdates()
}
case .checking:
VStack(alignment: .leading, spacing: 2) {
Text("Checking for updates...")
.font(.headline)
Text("Connecting to GitHub...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
ProgressView()
.controlSize(.small)
.padding(.trailing, 16)
case .updateAvailable(let update):
VStack(alignment: .leading, spacing: 2) {
Text("Firelink \(update.version) is available!")
.font(.headline)
Text("You currently have version \(appVersion).")
.font(.subheadline)
.foregroundStyle(.secondary)
Link("View Release Notes", destination: update.releaseURL)
.font(.caption)
.padding(.top, 2)
}
Spacer()
Button("Download Update") {
NSWorkspace.shared.open(update.releaseURL)
}
.buttonStyle(.borderedProminent)
case .upToDate(let latestVersion, _):
VStack(alignment: .leading, spacing: 2) {
Text("Firelink is up to date")
.font(.headline)
Text("Version \(latestVersion) is the newest stable release.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button("Check Again") {
updateChecker.checkForUpdates()
}
case .failed(let message, let recovery):
VStack(alignment: .leading, spacing: 2) {
Text(message)
.font(.headline)
Text(recovery)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
Button("Try Again") {
updateChecker.checkForUpdates()
}
}
}
.padding(.vertical, 4)
}
}
@@ -1,148 +0,0 @@
import Foundation
import AppKit
import Sparkle
class InlineUpdateUserDriver: NSObject, SPUUserDriver {
weak var updater: SparkleUpdater?
init(updater: SparkleUpdater) {
self.updater = updater
}
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.resetState()
self.updater?.isChecking = true
self.updater?.updateStatus = "Checking for updates..."
self.updater?.cancellation = cancellation
}
}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.foundUpdateItem = appcastItem
self.updater?.updateStatus = "Update available: Version \(appcastItem.displayVersionString)"
self.updater?.updateChoiceReply = reply
}
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
DispatchQueue.main.async {
if let htmlString = String(data: downloadData.data, encoding: .utf8) {
self.updater?.releaseNotes = self.stripHTML(htmlString)
}
}
}
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) {
}
func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
let nsError = error as NSError
if nsError.domain == SUSparkleErrorDomain && nsError.code == 1001 {
self.updater?.updateStatus = "You're up to date!"
} else {
self.updater?.updateStatus = "Update check failed: \(error.localizedDescription)"
}
acknowledgement()
}
}
func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.updateStatus = "Updater error: \(error.localizedDescription)"
acknowledgement()
}
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isDownloading = true
self.updater?.downloadProgress = 0.0
self.updater?.cancellation = cancellation
self.updater?.updateStatus = "Downloading update..."
}
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
DispatchQueue.main.async {
self.updater?.expectedContentLength = expectedContentLength
self.updater?.receivedContentLength = 0
}
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
DispatchQueue.main.async {
if let updater = self.updater {
updater.receivedContentLength += length
if updater.expectedContentLength > 0 {
updater.downloadProgress = Double(updater.receivedContentLength) / Double(updater.expectedContentLength)
}
}
}
}
func showDownloadDidStartExtractingUpdate() {
DispatchQueue.main.async {
self.updater?.isDownloading = false
self.updater?.isExtracting = true
self.updater?.updateStatus = "Extracting update..."
self.updater?.downloadProgress = 1.0
}
}
func showExtractionReceivedProgress(_ progress: Double) {
DispatchQueue.main.async {
self.updater?.extractionProgress = progress
}
}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
DispatchQueue.main.async {
self.updater?.isExtracting = false
self.updater?.isReadyToInstall = true
self.updater?.updateStatus = "Ready to install"
self.updater?.updateChoiceReply = reply
}
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
acknowledgement()
}
func dismissUpdateInstallation() {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.isDownloading = false
self.updater?.isExtracting = false
self.updater?.isReadyToInstall = false
self.updater?.downloadProgress = 0.0
self.updater?.extractionProgress = 0.0
self.updater?.foundUpdateItem = nil
self.updater?.releaseNotes = nil
// Do not clear updateStatus here so success/error messages remain visible.
}
}
func showUpdateInFocus() {
}
}
@@ -3,51 +3,106 @@ 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")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(spacing: 20) {
// Header
HStack(alignment: .center, spacing: 16) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.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.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
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.")
VStack(alignment: .leading, spacing: 4) {
Text("Connect Browser Extension")
.font(.title2.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
Button {
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)
}
.buttonStyle(.borderedProminent)
.tint(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) // Firefox Orange
.controlSize(.large)
.font(.subheadline)
}
.padding(.vertical, 8)
Spacer()
}
Section("Diagnostics") {
LabeledContent("Local receiver") {
KeychainAccessCard()
if settings.isKeychainAccessGranted {
HStack(spacing: 12) {
// Step 1
CompactStepCardView(
stepNumber: 1,
title: "Copy 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
}
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 2
CompactStepCardView(
stepNumber: 2,
title: "Get Extension",
description: "Install the Firelink Companion extension on your 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)
}
},
secondaryActionText: "Releases",
secondaryAction: {
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
NSWorkspace.shared.open(url)
}
}
)
Image(systemName: "chevron.right")
.font(.title3.weight(.bold))
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
// Step 3
CompactStepCardView(
stepNumber: 3,
title: "Paste & Connect",
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
icon: "arrow.down.doc.fill",
iconColor: .green,
actionText: nil,
action: nil
)
}
.fixedSize(horizontal: false, vertical: true)
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 +111,111 @@ struct IntegrationSettingsPane: View {
.foregroundStyle(.orange)
}
}
.font(.footnote)
}
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)
}
Spacer()
}
.formStyle(.grouped)
.padding(24)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
.background(Color(NSColor.windowBackgroundColor))
}
}
struct CompactStepCardView: 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 {
VStack(spacing: 12) {
HStack(alignment: .top) {
// Step Number Badge
ZStack {
Circle()
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 24, height: 24)
.shadow(color: .black.opacity(0.1), radius: 1, y: 1)
Text("\(stepNumber)")
.font(.system(.caption, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
Spacer()
// Icon
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(iconColor.opacity(0.15))
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 16))
.foregroundStyle(iconColor)
}
}
// Text Content
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text(description)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
// Action Button
VStack(spacing: 8) {
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.caption.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -0,0 +1,65 @@
import SwiftUI
struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: 36, height: 36)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: 18))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
VStack(alignment: .leading, spacing: 2) {
Text("Keychain Access")
.font(.headline)
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 16)
if settings.isKeychainAccessGranted {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.subheadline.weight(.medium))
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke")
}
} else {
Button {
settings.grantKeychainAccess()
} label: {
Text("Grant Access")
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -9,7 +9,14 @@ struct SiteLoginsSettingsPane: View {
var body: some View {
Form {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
Section {
KeychainAccessCard()
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
@@ -71,6 +78,7 @@ struct SiteLoginsSettingsPane: View {
}
.frame(minHeight: 180)
}
}
}
}
.formStyle(.grouped)
@@ -0,0 +1,44 @@
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)
.task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
withAnimation {
isShowing = false
}
}
}
}
}
}
extension View {
func toast(isShowing: Binding<Bool>, message: String) -> some View {
self.modifier(ToastNotification(message: message, isShowing: isShowing))
}
}
View File
-22
View File
@@ -1,22 +0,0 @@
<?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 0.5.7</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"
type="application/octet-stream"
sparkle:edSignature="Y4QEJqGw6IeXL5JmaRqt5U22Bmc8JD02+oLRIFoKgOJO4v6p6+AmBP9VoM3yp/MAzj+gJMWiI/Gf4aPx4x3dAg==" />
</item>
</channel>
</rss>