Compare commits

...

116 Commits

Author SHA1 Message Date
NimBold 7b3d620efa fix(build): revert to one-file yt-dlp binary to bypass macOS Gatekeeper Library Validation
Gatekeeper enforces Library Validation and prevents `dlopen` of quarantined ad-hoc signed dynamic libraries on ARM64. Because the `_internal` folder was shipped inside the DMG, the user's system flagged it with the com.apple.quarantine attribute, causing the Python shared library to fail to load inside `yt-dlp`.

By reverting back to the single-file PyInstaller binary (which is now properly code-signable without corruption on macOS), PyInstaller extracts the `_internal` folder at runtime to `/var/folders/...`. These extracted files do not inherit the quarantine flag, allowing AMFI to successfully load them and completely bypassing the dlopen system policy rejection.
2026-06-11 05:20:07 +03:30
NimBold db293a2613 chore: re-release 0.7.1 2026-06-11 05:06:39 +03:30
NimBold 49a4358212 fix: increase yt-dlp timeout for YouTube JS PoW
The latest yt-dlp release (2026.06.09) requires heavy JavaScript execution to solve YouTube's PoW challenges. This takes ~35s on most machines, which exceeded the hardcoded 30s timeout, causing 'Failed to load options'. Increased the timeout to 120s and improved the UI to show actual errors instead of hardcoding 'Failed to load options'.
2026-06-11 05:00:39 +03:30
NimBold a916ff3f92 fix: prevent youtube metadata extraction timeout
This fixes an issue where node child processes spawned by yt-dlp would keep standard output pipes open, preventing the EOF event from firing and causing the application to hang until a 30s timeout occurred.
2026-06-11 04:24:40 +03:30
NimBold b4185306ce chore(release): prepare 0.7.1 2026-06-11 04:03:03 +03:30
NimBold 39355f8d2d fix: resolve correct filename for auto-captured downloads 2026-06-11 03:32:14 +03:30
NimBold 55c6dbf41b docs: prepare v0.7.1 release 2026-06-11 03:17:34 +03:30
NimBold ae3476293e fix(security): address v0.7.0 security audit vulnerabilities
- fix(media): use temp config file for yt-dlp credentials
- fix(rpc): exclude rpcSecret and rpcPort from serialization
- fix(ssrf): validate private hosts for all fetches
- fix(uri): add rate limiting to firelink scheme
- fix(auth): implement HMAC-SHA256 request signing for LocalExtensionServer
- fix(fs): prevent path traversal during file deletion
- fix(fs): apply strict 0o600 permissions to aria2.conf
- fix(crypto): remove insecure UUID fallback for pairing token
- fix(http): strip CRLF characters from custom headers
- fix(network): use POSIX socket to eliminate port-finding race window
- fix(ui): limit text input lengths in Add Downloads view
- fix(concurrency): implement thread-safe temp directory cleanup
- feat(ui): modernize sidebar and download table components
2026-06-11 03:13:47 +03:30
NimBold facab68237 chore(release): bump version to 0.7.0 and update changelog 2026-06-11 00:04:51 +03:30
NimBold 3a7c594d7c ci: restore macOS SDK 26 check per project requirements
- Ensure SDK version is at least 26 to avoid UI issues with older SDKs
2026-06-10 23:45:46 +03:30
NimBold 0ace3e4111 fix: resolve additional bugs identified in code review
- core: remove DispatchGroup from MediaExtractionEngine to fix hanging on yt-dlp launch failure
- core: clear readabilityHandlers in Aria2DownloadEngine if process fails to launch
- core: fix TOCTOU type warning for findFreePort
- build: fix inverted logic in Mach-O validation script
- ci: fix hardcoded macOS SDK version check in release workflow
- ci: prevent workflow_dispatch from failing on verify-tag
2026-06-10 23:43:10 +03:30
NimBold 7e2e0eeb77 fix: resolve critical download bugs and configuration issues
- proxy: Prevent SOCKS5 proxy type from being silently converted to HTTP.
- core: Remove DispatchGroup from MediaDownloadEngine to fix a hang caused by unclosed output pipes when child processes inherit file descriptors.
- settings: Fix keychain primer logic to prevent wiping extension credentials on debug builds.
- ui: Resolve a batch rename collision in AddDownloadsView by properly checking new filenames against the current batch sequence.
2026-06-10 23:36:16 +03:30
NimBold 2a5452b7c6 fix: resolve multiple critical bugs identified in code review
- fix(engine): resolve aria2 port TOCTOU race and secure conf file

- fix(server): eliminate main queue deadlock risk in local extension server

- fix(controller): ensure synchronous state save on app termination

- fix(ui): fix table skipping new items by diffing identity instead of count

- fix(scheduler): use 2-letter abbreviations to resolve day name collisions

- fix(settings): purge keychain orphans when primer access is denied

- fix(metadata): prevent synchronous DNS resolution from blocking async pool

- refactor: clean up dead code and unused variables
2026-06-10 19:25:06 +03:30
NimBold fdbacb8a7f feat(ui): overhaul context menu and toolbar for modern hig
- Flattened playback controls in context menu
- Added 'Copy File Path' context action for completed downloads
- Reorganized context menu to prioritize Open/Finder actions and pin Properties to bottom
- Removed ControlGroup and explicit placements in toolbar for clean borderless style
- Renamed Start/Stop toolbar items to Resume/Pause with proper SF Symbols
- Added informative tooltips to toolbar actions
2026-06-10 18:49:55 +03:30
NimBold ffba883961 feat(ui): modernize download list and sidebar styling
- Reintroduce Speed and ETA columns with dedicated rendering
- Restructure Status column to align progress bar natively
- Enhance typography with rounded fonts and monospaced digits
- Move Settings shortcut back into sidebar for faster access
- Optimize download list sorting performance by removing dynamic sortedItems
2026-06-10 17:38:16 +03:30
NimBold 3b695169d6 feat(ui): add stop time to scheduler and unit picker to speed limiter
This commit overhauls the UI of the Scheduler and Speed Limiter tools using modern GroupBox designs. It also adds a highly-requested Stop Time feature to the scheduler to pause queues, and introduces a dynamic unit selection (KB/s, MB/s) to the Speed Limiter.
2026-06-10 12:40:31 +03:30
NimBold a5828ec586 chore: remove temporary test file 2026-06-10 12:28:07 +03:30
NimBold efb4446739 fix(security): track executable modification date for primer detection
Includes the executable's modification date in the version hash to ensure ad-hoc and local recompilations correctly trigger the keychain primer UI, preventing unexpected system prompts on boot.
2026-06-10 12:27:48 +03:30
NimBold fd90055ccd fix(security): prevent eager keychain password fetch on startup
Defers fetching site login passwords until they are strictly needed (when a download starts or when viewing properties) to prevent the OS from showing the keychain prompt before the in-app primer UI.
2026-06-10 12:03:11 +03:30
NimBold 14924cf254 feat(settings): improve UI for Site Logins, Engine, and About panes
- Refactor Site Logins pane to use a data Table with search and a dedicated editor sheet
- Add 'Reveal in Finder' buttons for bundled engines in Engine pane
- Fix alignment of 'Bundled' labels and 'Browser Cookies' picker in Engine pane
- Remove build number from the version display in About pane
2026-06-10 06:21:35 +03:30
NimBold 85f55db66f feat(settings): add ask where to save option and overhaul Locations and Downloads UI 2026-06-10 05:43:13 +03:30
NimBold cc89f48221 feat: overhaul settings panes and add global configuration options
- Overhauled Downloads, Look & Feel, and Network settings panes to match modern UI guidelines.

- Implemented global User-Agent spoofing for all download engines to bypass restrictions.

- Added new configuration options: dock badge toggle, notification controls, completion sound, auto-retries, and "ask where to save" prompt.

- Integrated NSApp.dockTile.badgeLabel for tracking active downloads in the macOS Dock.

- Ensured newly added options correctly persist through AppSettings.
2026-06-10 05:26:45 +03:30
NimBold 22cc11f751 perf(media): optimize yt-dlp extraction speed and fix proxy propagation
- Add '--extractor-args youtube:player_client=ios,web' to bypass heavy JS challenge solving
- Add '--compat-options no-youtube-unavailable-videos' to skip blocked videos
- Fix missing proxy configuration in MediaExtractionEngine that caused timeouts
- Add '--live-from-start' for yt-dlp to handle live streams correctly
2026-06-10 03:12:42 +03:30
NimBold 88e99a8528 style(ui): update integration step counter visibility and add up-to-date status icon 2026-06-10 03:12:42 +03:30
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
nimbold 9af9edbfd4 chore(release): prepare 0.6.1 2026-06-08 15:08:16 +03:30
nimbold 9f8e01839f fix: package bundled media engines 2026-06-08 14:57:07 +03:30
nimbold c1d97f31fb ci: automate fetching yt-dlp and ffmpeg binaries during release build 2026-06-08 14:25:18 +03:30
nimbold 02abef1443 feat(engines): bundle yt-dlp and ffmpeg directly in the app bundle
- Move yt-dlp and ffmpeg binaries into Sources/Firelink and update Package.swift to copy them as bundle resources.
- Remove dynamic downloading logic for yt-dlp and ffmpeg from MediaEngineManager.swift and delete obsolete BinaryDownloader.swift and GatekeeperConfig.swift.
- Update EngineSettingsPane.swift to remove the auto-updater UI, verify icons, and spinners.
- Fix a bug where calling Add Downloads via the browser extension would open two duplicate windows by changing the window group to a single-instance Window.
- Adjust minHeight of AddDownloadsView to prevent the preview section from being cut out without scrolling.
2026-06-08 14:16:38 +03:30
nimbold b58df6b660 chore(release): prepare 0.6.0 release 2026-06-08 07:17:17 +03:30
nimbold 5ed9b42c62 fix: cap max height of download links text editor 2026-06-08 06:54:25 +03:30
nimbold 72ef99d211 refactor: add backward compatibility support for extension tokens 2026-06-08 05:46:24 +03:30
nimbold baddf0da6d feat: enhance mixed media support and add duplicate resolution 2026-06-08 05:37:27 +03:30
nimbold 34ca209e09 chore: update Firelink-Extension submodule to latest 2026-06-08 05:13:08 +03:30
nimbold 410059e3de feat(ui): redesign settings panes and enhance update flows
- Implemented `InlineUpdateUserDriver` to handle inline update checks inside Settings panes.
- Redesigned `AboutSettingsPane` with a modern inline update UI, release notes DisclosureGroup, and clear visual feedback states.
- Rebuilt `EngineSettingsPane` using native macOS layouts, removing custom stacks for better native alignment.
- Relocated browser cookie selection and media engine updates in `EngineSettingsPane` for better contextual grouping.
2026-06-08 02:18:56 +03:30
nimbold 4e48f1e42c fix: harden media download flow 2026-06-08 01:06:00 +03:30
nimbold 2e5b4ae7c3 fix: pass extractor arguments to yt-dlp download process
- Fix: Append --extractor-args youtube:player_client=ios,tv to the MediaDownloadEngine to prevent yt-dlp from falling back to web client and failing with bot detection during download.
2026-06-08 00:38:32 +03:30
nimbold 2d0c05e4e4 fix: restore single click selection by removing simultaneousGesture 2026-06-08 00:24:12 +03:30
nimbold 8d5eb74fba fix: restore Download Properties routing and gestures
- Fix: Revert WindowGroup to expect UUID and update ALL openWindow calls to explicitly use id: 'download-properties' to prevent routing poisoning
- Fix: Wrap the File Name cell in doubleClickableCell using .simultaneousGesture so double clicks trigger the action without eating single click row selection
2026-06-08 00:17:32 +03:30
nimbold 01ba9c4c9b fix: pass UUID as String for download properties WindowGroup to prevent routing failures 2026-06-08 00:11:23 +03:30
nimbold bd6aa867c7 fix: size column fallback and table row interactions
- Fix: Size column falls back to 'bytesText' if sizeBytes is unknown
- Fix: Prevent filename text from intercepting row selection taps
- Fix: Use explicit window ID to ensure download properties opens reliably on double click
2026-06-07 23:59:03 +03:30
nimbold fbae92aae9 fix: media download UX and table row selection
- Fix: Remove aria2c from yt-dlp to allow native concurrent downloader to print progress
- Fix: Use generic 'Fetching media data...' message instead of hardcoding YouTube
- Fix: Remove onTapGesture from Table cells to restore native macOS SwiftUI row selection and use simultaneousGesture on the Table itself for double clicks
2026-06-07 23:39:18 +03:30
nimbold 49003665ef feat: improve yt-dlp fetching speed and redesign media detection UI
- Bypass YouTube web bot detection by using iOS/Android player clients in yt-dlp
- Redesign media extraction UX to use a clean inline layout instead of replacing the entire Add window
- Use dropdown Pickers for Type, Quality, and Format selection
- Remove the obsolete MediaInspectorCard
2026-06-07 22:53:38 +03:30
nimbold 1a2c59d243 fix: media downloads connections, progress parsing, file size, and selection highlight
- Removed hardcoded 1 connection limit for media downloads
- Added support for aria2c progress parsing in media downloads
- Fixed missing file size after download completion by reading from disk
- Fixed row selection highlight in DownloadTable by using onTapGesture instead of simultaneousGesture
2026-06-07 22:28:23 +03:30
nimbold c139ac50f2 fix(media): stabilize yt-dlp metadata and add-on updates 2026-06-07 12:48:00 +03:30
nimbold a900d97a5c feat: enhance media engine settings with cookie extraction and update checks
- Added `mediaCookieSource` to `AppSettings` to let users choose the browser for cookie extraction.
- Refactored `EngineSettingsPane` to display `yt-dlp` and `ffmpeg` statuses.
- Added a manual 'Check for Updates' button with UI loading feedback.
- Passed `--cookies-from-browser` to both metadata extraction and download engines if configured.
- Added `--ignore-no-formats-error` to `yt-dlp` metadata extraction to prevent crashes on restricted videos.
2026-06-07 12:15:50 +03:30
nimbold 8831318839 feat: modernize Integration settings UI and add official install button 2026-06-07 10:43:10 +03:30
nimbold 94180ddff9 docs: add Install on Firefox badge to README 2026-06-07 10:36:09 +03:30
nimbold e91414c478 docs: update README with new media features, credits, and live browser integration 2026-06-07 10:32:23 +03:30
nimbold eaea5bd908 feat: integrate yt-dlp to DownloadController and add global queue support 2026-06-07 10:23:55 +03:30
nimbold af05180ee6 feat: implement smart progressive disclosure UI and media extraction engine 2026-06-07 10:05:54 +03:30
nimbold 0799b08000 feat: implement gatekeeper architecture for on-demand media engine binaries 2026-06-07 09:57:27 +03:30
nimbold bf645898a9 chore(assets): update app icons and icon generation scripts 2026-06-07 09:08:52 +03:30
nimbold 6552bde261 fix(security): block automatic metadata fetch for private IP addresses 2026-06-07 09:08:11 +03:30
nimbold 125a8b9e6d fix: actually update extension icons with the 1.9x gradient icon (submodule bump) 2026-06-07 08:37:45 +03:30
nimbold 2589d878ea style: tone down icon gradient to 1.9x for modern subtle look 2026-06-07 08:11:26 +03:30
nimbold 2f7a1f52e7 style: increase gradient contrast for stronger lighting effect 2026-06-07 08:06:43 +03:30
nimbold 30d4d741af style: switch to lighter gradient (+1 to 0) 2026-06-07 08:01:04 +03:30
nimbold f98e6b8246 style: revert to plain mode without gradient 2026-06-06 16:13:38 +03:30
nimbold 387ada1afc fix: correctly remove black padding and mask corners 2026-06-06 16:09:27 +03:30
nimbold 37be7b14f0 style: apply premium gradient to the correct new icon 2026-06-06 16:04:26 +03:30
nimbold 1e064502db style: apply premium gradient to app icon 2026-06-06 16:00:38 +03:30
nimbold 09cea67041 fix(updates): harden Sparkle release metadata 2026-06-06 15:54:44 +03:30
nimbold 6a20250d22 style: remove redundant version string from up-to-date message 2026-06-06 15:31:05 +03:30
nimbold 99b8f66bcf fix: correct Sparkle SUNoUpdateError code to prevent false error messages 2026-06-06 15:28:53 +03:30
nimbold 890b6935b9 feat: inline Sparkle update checks to avoid unnecessary modals 2026-06-06 15:22:58 +03:30
nimbold b1802c718a chore: update appcast.xml with valid signature for new framework-embedded dmg 2026-06-06 15:12:48 +03:30
nimbold d0021e16f5 fix: embed Sparkle dynamic frameworks into app bundle to resolve crash 2026-06-06 15:10:37 +03:30
nimbold a42c7d9f74 chore: update appcast.xml for v0.5.7 Sparkle release 2026-06-06 15:07:29 +03:30
nimbold a375b2e685 feat: integrate Sparkle 2 for secure in-app updates 2026-06-06 15:01:40 +03:30
nimbold 301e337c2e fix: apply proper rounded corner mask to icon 2026-06-06 14:16:53 +03:30
nimbold 986687631d style: remove black corners from icon 2026-06-06 14:14:28 +03:30
nimbold 0714f6e707 style: update app icon 2026-06-06 14:11:21 +03:30
nimbold e25022fbe5 feat(branding): update app icon icns 2026-06-06 08:23:08 +03:30
nimbold 48dfd7c6fa feat(branding): update high-resolution app icon png 2026-06-06 08:21:55 +03:30
nimbold 5e442d1691 feat(branding): update monochrome menu bar icon 2026-06-06 08:21:26 +03:30
nimbold d1fa6dd910 feat(branding): update extension submodule reference 2026-06-06 08:21:08 +03:30
nimbold 27b9004d99 fix: repair app icons 2026-06-06 07:19:14 +03:30
nimbold ac8764bb78 fix: clear completed speeds and icon frame 2026-06-05 20:48:26 +03:30
nimbold 41535eca59 feat: update app icon branding 2026-06-05 20:38:55 +03:30
nimbold 2fb851b138 fix: correct download list row selection and remove build number from version string 2026-06-05 19:46:12 +03:30
nimbold 47eb226160 ci: fix github release title using commit subject instead of tag 2026-06-05 14:05:43 +03:30
nimbold a2135bb539 feat: redesign About pane and resolve download properties UI bugs
- Added transparent GitHub icon to About pane.

- Compacted About pane layout and consolidated footer.

- Fixed build script to correctly copy bundled image assets.

- Resolved progress bar state issues in Download Properties.

- Fixed text overlap bugs in Settings.
2026-06-05 13:56:02 +03:30
nimbold c18aa6fdaf fix: complete aria2 downloads via rpc 2026-06-05 12:38:14 +03:30
nimbold 4a9d4cab2f fix(settings): align preferences with runtime behavior 2026-06-05 11:41:56 +03:30
nimbold 2b820ec802 feat: modernize settings and add SOCKS5 support
- Split monolithic SettingsView.swift into modular Settings panes
- Add SOCKS5 proxy support to proxy configuration
- Fix ListRowDensity application in DownloadTable
- Improve discoverability of global speed limit by adding it to Downloads pane
2026-06-05 03:02:29 +03:30
nimbold 544db2aab6 docs: add 0.5.4 release notes 2026-06-05 03:02:29 +03:30
Nima c3bc35f669 Merge pull request #2 from nimbold/codex/fix-download-workflows
fix: harden download workflows
2026-06-04 15:45:23 +03:30
nimbold 0cbb982f5b fix: harden download workflows 2026-06-04 15:41:38 +03:30
nimbold 0040c23050 style: refine UI layouts, toolbars, and chunk map aesthetics
- Make global and contextual Start/Stop toolbar buttons mutually exclusive in main window
- Relocate Add Downloads primary action buttons from top toolbar to standard bottom Action Bar
- Expand Download Properties window width for better readability and condense font sizes
- Wrap Advanced Transfer settings in a native DisclosureGroup to reduce scrolling
- Redesign Chunk Map with rounded corners, larger boxes, and system accent colors
2026-06-04 14:29:13 +03:30
64 changed files with 7298 additions and 1771 deletions
+36 -10
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
@@ -46,20 +49,36 @@ jobs:
xcode-select -p
xcrun --sdk macosx --show-sdk-version
- name: Verify macOS 26 SDK
- name: Verify macOS 26+ SDK
shell: bash
run: |
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
SDK_MAJOR="${SDK_VERSION%%.*}"
if [[ "$SDK_MAJOR" != "26" ]]; then
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
if [[ "$SDK_MAJOR" -lt 26 ]]; then
echo "Expected at least macOS 26 SDK, got macOS $SDK_VERSION" >&2
exit 1
fi
- name: Install dependencies
run: brew install aria2 dylibbundler
- name: Fetch media engines
run: |
mkdir -p Sources/Firelink
# Download the one-file macOS build. PyInstaller on macOS now properly supports code-signing without corrupting the archive.
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
chmod +x Sources/Firelink/yt-dlp
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
curl -fsSL "https://ffmpeg.martin-riedl.de/redirect/latest/macos/arm64/release/ffmpeg.zip" -o ffmpeg.zip
unzip -q -o ffmpeg.zip -d Sources/Firelink
rm ffmpeg.zip
chmod +x Sources/Firelink/ffmpeg
test -x Sources/Firelink/yt-dlp
test -x Sources/Firelink/ffmpeg
- name: Build app bundle
env:
MARKETING_VERSION: ${{ steps.version.outputs.version }}
@@ -70,7 +89,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:
@@ -86,15 +105,22 @@ jobs:
if-no-files-found: error
- name: Publish GitHub release
if: github.ref_type == 'tag'
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
VERSION="${{ steps.version.outputs.version }}"
TAG_NAME="${{ steps.version.outputs.tag_name }}"
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
test -s release_notes.md
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
gh release upload "$TAG_NAME" dist/*.dmg --clobber
gh release edit "$TAG_NAME" --notes-file release_notes.md
else
gh release create "$GITHUB_REF_NAME" dist/*.dmg --notes-file release_notes.md --verify-tag
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md
else
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
fi
fi
+22
View File
@@ -2,7 +2,29 @@
.build/
build/
dist/
*.dmg
DerivedData/
.vscode/
*.xcuserdata/
*.xcuserstate
# Local secrets and exported signing keys
.env
.env.*
*.key
*.pem
*.p8
*.p12
*.cer
*.der
*.mobileprovision
sparkle_private_key*
SparklePrivateKey*
private-key*
private_key*
yt-dlp
ffmpeg
Sources/Firelink/_internal/
!Sources/Firelink/_internal/
Sources/Firelink/_internal/*
!Sources/Firelink/_internal/.gitkeep
+214
View File
@@ -5,6 +5,220 @@ 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.7.1] - 2026-06-11
### Fixes
- Increased the `yt-dlp` metadata extraction timeout to 120 seconds to properly handle YouTube's new JavaScript Proof-of-Work bot protection challenges.
- Improved the `AddDownloadsView` UI to display the exact underlying error message during extraction failures rather than a generic masked string.
### Security Fixes
- Addressed multiple vulnerabilities identified in the v0.7.0 security audit.
- Moved `yt-dlp` credential passing from CLI arguments to secure temporary configuration files to prevent process list leakage.
- Enforced strict `0o600` POSIX permissions on `aria2c` temporary configuration files to protect generated RPC secrets.
- Replaced the unauthenticated local connection protocol with a secure HMAC-SHA256 signature validation.
- Excluded sensitive properties like `rpcSecret` and `rpcPort` from `DownloadItem` serialization so they are never saved to disk in plaintext.
- Mitigated SSRF (Server-Side Request Forgery) by strictly validating metadata fetch requests against private IP addresses and loopback ranges.
- Prevented potential path traversal vulnerabilities by validating destination file URLs during duplicate resolution.
- Sanitized custom HTTP headers to prevent CR/LF injection vectors.
- Re-architected `aria2c` port-finding with POSIX sockets to eliminate a known race-condition window.
- Applied rate-limiting and text length bounds to the custom `firelink://` scheme to mitigate DoS and injection attempts.
### Fixes
- Fixed a metadata extraction timeout when downloading from YouTube by preventing child processes from holding process pipes open.
- Resolved an issue to correctly assign filenames for auto-captured downloads.
- Restored the UUID fallback for token generation to prevent silent failures if secure random byte generation fails.
- Hardened local API security by immediately rejecting requests if the expected pairing token is completely empty.
- Implemented a thread-safe cleanup mechanism for temporary directories to resolve a concurrency race condition during engine cancellation.
## [0.7.0] - 2026-06-11
### New Features & Improvements
- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
- Introduce an "Ask where to save" global configuration option for manual location picking per download.
- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
### Fixes
- Fix issues regarding proxy environment propagation into media download processes.
- Resolve multiple critical bugs related to configuration storage and download stability.
- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
## [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
- No new user-facing features in this patch release.
### Improvements
- Package bundled `yt-dlp` and `ffmpeg` executables into the macOS app bundle so media extraction works in release builds.
- Resolve bundled media engines from both app resources and SwiftPM resources to support packaged apps and local development builds.
### Changes
- Fetch release-time media engine binaries in GitHub Actions instead of storing large binaries in git.
- Use the changelog entry for GitHub release page descriptions so published release notes match the source tree.
- Remove stale media add-on update language now that media engines are bundled with the app.
- Update Firelink Companion to `1.0.8`.
### Fixes
- Replace the stale pinned FFmpeg download URL with Martin Riedl's latest macOS ARM64 release redirect.
- Fail release builds early when `yt-dlp` or `ffmpeg` cannot be fetched or made executable.
- Remove unused media inspector and media download entry-point code left behind by the removed engine update flow.
- Prevent Firelink Companion global capture from canceling browser downloads unless the native app confirms the local API handoff.
## [0.6.0] - 2026-06-08
### New features
- Enhance mixed media support and add duplicate resolution.
- Redesign settings panes and enhance update flows.
- Improve yt-dlp fetching speed and redesign media detection UI.
- Enhance media engine settings with cookie extraction and update checks.
- Modernize Integration settings UI and add official install button.
- 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 update checks to avoid unnecessary modals.
### Changes
- Add backward compatibility support for extension tokens.
- Update Firelink-Extension submodule to latest.
- Update app icons and icon generation scripts.
- Tone down icon gradient to 1.9x for modern subtle look.
- Increase gradient contrast for stronger lighting effect.
- Switch to lighter gradient (+1 to 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 release metadata for the framework-embedded dmg.
### Fixes
- Cap max height of download links text editor.
- Harden media download flow.
- Pass extractor arguments to yt-dlp download process.
- Restore single click selection by removing simultaneousGesture.
- Restore Download Properties routing and gestures.
- Pass UUID as String for download properties WindowGroup to prevent routing failures.
- Size column fallback and table row interactions.
- Media download UX and table row selection.
- Media downloads connections, progress parsing, file size, and selection highlight.
- Stabilize yt-dlp metadata and add-on updates.
- 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 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 an integrated release-checking flow.
- Added secure update metadata checks before presenting new releases in the app.
## [0.5.6] - 2026-06-05
### New features
- Added the official transparent GitHub icon to the Source Code link in the About page.
### Changes
- Compacted the About settings pane to reduce vertical padding, placing the app identity and updates prominently at the top.
- Consolidated developer, credits, and legal links into a single unified footer section in the About pane.
### Fixes
- Fixed a build script bug that prevented bundled images (like the GitHub icon) from being copied into the final app bundle.
## [0.5.5] - 2026-06-05
### New features
- Added a compact Download Properties inspector with a persistent progress summary and redownload-aware transfer settings.
- Added authenticated metadata probing so batch previews can use custom or saved credentials.
### Changes
- Updated Download Properties disclosure sections so their full title row opens and closes them.
- Compacted Add Downloads with a smaller summary strip, queue picker, and clearer per-file speed limit wording.
- Expanded download table hit areas so double-clicks register across empty cell space.
### Fixes
- Fixed active downloads that could remain stuck at 99% until manually stopped by detecting `aria2` completion through RPC.
- Fixed Chunk Map layout overlap in Download Properties.
- Fixed Download Properties controls that implied completed or active file identity edits would apply immediately.
## [0.5.4] - 2026-06-04
### New features
- Added direct double-click access to Download Properties for unfinished downloads.
- Added a `make verify` command for local build and Firefox extension manifest checks.
### Changes
- Updated Firefox integration to probe the same local fallback ports used by the app.
- Updated global speed limiting so changes apply to active downloads through `aria2` RPC.
- Declared SwiftPM resources and added development fallbacks for app icons and Firefox extension copying.
- Narrowed saved site-login matching so plain host patterns match exact hosts unless a wildcard is used.
### Fixes
- Fixed browser handoff failures when the default local extension port is unavailable.
- Fixed dropped `Referer` headers from browser extension requests.
- Fixed scheduler configurations that could be enabled without any runnable queue target.
- Fixed unsafe file names from URLs, metadata responses, and manual property edits.
- Fixed a possible duplicate-open glitch when double-clicking unfinished downloads.
## [0.5.3] - 2026-06-04
### New features
+8 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build app dmg run clean
.PHONY: build app dmg release run verify clean
build:
swift build -c release
@@ -9,9 +9,16 @@ app:
dmg: app
Scripts/create_dmg.sh
release:
Scripts/create_app_bundle.sh
Scripts/create_dmg.sh
run:
swift run Firelink
verify:
Scripts/verify.sh
clean:
swift package clean
rm -rf build dist
+11 -1
View File
@@ -10,10 +10,20 @@ let package = Package(
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [],
targets: [
.executableTarget(
name: "Firelink",
path: "Sources/Firelink"
dependencies: [],
path: "Sources/Firelink",
exclude: [
"_internal"
],
resources: [
.process("Assets.xcassets"),
.copy("yt-dlp"),
.copy("ffmpeg")
]
)
]
)
+36 -41
View File
@@ -1,56 +1,65 @@
<div align="center">
<img src="Resources/AppIcon.png" alt="Firelink Icon" width="128" height="128" />
<h1>Firelink</h1>
<p><strong>A clean, native SwiftUI download manager for Apple Silicon macOS</strong></p>
<p><strong>The modern, blazing-fast download manager built natively for Apple Silicon macOS.</strong></p>
<a href="https://swift.org"><img src="https://img.shields.io/badge/Swift-6.0-orange?logo=swift&logoColor=white" alt="Swift Version" /></a>
<a href="https://apple.com"><img src="https://img.shields.io/badge/macOS-14.0%2B-blue?logo=apple&logoColor=white" alt="Platform Support" /></a>
<a href="https://aria2.github.io/"><img src="https://img.shields.io/badge/Engine-aria2c-red?logo=terminal&logoColor=white" alt="Engine" /></a>
<a href="https://github.com/yt-dlp/yt-dlp"><img src="https://img.shields.io/badge/Engine-yt--dlp-red?logo=youtube&logoColor=white" alt="yt-dlp Engine" /></a>
<a href="https://ffmpeg.org/"><img src="https://img.shields.io/badge/Engine-ffmpeg-red?logo=ffmpeg&logoColor=white" alt="ffmpeg Engine" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License" /></a>
</div>
---
**Firelink** brings the efficiency of multi-segmented download managers (like IDM or FDM) to macOS with a modern, native SwiftUI interface. Designed specifically for Apple Silicon, it delivers high-speed concurrent transfers, drag-and-drop queue control, automated file organization, and Keychain-secured authentication—all in a lightweight native package.
**Firelink** reimagines macOS file downloading by wrapping legendary open-source engines (`aria2c`, `yt-dlp`, and `ffmpeg`) in a beautifully crafted, highly polished SwiftUI interface. Enjoy high-speed segmented downloads, native media extraction, seamless browser integration, and intelligent file organization without compromising on the aesthetics of your Mac.
---
### 📸 Screenshots
Dark mode is shown by default with privacy-safe example downloads. Light mode is tucked away below so the README stays easy to scan.
## 📸 Screenshots
<div align="center">
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window in dark theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Firefox integration settings in dark theme" width="32%" />
<img src="Resources/Screenshots/Dark/MainPage.png" alt="Firelink main window" width="32%" />
<img src="Resources/Screenshots/Dark/AddWindow.png" alt="Add downloads window" width="32%" />
<img src="Resources/Screenshots/Dark/Settings.png" alt="Settings" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup</sub>
<sub>A premium native experience, from batch linking to advanced settings.</sub>
</div>
<details>
<summary><b>☀️ View Light Theme Screenshots</b></summary>
<summary><b>Light Theme Screenshots</b></summary>
<br/>
<div align="center">
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window in light theme with sample downloads" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window in light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Firefox integration settings in light theme" width="32%" />
<br/>
<sub>Main window, batch link intake, and Firefox integration setup in light theme</sub>
<img src="Resources/Screenshots/Light/MainPage.png" alt="Firelink main window light theme" width="32%" />
<img src="Resources/Screenshots/Light/AddWindow.png" alt="Add downloads window light theme" width="32%" />
<img src="Resources/Screenshots/Light/Settings.png" alt="Settings light theme" width="32%" />
</div>
</details>
---
## ✨ Features
-**High-Speed Downloads:** Multi-segmented engine powered by `aria2c`.
- 🎨 **Native SwiftUI:** Responsive Apple Silicon native UI.
- 🎯 **Chunk Map Inspector:** Visually monitor active segment connections in real time.
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱 **Drag-and-Drop:** Seamlessly import URLs or text files directly into the app or Dock icon.
- 🛡 **Reliability:** Automatic download recovery and retry handling.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, Speed Limiter, and system sleep prevention during active downloads.
-**Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits and a built-in download scheduler.
- 🪄 **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 strictly adhering to Apple Human Interface Guidelines, 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 and secure HMAC-SHA256 authenticated local API endpoints ensure your system remains strictly isolated and protected from malicious scripts.
- 🗂 **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.
---
## 🧩 Browser Integration
We are live! Send downloads directly from your browser to the Firelink app with zero friction.
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20on%20Firefox-FF7139?logo=firefox-browser&logoColor=white&style=for-the-badge" alt="Install on Firefox" /></a>
*(Check out the [Firelink-Extension source code](https://github.com/nimbold/Firelink-Extension) to contribute or learn more.)*
---
@@ -70,26 +79,12 @@ make app && open build/Firelink.app
---
## 🧩 Browser Extension
Find the companion browser extension (Firefox) at:
👉 **[nimbold/Firelink-Extension](https://github.com/nimbold/Firelink-Extension)**
---
## 🗺️ Roadmap
- [x] Zero-Config `aria2c` bundling.
- [x] Global & per-download Speed Limiter.
- [x] Browser Extensions support.
- [x] In-app integrated Settings UI.
- [ ] Notarized `.app` releases and Homebrew formulae.
---
## 🏆 Credits
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
Firelink stands on the shoulders of giants. A massive thank you to the contributors of these phenomenal open-source projects:
- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
---
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

+112 -5
View File
@@ -4,8 +4,11 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
CONFIGURATION="${CONFIGURATION:-release}"
MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}"
BUILD_NUMBER="${BUILD_NUMBER:-1}"
DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
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"
@@ -13,6 +16,38 @@ 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 1
fi
lipo -archs "$path" >/dev/null 2>&1
}
ensure_ytdlp() {
local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp"
if [[ -x "$ytdlp_path" ]] && is_valid_mach_o "$ytdlp_path"; then
return
fi
if ! command -v curl >/dev/null; then
echo "WARNING: yt-dlp is missing, and curl is not available to refresh it." >&2
return
fi
echo "Refreshing bundled yt-dlp runtime..."
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o "$ytdlp_path"
chmod +x "$ytdlp_path"
# Remove legacy _internal directory if it exists
rm -rf "$ROOT_DIR/Sources/Firelink/_internal"
}
ensure_ytdlp
swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
@@ -20,6 +55,21 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
cp "$ROOT_DIR/Resources/GitHubTemplate.png" "$RESOURCES_DIR/GitHubTemplate.png"
for media_engine in yt-dlp ffmpeg; do
media_engine_path="$ROOT_DIR/Sources/Firelink/$media_engine"
if [[ -x "$media_engine_path" ]]; then
cp "$media_engine_path" "$RESOURCES_DIR/$media_engine"
chmod +x "$RESOURCES_DIR/$media_engine"
else
echo "WARNING: $media_engine not found or not executable at $media_engine_path"
fi
done
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"
@@ -34,18 +84,25 @@ ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..."
brew install dylibbundler
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
else
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -77,12 +134,62 @@ 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>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>local.firelink.swiftui</string>
<key>CFBundleURLSchemes</key>
<array>
<string>firelink</string>
</array>
</dict>
</array>
</dict>
</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"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
swift build
git diff --check
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
+502 -201
View File
@@ -26,37 +26,86 @@ struct AddDownloadsView: View {
@State private var authPassword = ""
@State private var saveLogin = false
@State private var conflictingDownloads: [DuplicateDownloadItem] = []
@State private var showingDuplicates = false
@State private var pendingStartFlag = false
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))
}
}
.toolbar {
Divider()
actionBar
.padding(16)
.background(.background)
}
.frame(minWidth: 720, idealWidth: 800, minHeight: 620, idealHeight: 680)
.sheet(isPresented: $showingDuplicates) {
DuplicateResolutionView(
conflicts: $conflictingDownloads,
onConfirm: {
showingDuplicates = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
}
},
onCancel: {
showingDuplicates = false
}
)
}
.frame(minWidth: 640, idealWidth: 680, minHeight: 500, idealHeight: 540)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
.onChange(of: metadataRequestSignature) { _, _ in
guard !DownloadURLParser.parse(linkText).isEmpty else { return }
scheduleMetadataRefresh(for: linkText)
}
.onAppear {
connectionsPerServer = Double(settings.perServerConnections)
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
controller.pendingAddQueueID = nil
if let text = controller.pendingPasteboardText {
applyPendingReferer()
linkText = text
controller.pendingPasteboardText = nil
}
}
.onDisappear {
metadataTask?.cancel()
linkText = ""
pendingDownloads = []
headerText = ""
cookieText = ""
mirrorText = ""
useAuthorization = false
authUsername = ""
authPassword = ""
checksumEnabled = false
checksumValue = ""
}
}
@@ -70,14 +119,14 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 82)
.frame(minHeight: 72, idealHeight: 100, maxHeight: 160)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
.foregroundStyle(.secondary)
Spacer()
Button {
refreshMetadata(for: linkText)
refreshMetadata(for: linkText, isAutoFetch: false)
} label: {
Label("Refresh Metadata", systemImage: "arrow.clockwise")
}
@@ -88,95 +137,82 @@ 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("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.subheadline.weight(.semibold))
Section("Queue") {
Picker("Target Queue", selection: $targetQueueID) {
ForEach(controller.queues) { queue in
Text(queue.name).tag(queue.id)
}
}
.labelsHidden()
}
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", systemImage: "speedometer")
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("Limit this batch", 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)
}
}
}
}
private var summarySection: some View {
HStack(spacing: 8) {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
CompactSummaryStrip(
metrics: [
SummaryMetric(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc"),
SummaryMetric(title: "Required", value: requiredSpaceText, symbolName: "externaldrive"),
SummaryMetric(title: "Free", value: freeSpaceText, symbolName: "internaldrive"),
SummaryMetric(title: "Unknown", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
]
)
}
private var previewSection: some View {
@@ -184,8 +220,8 @@ struct AddDownloadsView: View {
Label("Preview", systemImage: "list.bullet.rectangle")
.font(.subheadline.weight(.semibold))
Table(pendingDownloads) {
TableColumn("File") { item in
Table($pendingDownloads) {
TableColumn("File") { $item in
HStack {
Image(systemName: item.category.symbolName)
.foregroundStyle(categoryColor(item.category))
@@ -196,130 +232,240 @@ struct AddDownloadsView: View {
}
}
TableColumn("Size") { item in
TableColumn("Size") { $item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
}
.width(86)
TableColumn("Save To") { item in
Text(destinationDirectory(for: item).path)
TableColumn("Save To") { $item in
Text(item.destinationPath)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
TableColumn("Status") { item in
MetadataStatusView(state: item.state)
TableColumn("Status") { $item in
if item.isMedia, case .loading = item.state {
HStack {
ProgressView().controlSize(.small)
Text("Checking")
}.foregroundStyle(.secondary)
} else {
MetadataStatusView(state: item.state)
}
}
.width(110)
.width(min: 110, ideal: 140, max: 200)
}
.frame(minHeight: 135)
.frame(minHeight: 160)
}
}
@ToolbarContentBuilder
private var actionBar: some ToolbarContent {
ToolbarItem(placement: .status) {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
metadataTask = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}
private var actionBar: some View {
HStack {
Text(actionMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if metadataTask != nil {
Button {
metadataTask?.cancel()
metadataTask = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Spacer()
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
ToolbarItemGroup(placement: .primaryAction) {
Button {
Button("Add to Queue") {
addDownloads(start: false)
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(!canAddDownloads)
Button {
Button("Start Downloads") {
addDownloads(start: true)
} label: {
Label("Start Downloads", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(!canAddDownloads)
.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 errorMsg) = firstMedia.state {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
Text("Failed to load options.")
.foregroundStyle(.secondary)
}
Text(errorMsg)
.font(.caption)
.foregroundStyle(.red)
.lineLimit(3)
}
} 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)
.onChange(of: headerText) { _, newValue in
if newValue.count > 65536 { headerText = String(newValue.prefix(65536)) }
}
}
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))
.onChange(of: cookieText) { _, newValue in
if newValue.count > 65536 { cookieText = String(newValue.prefix(65536)) }
}
}
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)
.onChange(of: mirrorText) { _, newValue in
if newValue.count > 65536 { mirrorText = String(newValue.prefix(65536)) }
}
}
}
.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))
}
}
@@ -383,22 +529,61 @@ struct AddDownloadsView: View {
)
}
private var metadataRequestSignature: String {
[
headerText,
cookieText,
useAuthorization ? "auth" : "no-auth",
authUsername,
authPassword
].joined(separator: "\u{1f}")
}
private func metadataCredentials(for url: URL) -> DownloadCredentials? {
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanUsername.isEmpty else { return nil }
return DownloadCredentials(username: cleanUsername, password: authPassword)
}
return settings.credentials(for: url)
}
private func scheduleMetadataRefresh(for text: String) {
metadataTask?.cancel()
metadataTask = Task {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await MainActor.run {
refreshMetadata(for: text)
refreshMetadata(for: text, isAutoFetch: true)
}
}
}
private func refreshMetadata(for text: String) {
private func applyPendingReferer() {
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
!referer.isEmpty,
URL(string: referer) != nil else {
controller.pendingReferer = nil
return
}
let refererHeader = "Referer: \(referer)"
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? refererHeader
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
}
controller.pendingReferer = nil
}
private func refreshMetadata(for text: String, isAutoFetch: Bool) {
let urls = DownloadURLParser.parse(text)
metadataTask?.cancel()
pendingDownloads = urls.map { url in
let isMedia = MediaDetector.isSupportedMedia(url: url)
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
return PendingDownload(
@@ -406,7 +591,8 @@ struct AddDownloadsView: View {
fileName: fileName,
category: category,
defaultDirectory: settings.destinationDirectory(for: category),
state: .loading
state: .loading,
isMedia: isMedia
)
}
@@ -417,14 +603,54 @@ struct AddDownloadsView: View {
saveLogin = false
}
guard !urls.isEmpty else {
metadataTask = nil
return
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
await withTaskGroup(of: PendingDownload.self) { group in
for item in pendingDownloads {
group.addTask {
if item.isMedia {
var fetchedItem = item
do {
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
let (metadata, options) = try await MediaExtractionEngine.fetchMetadata(
for: item.url,
cookieSource: settings.mediaCookieSource,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions,
proxyConfiguration: settings.downloadProxyConfiguration
)
fetchedItem.mediaMetadata = metadata
fetchedItem.mediaOptions = options
if let bestVideo = options.first(where: { !$0.isAudioOnly && $0.name.contains("Best") }) ?? options.first(where: { !$0.isAudioOnly }) ?? options.first {
fetchedItem.selectedMediaOption = bestVideo
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
fetchedItem.fileName = "\(cleanTitle).\(bestVideo.outputExtension)"
fetchedItem.category = FileClassifier.category(forFileName: fetchedItem.fileName)
}
fetchedItem.state = .loaded
} catch {
fetchedItem.state = .failed(error.localizedDescription)
}
return fetchedItem
} else {
return await DownloadMetadataFetcher.fetch(
for: item.url,
settings: settings,
credentials: metadataCredentials(for: item.url),
transferOptions: transferOptions,
isAutoFetch: isAutoFetch
)
}
}
}
for await loadedItem in group {
guard !Task.isCancelled else { break }
await MainActor.run {
if let index = pendingDownloads.firstIndex(where: { $0.url == loadedItem.url }) {
pendingDownloads[index] = loadedItem
}
@@ -455,25 +681,70 @@ struct AddDownloadsView: View {
}
private func addDownloads(start: Bool) {
var explicitCredentials: DownloadCredentials? = nil
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
var conflicts: [DuplicateDownloadItem] = []
for pending in pendingDownloads {
let destURL = overrideDirectory ?? pending.defaultDirectory
let destPath = destURL.appendingPathComponent(pending.fileName).path
if controller.downloads.contains(where: { $0.url == pending.url && $0.status != .canceled && $0.status != .completed }) {
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingURL("URL already in queue")))
} else if controller.downloads.contains(where: { $0.destinationPath == destPath && $0.status != .canceled }) || FileManager.default.fileExists(atPath: destPath) {
conflicts.append(DuplicateDownloadItem(id: pending.id, pendingItem: pending, reason: .existingFile("File exists at destination")))
}
}
if !conflicts.isEmpty {
conflictingDownloads = conflicts
pendingStartFlag = start
showingDuplicates = true
return
}
executeAddDownloads(start: start)
}
private func executeAddDownloads(start: Bool, conflicts: [DuplicateDownloadItem]? = nil) {
var finalDownloads = pendingDownloads
if let conflicts {
for conflict in conflicts {
guard let index = finalDownloads.firstIndex(where: { $0.id == conflict.id }) else { continue }
switch conflict.resolution {
case .skip:
finalDownloads.remove(at: index)
case .rename:
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
var newName = finalDownloads[index].fileName
var count = 1
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
let ext = URL(fileURLWithPath: newName).pathExtension
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) || finalDownloads.prefix(upTo: index).contains(where: { (overrideDirectory ?? $0.defaultDirectory) == destURL && $0.fileName == newName }) {
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
count += 1
}
finalDownloads[index].fileName = newName
case .replace:
let destURL = overrideDirectory ?? finalDownloads[index].defaultDirectory
let path = destURL.appendingPathComponent(finalDownloads[index].fileName).path
guard URL(fileURLWithPath: path).standardized.path.hasPrefix(destURL.standardized.path) else { continue }
if let existingIndex = controller.downloads.firstIndex(where: { ($0.destinationPath == path || $0.url == finalDownloads[index].url) && $0.status != .canceled }) {
controller.delete(controller.downloads[existingIndex], deleteFiles: true)
} else if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
}
}
}
}
guard !finalDownloads.isEmpty else {
dismiss()
return
}
let explicitCredentials = explicitCredentials(for: finalDownloads.map(\.url))
controller.addPendingDownloads(
pendingDownloads,
finalDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
@@ -485,6 +756,25 @@ struct AddDownloadsView: View {
dismiss()
}
private func explicitCredentials(for urls: [URL]) -> DownloadCredentials? {
guard useAuthorization else { return nil }
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleanUsername.isEmpty else { return nil }
if saveLogin {
var savedHosts = Set<String>()
for url in urls {
if let host = url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
return DownloadCredentials(username: cleanUsername, password: authPassword)
}
private var overrideDirectory: URL? {
guard overrideDestination else { return nil }
let trimmed = destinationPath.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -541,30 +831,41 @@ struct AddDownloadsView: View {
}
}
private struct SummaryTile: View {
private struct SummaryMetric: Identifiable {
let title: String
let value: String
let symbolName: String
var id: String { title }
}
private struct CompactSummaryStrip: View {
let metrics: [SummaryMetric]
var body: some View {
HStack(spacing: 10) {
Image(systemName: symbolName)
.font(.body)
.foregroundStyle(.secondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold).monospacedDigit())
.lineLimit(1)
HStack(spacing: 12) {
ForEach(metrics) { metric in
HStack(spacing: 6) {
Image(systemName: metric.symbolName)
.font(.caption)
.foregroundStyle(.secondary)
Text(metric.title)
.font(.caption)
.foregroundStyle(.secondary)
Text(metric.value)
.font(.caption.weight(.semibold).monospacedDigit())
.lineLimit(1)
}
if metric.id != metrics.last?.id {
Divider()
.frame(height: 14)
}
}
Spacer(minLength: 0)
}
.padding(9)
.frame(maxWidth: .infinity)
.frame(minHeight: 52)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
+246 -11
View File
@@ -20,21 +20,66 @@ enum ProxyMode: String, Codable, CaseIterable, Sendable {
}
}
enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
case none = "None"
case safari = "Safari"
case chrome = "Chrome"
case firefox = "Firefox"
case edge = "Edge"
case brave = "Brave"
var ytDlpBrowserName: String? {
switch self {
case .none:
nil
case .safari, .chrome, .firefox, .edge, .brave:
rawValue.lowercased()
}
}
var statusTitle: String {
switch self {
case .none:
"Not using browser cookies"
case .safari, .chrome, .firefox, .edge, .brave:
"Using \(rawValue) cookies"
}
}
var statusDetail: String {
switch self {
case .none:
"Restricted media may fail if the site requires login."
case .safari:
"yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
case .chrome, .firefox, .edge, .brave:
"yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
}
}
}
enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
case ftp
case socks5
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
case .https: "HTTPS (legacy)"
case .ftp: "FTP (legacy)"
case .socks5: "SOCKS5"
}
}
var uriScheme: String {
rawValue
switch self {
case .http, .https, .ftp:
"http"
case .socks5:
"socks5"
}
}
}
@@ -68,11 +113,11 @@ final class AppSettings: ObservableObject {
@Published var appTheme: AppTheme = .system {
didSet { save() }
}
@Published var appFontSize: AppFontSize = .standard {
didSet { save() }
}
@Published var listRowDensity: ListRowDensity = .standard {
didSet { save() }
}
@@ -112,6 +157,33 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var showNotifications: Bool {
didSet { save() }
}
@Published var customUserAgent: String {
didSet { save() }
}
@Published var playCompletionSound: Bool {
didSet { save() }
}
@Published var showDockBadge: Bool {
didSet { save() }
}
@Published var maxAutomaticRetries: Int {
didSet {
let clamped = min(max(maxAutomaticRetries, 0), 10)
if maxAutomaticRetries != clamped {
maxAutomaticRetries = clamped
return
}
save()
}
}
@Published var proxySettings: ProxySettings {
didSet {
let normalized = proxySettings.normalized
@@ -131,6 +203,28 @@ final class AppSettings: ObservableObject {
didSet { save() }
}
@Published var mediaCookieSource: BrowserCookieSource {
didSet { save() }
}
@Published var extensionPairingToken: String {
didSet {
if isKeychainAccessGranted {
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
}
}
}
@Published var isKeychainAccessGranted: Bool {
didSet { save() }
}
@Published var showKeychainPrimer = false
@Published var askWhereToSaveEachFile: Bool {
didSet { save() }
}
@Published var message = ""
private let defaults: UserDefaults
@@ -140,6 +234,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
@@ -149,9 +244,18 @@ final class AppSettings: ObservableObject {
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
showNotifications = stored.showNotifications ?? true
playCompletionSound = stored.playCompletionSound ?? true
showDockBadge = stored.showDockBadge ?? true
customUserAgent = stored.customUserAgent ?? ""
maxAutomaticRetries = min(max(stored.maxAutomaticRetries ?? 3, 0), 10)
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
mediaCookieSource = stored.mediaCookieSource ?? .none
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
granted = stored.isKeychainAccessGranted ?? false
isKeychainAccessGranted = granted
askWhereToSaveEachFile = stored.askWhereToSaveEachFile ?? false
} else {
appTheme = .system
appFontSize = .standard
@@ -160,9 +264,52 @@ final class AppSettings: ObservableObject {
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true
showNotifications = true
playCompletionSound = true
showDockBadge = true
customUserAgent = ""
maxAutomaticRetries = 3
proxySettings = ProxySettings()
siteLogins = []
mediaCookieSource = .none
downloadDirectories = Self.defaultDirectories()
granted = false
isKeychainAccessGranted = granted
askWhereToSaveEachFile = false
}
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
var execHash = "unknown"
if let execPath = Bundle.main.executablePath,
let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
let modDate = attr[.modificationDate] as? Date {
execHash = String(modDate.timeIntervalSince1970)
}
let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
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()
}
}
} else {
extensionPairingToken = ""
}
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
@@ -193,6 +340,10 @@ final class AppSettings: ObservableObject {
}
func addSiteLogin(urlPattern: String, username: String, password: String) {
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
}
func saveSiteLogin(id: UUID?, urlPattern: String, username: String, password: String) {
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -201,12 +352,39 @@ final class AppSettings: ObservableObject {
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
message = "Could not save the password to Keychain."
if let id,
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
message = "A login for \(pattern) already exists."
return
}
if let index = siteLogins.firstIndex(where: { login in
if let id {
return login.id == id
}
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
}) {
let loginID = siteLogins[index].id
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
message = "Could not save the password to Keychain."
return
}
siteLogins[index].urlPattern = pattern
siteLogins[index].username = cleanUsername
message = "Updated login for \(pattern)."
return
}
guard !password.isEmpty else {
message = "Add a password."
return
}
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
if !KeychainCredentialStore.setPassword(password, for: login.id) {
message = "Could not save the password to Keychain."
return
}
siteLogins.append(login)
message = "Added login for \(pattern)."
}
@@ -235,6 +413,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 {
revokeKeychainAccess()
}
}
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
@@ -244,9 +454,17 @@ final class AppSettings: ObservableObject {
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
showNotifications: showNotifications,
playCompletionSound: playCompletionSound,
showDockBadge: showDockBadge,
customUserAgent: customUserAgent,
maxAutomaticRetries: maxAutomaticRetries,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
siteLogins: siteLogins,
mediaCookieSource: mediaCookieSource,
isKeychainAccessGranted: isKeychainAccessGranted,
askWhereToSaveEachFile: askWhereToSaveEachFile
)
let defaults = self.defaults
let storageKey = self.storageKey
@@ -256,7 +474,7 @@ final class AppSettings: ObservableObject {
let data = await Task.detached(priority: .background) {
try? JSONEncoder().encode(stored)
}.value
guard !Task.isCancelled, let encoded = data else { return }
defaults.set(encoded, forKey: storageKey)
}
@@ -285,7 +503,16 @@ final class AppSettings: ObservableObject {
return absolute.contains(normalizedPattern)
}
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
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] {
@@ -314,7 +541,15 @@ private struct StoredSettings: Codable {
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool
var showNotifications: Bool?
var playCompletionSound: Bool?
var showDockBadge: Bool?
var customUserAgent: String?
var maxAutomaticRetries: Int?
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
var siteLogins: [SiteLogin]
var mediaCookieSource: BrowserCookieSource?
var isKeychainAccessGranted: Bool?
var askWhereToSaveEachFile: Bool?
}
-113
View File
@@ -1,113 +0,0 @@
import Foundation
@MainActor
final class AppUpdateChecker: ObservableObject {
enum Status: Equatable {
case idle
case checking
case upToDate(String)
case updateAvailable(latestVersion: String, releaseURL: URL)
case unavailable(String)
var message: String {
switch self {
case .idle:
"Check GitHub Releases for the latest Firelink build."
case .checking:
"Checking for updates..."
case .upToDate(let version):
"Firelink is up to date. Latest version: \(version)."
case .updateAvailable(let latestVersion, _):
"Version \(latestVersion) is available."
case .unavailable(let message):
message
}
}
}
@Published private(set) var status: Status = .idle
@Published private(set) var lastChecked: Date?
let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func checkForUpdates(currentVersion: String) async {
status = .checking
lastChecked = Date()
do {
var request = URLRequest(url: latestReleaseURL)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
status = .unavailable("Could not read the update server response.")
return
}
guard httpResponse.statusCode == 200 else {
status = .unavailable("No published Firelink release was found.")
return
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
let latestVersion = release.version
if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
} else {
status = .upToDate(latestVersion)
}
} catch {
status = .unavailable("Could not check for updates. Try again later.")
}
}
}
private struct GitHubRelease: Decodable {
let tagName: String
let htmlURL: URL
var version: String {
tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
}
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlURL = "html_url"
}
}
private enum VersionComparator {
static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
let candidateParts = parts(from: candidate)
let currentParts = parts(from: current)
let count = max(candidateParts.count, currentParts.count)
for index in 0..<count {
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
let currentValue = index < currentParts.count ? currentParts[index] : 0
if candidateValue != currentValue {
return candidateValue > currentValue
}
}
return false
}
private static func parts(from version: String) -> [Int] {
version
.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
.split(separator: ".")
.map { component in
let digits = component.prefix(while: \.isNumber)
return Int(digits) ?? 0
}
}
}
+309 -53
View File
@@ -2,7 +2,7 @@ import Foundation
import CFNetwork
import Network
final class Aria2DownloadEngine {
final class Aria2DownloadEngine: Sendable {
struct Handle {
let processIdentifier: Int32
let rpcPort: Int
@@ -10,18 +10,43 @@ final class Aria2DownloadEngine {
let cancel: @Sendable () -> Void
}
static func findFreePort() -> Int {
var port: UInt16 = 6800
let parameters = NWParameters.tcp
for p in 6800...6900 {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(p))!)
if let listener = try? NWListener(using: parameters) {
listener.cancel()
port = UInt16(p)
break
static func findFreePort() -> (UInt16, Int32)? {
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
addr.sin_port = 0
let sock = socket(AF_INET, SOCK_STREAM, 0)
guard sock >= 0 else { return nil }
var addrPtr = addr
let bindResult = withUnsafePointer(to: &addrPtr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(sock, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
return Int(port)
guard bindResult == 0 else {
close(sock)
return nil
}
var boundAddr = sockaddr_in()
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
let getsocknameResult = withUnsafeMutablePointer(to: &boundAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(sock, $0, &len)
}
}
guard getsocknameResult == 0 else {
close(sock)
return nil
}
let port = UInt16(bigEndian: boundAddr.sin_port)
return (port, sock)
}
enum EngineError: LocalizedError {
@@ -56,21 +81,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
}
@@ -91,10 +109,10 @@ final class Aria2DownloadEngine {
// Close the write file handle in the parent process immediately
// This guarantees readToEnd() won't hang waiting for the parent itself
outputPipe.fileHandleForWriting.closeFile()
let data = try? outputPipe.fileHandleForReading.readToEnd()
process.waitUntilExit()
guard process.terminationStatus == 0, let data = data else { return nil }
let output = String(data: data, encoding: .utf8) ?? ""
@@ -114,7 +132,7 @@ final class Aria2DownloadEngine {
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
) async throws -> Handle {
guard let executableURL else {
throw EngineError.executableNotFound
}
@@ -124,22 +142,74 @@ final class Aria2DownloadEngine {
withIntermediateDirectories: true
)
let rpcPort = Self.findFreePort()
let rpcSecret = UUID().uuidString
var lastError: Error?
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
rpcSecret: rpcSecret
)
for _ in 1...5 {
guard let (rpcPortVal, portSocket) = Self.findFreePort() else {
lastError = EngineError.launchFailed("Could not find free port")
continue
}
let rpcPort = Int(rpcPortVal)
let rpcSecret = UUID().uuidString
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
final class CleanupState: @unchecked Sendable {
private let lock = NSLock()
private var didCleanup = false
func cleanup(tempDir: URL) {
lock.lock()
defer { lock.unlock() }
if !didCleanup {
try? FileManager.default.removeItem(at: tempDir)
didCleanup = true
}
}
}
let cleanupState = CleanupState()
let cleanupTempDir: @Sendable () -> Void = {
cleanupState.cleanup(tempDir: tempDir)
}
do {
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
} catch {
close(portSocket)
lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
continue
}
let confURL = tempDir.appendingPathComponent("aria2.conf")
do {
let confContent = "rpc-secret=\(rpcSecret)\n"
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
} catch {
close(portSocket)
lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
continue
}
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
let process = Process()
process.executableURL = executableURL
do {
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
confURL: confURL
)
} catch {
close(portSocket)
lastError = error
break
}
let inputPipe = Pipe()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardInput = inputPipe
process.standardOutput = outputPipe
process.standardError = errorPipe
@@ -147,6 +217,8 @@ final class Aria2DownloadEngine {
let parser = Aria2ProgressParser()
let outputBuffer = LockedDataBuffer()
let errorBuffer = LockedDataBuffer()
let completionGate = CompletionGate(completion)
let completionMonitor = CompletionMonitor()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
@@ -166,11 +238,13 @@ final class Aria2DownloadEngine {
}
process.terminationHandler = { finishedProcess in
cleanupTempDir()
completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
completion(.success(()))
completionGate.complete(.success(()))
return
}
@@ -182,24 +256,165 @@ final class Aria2DownloadEngine {
.compactMap { $0 }
.filter { !$0.isEmpty }
.joined(separator: "\n")
completion(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
}
do {
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
var didThrow = false
do {
close(portSocket)
try process.run()
if let input = inputFileContent(for: item).data(using: .utf8) {
inputPipe.fileHandleForWriting.write(input)
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
didThrow = true
lastError = EngineError.launchFailed(error.localizedDescription)
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
throw EngineError.launchFailed(error.localizedDescription)
if didThrow {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
cleanupTempDir()
continue
}
try? await Task.sleep(nanoseconds: 100_000_000)
if !process.isRunning {
let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
cleanupTempDir()
continue
}
// If it exited for another reason, we might still want to fail or let the terminationHandler process it.
// But the terminationHandler will hit completionGate, so we just return the handle.
}
completionMonitor.set(
Self.monitorCompletion(
rpcPort: rpcPort,
rpcSecret: rpcSecret,
process: process,
completionGate: completionGate
)
)
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionMonitor.cancel()
if process.isRunning {
process.terminate()
}
cleanupTempDir()
}
}
throw lastError ?? EngineError.launchFailed("Failed to start aria2c after 5 attempts.")
}
private static func monitorCompletion(
rpcPort: Int,
rpcSecret: String,
process: Process,
completionGate: CompletionGate<Void>
) -> Task<Void, Never> {
Task.detached {
while !Task.isCancelled && process.isRunning {
if await completedDownloadStatus(rpcPort: rpcPort, rpcSecret: rpcSecret) {
completionGate.complete(.success(()))
if process.isRunning {
process.terminate()
}
return
}
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
return
}
}
}
}
private static func completedDownloadStatus(rpcPort: Int, rpcSecret: String) async -> Bool {
guard let stopped = await rpcCall(
rpcPort: rpcPort,
rpcSecret: rpcSecret,
method: "aria2.tellStopped",
arguments: [0, 10, ["status", "errorCode", "completedLength", "totalLength"]]
) as? [[String: Any]] else {
return false
}
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
if process.isRunning {
process.terminate()
}
if stopped.contains(where: { item in
(item["status"] as? String) == "complete"
}) {
return true
}
return stopped.contains { item in
guard (item["status"] as? String) == "error",
(item["errorCode"] as? String) == "0",
let completedLength = Int64(item["completedLength"] as? String ?? ""),
let totalLength = Int64(item["totalLength"] as? String ?? ""),
totalLength > 0 else {
return false
}
return completedLength >= totalLength
}
}
private static func rpcCall(
rpcPort: Int,
rpcSecret: String,
method: String,
arguments: [Any]
) async -> Any? {
guard let url = URL(string: "http://127.0.0.1:\(rpcPort)/jsonrpc") else { return nil }
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": method,
"id": UUID().uuidString,
"params": ["token:\(rpcSecret)"] + arguments
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
request.timeoutInterval = 3
guard let (responseData, _) = try? await URLSession.shared.data(for: request),
let json = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
return nil
}
return json["result"]
}
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": "aria2.changeGlobalOption",
"id": UUID().uuidString,
"params": [
"token:\(handle.rpcSecret)",
["max-overall-download-limit": limitValue]
]
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = data
request.timeoutInterval = 3
_ = try? await URLSession.shared.data(for: request)
}
private func arguments(
@@ -207,9 +422,10 @@ final class Aria2DownloadEngine {
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
rpcPort: Int,
rpcSecret: String
confURL: URL
) throws -> [String] {
var arguments = [
"--conf-path=\(confURL.path)",
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
@@ -226,12 +442,11 @@ final class Aria2DownloadEngine {
"--input-file=-",
"--enable-rpc=true",
"--rpc-listen-port=\(rpcPort)",
"--rpc-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
@@ -398,6 +613,47 @@ final class LockedDataBuffer: @unchecked Sendable {
}
}
final class CompletionGate<Success>: @unchecked Sendable {
private let lock = NSLock()
private var didComplete = false
private let completion: @Sendable (Result<Success, Error>) -> Void
init(_ completion: @escaping @Sendable (Result<Success, Error>) -> Void) {
self.completion = completion
}
func complete(_ result: Result<Success, Error>) {
lock.lock()
let shouldComplete = !didComplete
if shouldComplete {
didComplete = true
}
lock.unlock()
guard shouldComplete else { return }
completion(result)
}
}
final class CompletionMonitor: @unchecked Sendable {
private let lock = NSLock()
private var task: Task<Void, Never>?
func set(_ task: Task<Void, Never>) {
lock.lock()
self.task = task
lock.unlock()
}
func cancel() {
lock.lock()
let task = self.task
self.task = nil
lock.unlock()
task?.cancel()
}
}
final class Aria2ProgressParser: @unchecked Sendable {
private let percentageRegex = try? NSRegularExpression(pattern: #"\((\d+(?:\.\d+)?)%\)"#)
private let connectionRegex = try? NSRegularExpression(pattern: #"CN:(\d+)"#)
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "github-mark.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+26 -25
View File
@@ -2,12 +2,12 @@ import SwiftUI
struct ChunkMapView: View {
let item: DownloadItem
@State private var bitfield: String = ""
@State private var numPieces: Int = 0
@State private var pollTask: Task<Void, Never>?
@State private var isVisible = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if numPieces > 0 {
@@ -34,11 +34,11 @@ struct ChunkMapView: View {
}
}
}
private func startPolling() {
pollTask?.cancel()
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
pollTask = Task {
while !Task.isCancelled {
await fetchStatus(port: port, secret: secret)
@@ -50,23 +50,23 @@ struct ChunkMapView: View {
}
}
}
private func fetchStatus(port: Int, secret: String) async {
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"jsonrpc": "2.0",
"method": "aria2.tellActive",
"id": "1",
"params": ["token:\(secret)", ["bitfield", "numPieces"]]
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
request.httpBody = data
do {
let (responseData, _) = try await URLSession.shared.data(for: request)
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
@@ -74,11 +74,11 @@ struct ChunkMapView: View {
let active = result.first else {
return
}
let fetchedBitfield = active["bitfield"] as? String ?? ""
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
await MainActor.run {
self.bitfield = fetchedBitfield
self.numPieces = fetchedNumPieces
@@ -92,7 +92,7 @@ struct ChunkMapView: View {
struct ChunkGrid: View {
let bitfield: String
let numPieces: Int
private var pieces: [Bool] {
var result = [Bool]()
result.reserveCapacity(numPieces)
@@ -110,50 +110,51 @@ struct ChunkGrid: View {
}
return result
}
var body: some View {
let itemPieces = pieces
Canvas { context, size in
let boxSize: CGFloat = 6
let spacing: CGFloat = 1
let boxSize: CGFloat = 10
let spacing: CGFloat = 2
let cornerSize = CGSize(width: 2, height: 2)
let width = size.width
let x: CGFloat = 0
let y: CGFloat = 0
let completedPath = Path { p in
var cx = x
var cy = y
for piece in itemPieces {
if piece {
p.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing))
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
}
cx += boxSize
if cx >= width {
if cx + boxSize > width {
cx = 0
cy += boxSize
}
}
}
let pendingPath = Path { p in
var cx: CGFloat = 0
var cy: CGFloat = 0
for piece in itemPieces {
if !piece {
p.addRect(CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing))
p.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
}
cx += boxSize
if cx >= width {
if cx + boxSize > width {
cx = 0
cy += boxSize
}
}
}
context.fill(pendingPath, with: .color(Color.gray.opacity(0.3)))
context.fill(completedPath, with: .color(Color.green))
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
context.fill(completedPath, with: .color(Color.accentColor))
}
.frame(minHeight: 80)
.frame(minHeight: 140)
}
}
+145 -38
View File
@@ -21,13 +21,28 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
openWindow(id: "add-downloads")
}
.onChange(of: controller.activeCount) { _, newCount in
updateDockBadge(count: newCount, show: settings.showDockBadge)
}
.onChange(of: settings.showDockBadge) { _, show in
updateDockBadge(count: controller.activeCount, show: show)
}
.onAppear {
updateDockBadge(count: controller.activeCount, show: settings.showDockBadge)
}
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
for provider in providers {
if provider.canLoadObject(ofClass: URL.self) {
_ = 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")
}
}
@@ -36,7 +51,12 @@ 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")
}
}
@@ -45,6 +65,10 @@ struct ContentView: View {
}
return true
}
.sheet(isPresented: $settings.showKeychainPrimer) {
KeychainPrimerView()
.environmentObject(settings)
}
}
@ViewBuilder
@@ -93,54 +117,44 @@ struct ContentView: View {
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
ToolbarItemGroup {
Button {
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
Label("Add Download", systemImage: "plus")
}
}
.help("Add a new download")
ToolbarItem {
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
Button {
controller.startQueue(queueID: queueID)
} label: {
Label("Start Queue", systemImage: "play.fill")
}
}
ToolbarItem {
Button {
controller.pauseActiveDownloads(queueID: queueID)
} label: {
Label("Stop Downloads", systemImage: "stop.fill")
}
.disabled(!hasActiveDownloads(in: queueID))
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
} label: {
Label("Stop", systemImage: "stop.fill")
if selectedItems.isEmpty {
controller.startQueue(queueID: queueID)
} else {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Resume All" : "Resume", systemImage: "play.fill")
}
.help(selectedItems.isEmpty ? "Resume all downloads" : "Resume selected downloads")
.disabled(!canStart)
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading || $0.status == .queued })
Button {
if selectedItems.isEmpty {
controller.pauseActiveDownloads(queueID: queueID)
} else {
for item in selectedItems where item.status == .downloading || item.status == .queued {
controller.pause(item)
}
}
} label: {
Label(selectedItems.isEmpty ? "Pause All" : "Pause", systemImage: "pause.fill")
}
.help(selectedItems.isEmpty ? "Pause all active downloads" : "Pause selected downloads")
.disabled(!canStop)
}
}
.background {
@@ -151,18 +165,34 @@ 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 {
performPrimaryAction(for: item)
}
}
.keyboardShortcut(.return, modifiers: [])
.opacity(0)
.buttonStyle(.plain)
.focusable(false)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
@@ -180,6 +210,14 @@ struct ContentView: View {
}
}
private func updateDockBadge(count: Int, show: Bool) {
if show && count > 0 {
NSApp.dockTile.badgeLabel = "\(count)"
} else {
NSApp.dockTile.badgeLabel = nil
}
}
private var selectedItems: [DownloadItem] {
controller.downloads.filter { selection.contains($0.id) }
}
@@ -190,10 +228,11 @@ struct ContentView: View {
}
selection.removeAll()
}
private func handlePaste(queueID: UUID?) {
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
controller.pendingPasteboardText = text
controller.pendingReferer = nil
controller.pendingAddQueueID = queueID
openWindow(id: "add-downloads")
}
@@ -202,6 +241,14 @@ struct ContentView: View {
selection = Set(items.map { $0.id })
}
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
} else {
openWindow(id: "download-properties", value: item.id)
}
}
private func hasActiveDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
@@ -210,6 +257,14 @@ struct ContentView: View {
return controller.activeCount > 0
}
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
if let queueID {
return controller.queueItems(for: queueID).contains { $0.status == .queued }
}
return controller.queuedCount > 0
}
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
@@ -227,3 +282,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)
}
}
+482 -109
View File
@@ -9,22 +9,28 @@ final class DownloadController: ObservableObject {
@Published var queues: [DownloadQueue] = [.main]
@Published var engineMessage = ""
@Published var pendingPasteboardText: String?
@Published var pendingReferer: String?
@Published var extensionServerPort: UInt16?
var pendingAddQueueID: UUID?
private let settings: AppSettings
private let engine = Aria2DownloadEngine()
private let mediaEngine = MediaDownloadEngine()
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var activeMediaHandles: [UUID: MediaDownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var queuePumpScope: QueuePumpScope = .idle
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
private lazy var storageURL: URL = {
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
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
@@ -38,7 +44,26 @@ final class DownloadController: ObservableObject {
}
}
.store(in: &cancellables)
settings.$globalSpeedLimitKiBPerSecond
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
}
}
.store(in: &cancellables)
settings.$maxConcurrentDownloads
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor in
self?.applySpeedLimitsToActiveDownloads()
self?.pumpQueue()
}
}
.store(in: &cancellables)
$downloads
.dropFirst()
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
@@ -46,10 +71,10 @@ final class DownloadController: ObservableObject {
self?.saveDownloads()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in
self?.saveDownloads()
self?.saveDownloadsSync()
}
.store(in: &cancellables)
@@ -87,7 +112,24 @@ final class DownloadController: ObservableObject {
Aria2DownloadEngine.findExecutable() != nil
}
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID) {
private var hasStartableQueuedDownloadIgnoringEngine: Bool {
downloads.contains { item in
item.status == .queued &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}
}
private var hasRunnableQueuedDownload: Bool {
downloads.contains { item in
item.status == .queued &&
(item.mediaFormatSelector != nil || hasAria2) &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}
}
func add(urlText: String, connectionsPerServer: Int? = nil, queueID: UUID = DownloadQueue.mainQueueID, filename: String? = nil) {
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
let scheme = url.scheme?.lowercased(),
["http", "https", "ftp", "sftp"].contains(scheme) else {
@@ -95,11 +137,12 @@ final class DownloadController: ObservableObject {
return
}
let fileName = FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: fileName)
let explicitName = filename?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedFileName = (explicitName != nil && !explicitName!.isEmpty) ? explicitName! : FileClassifier.fileName(from: url)
let category = FileClassifier.category(forFileName: resolvedFileName)
let item = DownloadItem(
url: url,
fileName: fileName,
fileName: resolvedFileName,
category: category,
destinationDirectory: settings.destinationDirectory(for: category),
connectionsPerServer: min(max(connectionsPerServer ?? settings.perServerConnections, 1), 16),
@@ -112,7 +155,7 @@ final class DownloadController: ObservableObject {
}
downloads.append(item)
engineMessage = "Added \(fileName) to \(category.rawValue)."
engineMessage = "Added \(resolvedFileName) to \(category.rawValue)."
saveDownloads()
}
@@ -131,10 +174,11 @@ final class DownloadController: ObservableObject {
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
let items = pendingDownloads.map { pending in
DownloadItem(
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
return DownloadItem(
url: pending.url,
fileName: pending.fileName,
category: pending.category,
fileName: fileName,
category: FileClassifier.category(forFileName: fileName),
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: credentials ?? settings.credentials(for: pending.url),
@@ -146,7 +190,9 @@ final class DownloadController: ObservableObject {
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue",
queueID: targetQueueID
queueID: targetQueueID,
mediaFormatSelector: pending.selectedMediaOption?.formatSelector,
isAudioOnlyMedia: pending.selectedMediaOption?.isAudioOnly
)
}
@@ -187,8 +233,11 @@ final class DownloadController: ObservableObject {
}
func pause(_ item: DownloadItem) {
lastProgressUpdateTimes[item.id] = nil
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
@@ -196,6 +245,7 @@ final class DownloadController: ObservableObject {
}
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -211,6 +261,8 @@ final class DownloadController: ObservableObject {
for item in activeItems {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .paused
$0.message = "Paused. Resume will continue from the partial file."
@@ -221,6 +273,7 @@ final class DownloadController: ObservableObject {
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
@@ -228,6 +281,8 @@ final class DownloadController: ObservableObject {
func queue(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .queued
if item.status != .paused {
@@ -241,6 +296,7 @@ final class DownloadController: ObservableObject {
}
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
}
@@ -302,6 +358,8 @@ final class DownloadController: ObservableObject {
func cancel(_ item: DownloadItem) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
update(item.id) {
$0.status = .canceled
$0.message = "Canceled"
@@ -309,20 +367,18 @@ final class DownloadController: ObservableObject {
}
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
for index in offsets {
let item = downloads[index]
delete(item, deleteFiles: deleteFiles)
}
}
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
activeHandles[item.id]?.cancel()
activeHandles[item.id] = nil
activeMediaHandles[item.id]?.cancel()
activeMediaHandles[item.id] = nil
if deleteFiles {
trashFiles(for: item)
} else if item.status != .completed {
@@ -332,6 +388,7 @@ final class DownloadController: ObservableObject {
downloads.removeAll { $0.id == item.id }
automaticRetryCounts[item.id] = nil
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
}
@@ -410,7 +467,11 @@ final class DownloadController: ObservableObject {
}
private func pumpQueue() {
guard hasAria2 else {
guard hasStartableQueuedDownloadIgnoringEngine else {
return
}
guard hasRunnableQueuedDownload else {
engineMessage = "aria2c is not installed. Run `brew install aria2` to enable downloads."
return
}
@@ -420,6 +481,7 @@ final class DownloadController: ObservableObject {
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { item in
item.status == .queued &&
(item.mediaFormatSelector != nil || hasAria2) &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}) {
@@ -435,6 +497,22 @@ final class DownloadController: ObservableObject {
pruneActiveQueueScopes()
}
private func injectedEngineItem(from item: DownloadItem) -> DownloadItem {
var engineItem = item
if engineItem.credentials != nil {
if let storedPassword = KeychainCredentialStore.password(for: engineItem.id) {
engineItem.credentials?.password = storedPassword
}
}
let ua = settings.customUserAgent.trimmingCharacters(in: .whitespacesAndNewlines)
if !ua.isEmpty, !(engineItem.requestHeaders?.contains(where: { $0.name.caseInsensitiveCompare("user-agent") == .orderedSame }) ?? false) {
var headers = engineItem.requestHeaders ?? []
headers.append(DownloadRequestHeader(name: "User-Agent", value: ua))
engineItem.requestHeaders = headers
}
return engineItem
}
private func start(_ item: DownloadItem) {
update(item.id) {
$0.status = .downloading
@@ -446,69 +524,221 @@ final class DownloadController: ObservableObject {
}
saveDownloads()
do {
let handle = try engine.start(
item: item,
proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
progress: { [weak self] progress in
Task { @MainActor in
self?.update(item.id) {
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
if item.mediaFormatSelector != nil {
Task {
do {
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Checking bundled media engines..."
}
},
completion: { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.activeHandles[item.id] = nil
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
update(item.id) {
guard $0.status == .downloading else { return }
$0.message = "Starting yt-dlp..."
}
let handle = try await mediaEngine.start(
item: injectedEngineItem(from: liveItem),
cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration,
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.5 {
return
}
self?.lastProgressUpdateTimes[item.id] = now
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
if $0.message == "Starting" {
$0.message = "Downloading Media"
}
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
},
messageUpdate: { [weak self] message in
Task { @MainActor in
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.message = message
}
}
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleMediaCompletion(item: item, result: result)
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.updateSleepActivity()
)
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeMediaHandles[item.id] = handle
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch {
guard shouldHandleStartFailure(for: item.id) else { return }
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
}
} else {
Task {
do {
let liveItem = injectedEngineItem(from: item)
let handle = try await engine.start(
item: liveItem,
proxyConfiguration: settings.downloadProxyConfiguration,
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.5 {
return
}
self?.lastProgressUpdateTimes[item.id] = now
self?.update(item.id) {
guard $0.status == .downloading else { return }
$0.progress = progress.fraction
$0.bytesText = progress.bytesText
$0.speedText = progress.speedText
$0.etaText = progress.etaText
$0.connectionCount = progress.connectionCount
$0.message = "Downloading"
}
}
},
completion: { [weak self] result in
Task { @MainActor in
self?.handleCompletion(item: item, result: result)
}
}
)
await MainActor.run {
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Starting..."
}
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
}
} catch {
await MainActor.run {
handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
pumpQueue()
}
}
)
activeHandles[item.id] = handle
update(item.id) {
$0.rpcPort = handle.rpcPort
$0.rpcSecret = handle.rpcSecret
$0.message = "Process \(handle.processIdentifier)"
}
saveDownloads()
updateSleepActivity()
} catch {
handleDownloadFailure(itemID: item.id, error: error)
updateSleepActivity()
pumpQueue()
}
}
private func activeDownloadItem(id: UUID) -> DownloadItem? {
downloads.first { $0.id == id && $0.status == .downloading }
}
private func shouldHandleStartFailure(for id: UUID) -> Bool {
guard let item = downloads.first(where: { $0.id == id }) else {
automaticRetryCounts[id] = nil
return false
}
return item.status != .paused && item.status != .canceled
}
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>) {
activeHandles[item.id] = nil
switch result {
case .success:
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: item.fileName)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
private func handleMediaCompletion(item: DownloadItem, result: Result<URL, Error>) {
activeMediaHandles[item.id] = nil
switch result {
case .success(let outputURL):
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.destinationDirectory = outputURL.deletingLastPathComponent()
$0.fileName = outputURL.lastPathComponent
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.message = "Saved to \(outputURL.path)"
$0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: outputURL.lastPathComponent)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index])
@@ -526,8 +756,8 @@ final class DownloadController: ObservableObject {
) {
update(id) {
$0.url = url
$0.fileName = fileName
$0.category = FileClassifier.category(forFileName: fileName)
$0.fileName = FileClassifier.sanitizedFileName(fileName)
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.destinationDirectory = destinationDirectory
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
$0.credentials = credentials
@@ -543,28 +773,37 @@ final class DownloadController: ObservableObject {
} else if credentials == nil {
KeychainCredentialStore.deletePassword(for: id)
}
applySpeedLimitToActiveDownload(id: id)
saveDownloads()
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, 10_485_760)
SpeedLimitPolicy.normalized(value)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
SpeedLimitPolicy.effectiveLimit(
itemLimit: item.speedLimitKiBPerSecond,
globalLimit: settings.globalSpeedLimitKiBPerSecond,
activeDownloadCount: activeCount
)
}
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
private func applySpeedLimitsToActiveDownloads() {
for item in downloads where item.status == .downloading {
applySpeedLimitToActiveDownload(id: item.id)
}
}
private func applySpeedLimitToActiveDownload(id: UUID) {
guard let handle = activeHandles[id],
let item = downloads.first(where: { $0.id == id }) else {
return
}
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
Task {
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
}
}
@@ -584,9 +823,17 @@ final class DownloadController: ObservableObject {
}
private func handleDownloadFailure(itemID: UUID, error: Error) {
guard let currentItem = downloads.first(where: { $0.id == itemID }) else {
automaticRetryCounts[itemID] = nil
return
}
guard currentItem.status != .paused, currentItem.status != .canceled else {
return
}
let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
guard isAutomaticallyRecoverable(error), retryCount < settings.maxAutomaticRetries else {
automaticRetryCounts[itemID] = nil
update(itemID) {
$0.status = .failed
@@ -609,23 +856,58 @@ final class DownloadController: ObservableObject {
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(settings.maxAutomaticRetries))."
$0.autoResumeOnLaunch = true
}
saveDownloads()
}
private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else {
return true
if let engineError = error as? Aria2DownloadEngine.EngineError {
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
}
switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
if let mediaError = error as? MediaDownloadEngine.EngineError {
switch mediaError {
case .missingEngine:
return false
case .launchFailed(let message):
return isRecoverableMediaFailure(message)
}
}
return true
}
private func isRecoverableMediaFailure(_ message: String) -> Bool {
let lowercased = message.lowercased()
let permanentMarkers = [
"requires browser cookies",
"choose a browser",
"challenge solving failed",
"install deno or node",
"requested format is not available",
"unsupported url",
"private video",
"sign in",
"not a bot",
"video unavailable",
"no video formats found",
"no audio formats found",
"ffmpeg is not installed",
"yt-dlp is not installed"
]
if permanentMarkers.contains(where: { lowercased.contains($0) }) {
return false
}
return true
}
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
@@ -722,6 +1004,22 @@ final class DownloadController: ObservableObject {
}
}
private func saveDownloadsSync() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
let storageURL = self.storageURL
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to synchronously save downloads: \(error)")
}
}
private func saveDownloads() {
let queuesCopy = queues
let downloadsCopy = downloads.map(\.redactedForPersistence)
@@ -734,7 +1032,7 @@ final class DownloadController: ObservableObject {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state)
guard !Task.isCancelled else { return }
try data.write(to: storageURL, options: .atomic)
} catch {
@@ -774,9 +1072,19 @@ final class DownloadController: ObservableObject {
if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID
}
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword
if adjusted.status == .completed && adjusted.progress != 1 {
adjusted.progress = 1
shouldRewriteStoredDownloads = true
}
if adjusted.status == .completed &&
(adjusted.speedText != "-" || adjusted.etaText != "-" || adjusted.connectionCount != 0 || adjusted.autoResumeOnLaunch != false) {
adjusted.speedText = "-"
adjusted.etaText = "-"
adjusted.connectionCount = 0
adjusted.autoResumeOnLaunch = false
shouldRewriteStoredDownloads = true
}
if adjusted.status == .downloading {
@@ -829,14 +1137,49 @@ 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)
guard settings.showNotifications || settings.playCompletionSound else { return }
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 }
var options: UNAuthorizationOptions = []
if self.settings.showNotifications { options.insert(.alert) }
if self.settings.playCompletionSound { options.insert(.sound) }
UNUserNotificationCenter.current().requestAuthorization(options: options) { [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 self.settings.showNotifications {
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."
}
} else {
content.title = "Download Completed"
}
if self.settings.playCompletionSound {
content.sound = .default
}
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
}
} catch {}
}
}
}
@@ -846,6 +1189,36 @@ private struct StoredDownloadState: Codable {
var downloads: [DownloadItem]
}
enum SpeedLimitPolicy {
static let maximumKiBPerSecond = 10_485_760
static func normalized(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, maximumKiBPerSecond)
}
static func effectiveLimit(
itemLimit: Int?,
globalLimit: Int?,
activeDownloadCount: Int
) -> Int? {
let itemLimit = normalized(itemLimit)
let globalLimit = normalized(globalLimit)
.map { max(1, $0 / max(activeDownloadCount, 1)) }
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
}
}
private final class SleepActivityHandle: @unchecked Sendable {
private let activity: NSObjectProtocol
+113 -5
View File
@@ -1,8 +1,9 @@
import Foundation
enum DownloadURLParser {
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
static func parse(_ text: String) -> [URL] {
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
@@ -33,7 +34,9 @@ enum DownloadMetadataFetcher {
static func fetch(
for url: URL,
settings: AppSettings,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
credentials: DownloadCredentials? = nil,
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
isAutoFetch: Bool = false
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
@@ -51,11 +54,33 @@ enum DownloadMetadataFetcher {
return pending
}
if let host = url.host {
let isPrivate = await Task.detached {
isPrivateHost(host)
}.value
if isPrivate {
pending.state = .loaded
return pending
}
}
var request = URLRequest(url: url)
request.httpMethod = "HEAD"
request.timeoutInterval = 12
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
let normalizedHeaders = transferOptions.requestHeaders.map(\.normalized).filter { !$0.isEmpty }
let hasAuthorizationHeader = normalizedHeaders.contains { $0.name.caseInsensitiveCompare("Authorization") == .orderedSame }
if let credentials, !credentials.isEmpty, !hasAuthorizationHeader {
let token = "\(credentials.username):\(credentials.password)"
.data(using: .utf8)?
.base64EncodedString()
if let token {
request.setValue("Basic \(token)", forHTTPHeaderField: "Authorization")
}
}
for header in normalizedHeaders {
request.setValue(header.value, forHTTPHeaderField: header.name)
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
@@ -71,8 +96,8 @@ enum DownloadMetadataFetcher {
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
let fileName = fileName(fromContentDisposition: disposition) {
pending.fileName = fileName
pending.category = FileClassifier.category(forFileName: fileName)
pending.fileName = FileClassifier.sanitizedFileName(fileName)
pending.category = FileClassifier.category(forFileName: pending.fileName)
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
}
@@ -108,6 +133,89 @@ enum DownloadMetadataFetcher {
}
return nil
}
private static func isPrivateHost(_ host: String) -> Bool {
let h = host.lowercased()
if h == "localhost" || h.hasSuffix(".local") { return true }
if !h.contains(".") && !h.contains(":") { 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
)
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
}
}
enum ByteFormatter {
+252 -75
View File
@@ -46,6 +46,8 @@ struct DownloadPropertiesView: View {
@State private var cookieText: String
@State private var mirrorText: String
@State private var errorMessage = ""
@State private var showsAdvancedTransfer = false
@State private var showsChunkMap = false
init(item: DownloadItem) {
self.item = item
@@ -58,7 +60,8 @@ struct DownloadPropertiesView: View {
if let credentials = item.credentials {
_loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username)
_password = State(initialValue: credentials.password)
let storedPassword = KeychainCredentialStore.password(for: item.id) ?? credentials.password
_password = State(initialValue: storedPassword)
} else {
_loginMode = State(initialValue: .matching)
_username = State(initialValue: "")
@@ -80,39 +83,87 @@ struct DownloadPropertiesView: View {
var body: some View {
VStack(spacing: 0) {
DownloadSummaryHeader(item: item)
.padding(.horizontal, 18)
.padding(.vertical, 12)
Divider()
Form {
Section("Download") {
TextField("URL", text: $urlText)
.font(.system(.body, design: .monospaced))
TextField("File name", text: $fileName)
HStack {
TextField("Save location", text: $destinationPath)
.font(.system(.body, design: .monospaced))
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
Toggle("Limit speed", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
if let noticeText {
Section {
Label(noticeText, systemImage: noticeSystemImage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Site Login") {
Section("Download") {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("URL")
.foregroundStyle(.secondary)
TextField("URL", text: $urlText)
.font(.system(.callout, design: .monospaced))
.disabled(fileIdentityLocked)
}
GridRow {
Text("File name")
.foregroundStyle(.secondary)
TextField("File name", text: $fileName)
.disabled(fileIdentityLocked)
}
GridRow {
Text("Save location")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
TextField("Save location", text: $destinationPath)
.font(.system(.callout, design: .monospaced))
.disabled(fileIdentityLocked)
Button {
selectDestination()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
.disabled(fileIdentityLocked)
}
}
GridRow {
Text("Connections")
.foregroundStyle(.secondary)
Stepper("\(connections) per file", value: $connections, in: 1...16)
.disabled(transferSettingsLocked)
}
GridRow {
Text("Speed")
.foregroundStyle(.secondary)
HStack {
Toggle("Limit", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"\(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
}
}
}
Section(item.status == .completed ? "Site Login for Redownload" : "Site Login") {
Picker("Login", selection: $loginMode) {
ForEach(LoginMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.disabled(transferSettingsLocked)
if loginMode == .matching {
Text(matchingLoginText)
@@ -120,48 +171,64 @@ struct DownloadPropertiesView: View {
.foregroundStyle(.secondary)
} else if loginMode == .custom {
TextField("Username", text: $username)
.disabled(transferSettingsLocked)
SecureField("Password", text: $password)
.disabled(transferSettingsLocked)
}
}
Section("Advanced Transfer") {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
Section {
CollapsibleGroup(title: advancedTransferTitle, isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("Checksum")
.foregroundStyle(.secondary)
Toggle("Verify", isOn: $checksumEnabled)
.disabled(transferSettingsLocked)
}
if checksumEnabled {
GridRow {
Text("Algorithm")
.foregroundStyle(.secondary)
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
.disabled(transferSettingsLocked)
}
GridRow {
Text("Digest")
.foregroundStyle(.secondary)
TextField("Expected digest", text: $checksumValue)
.font(.system(.callout, design: .monospaced))
.disabled(transferSettingsLocked)
}
}
GridRow {
Text("Cookies")
.foregroundStyle(.secondary)
TextField("Cookies", text: $cookieText)
.font(.system(.callout, design: .monospaced))
.disabled(transferSettingsLocked)
}
}
TextField("Expected digest", text: $checksumValue)
.font(.system(.body, design: .monospaced))
CompactEditor(title: "Headers", text: $headerText)
.disabled(transferSettingsLocked)
CompactEditor(title: "Mirrors", text: $mirrorText)
.disabled(transferSettingsLocked)
}
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
TextField("Cookies", text: $cookieText)
.font(.system(.body, design: .monospaced))
VStack(alignment: .leading, spacing: 6) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
}
Section("Progress") {
ProgressView(value: item.progress)
InfoGrid(item: item)
}
if item.status == .downloading && item.rpcPort != nil {
Section("Chunk Map") {
ChunkMapView(item: item)
Section {
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
ChunkMapView(item: item)
}
}
}
}
@@ -184,10 +251,82 @@ struct DownloadPropertiesView: View {
}
.buttonStyle(.borderedProminent)
}
.padding(14)
.padding(12)
.background(.bar)
}
.frame(width: 620, height: 760)
.frame(width: 720, height: 580)
}
private var fileIdentityLocked: Bool {
item.status == .completed || item.status == .downloading
}
private var transferSettingsLocked: Bool {
item.status == .downloading
}
private var noticeText: String? {
switch item.status {
case .completed:
return "File identity is read-only. Transfer settings are saved for redownload."
case .downloading:
return "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."
default:
return nil
}
}
private var noticeSystemImage: String {
item.status == .completed ? "checkmark.circle" : "bolt.horizontal.circle"
}
private var advancedTransferTitle: String {
item.status == .completed ? "Advanced Transfer for Redownload" : "Advanced Transfer"
}
private struct CompactEditor: View {
let title: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
.foregroundStyle(.secondary)
TextEditor(text: $text)
.font(.system(.callout, design: .monospaced))
.frame(minHeight: 44, maxHeight: 54)
}
}
}
private struct CollapsibleGroup<Content: View>: View {
let title: String
@Binding var isExpanded: Bool
@ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: isExpanded ? 10 : 0) {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 12)
Text(title)
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if isExpanded {
content()
.padding(.leading, 18)
}
}
}
}
private var matchingLoginText: String {
@@ -220,7 +359,7 @@ struct DownloadPropertiesView: View {
return
}
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanFileName = FileClassifier.sanitizedFileName(fileName)
guard !cleanFileName.isEmpty else {
errorMessage = "File name cannot be empty."
return
@@ -284,31 +423,69 @@ struct DownloadPropertiesView: View {
}
}
private struct InfoGrid: View {
private struct DownloadSummaryHeader: View {
let item: DownloadItem
var body: some View {
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 8) {
info("Status", item.status.rawValue)
info("Progress", item.progress.formatted(.percent.precision(.fractionLength(0))))
info("Size", ByteFormatter.string(item.sizeBytes))
info("Speed", item.speedText)
info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)")
info("Speed cap", item.speedLimitText)
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue)
info("Destination", item.destinationPath)
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 12) {
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Label(item.status.rawValue, systemImage: item.category.symbolName)
.foregroundStyle(statusColor)
}
ProgressView(value: item.status == .completed ? 1.0 : item.progress)
Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 5) {
GridRow {
summary("Progress", (item.status == .completed ? 1.0 : item.progress).formatted(.percent.precision(.fractionLength(0))))
summary("Size", ByteFormatter.string(item.sizeBytes))
summary("Speed", item.displaySpeedText)
summary("ETA", item.displayETAText)
}
GridRow {
summary("Live connections", "\(item.connectionCount)")
summary("Speed cap", item.speedLimitText)
summary("Category", item.category.rawValue)
summary("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
}
GridRow {
summary("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
.gridCellColumns(2)
summary("Destination", item.destinationPath)
.gridCellColumns(2)
}
}
.font(.caption)
}
}
private func info(_ label: String, _ value: String) -> some View {
GridRow {
private var statusColor: Color {
switch item.status {
case .queued:
.secondary
case .downloading:
.accentColor
case .paused:
.orange
case .completed:
.green
case .failed, .canceled:
.red
}
}
private func summary(_ label: String, _ value: String) -> some View {
HStack(spacing: 4) {
Text(label)
.foregroundStyle(.secondary)
Text(value)
.lineLimit(2)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
+210 -113
View File
@@ -13,105 +13,131 @@ struct DownloadTable: View {
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
var sortedItems: [DownloadItem] {
items.sorted(using: sortOrder)
}
@State private var sortedItems: [DownloadItem] = []
var body: some View {
VStack(spacing: 0) {
HStack {
Text(title)
.font(.headline)
.font(.title2)
.fontWeight(.semibold)
Text("\(items.count)")
.font(.caption)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 5))
.clipShape(Capsule())
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
TableColumn("File Name", value: \.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}
.width(min: 80, ideal: 100)
TableColumn("Status", value: \.status.rawValue) { item in
combinedStatusCell(for: item)
}
.width(min: 160, ideal: 200)
TableColumn("Speed", value: \.displaySpeedText) { item in
if item.status == .downloading {
formattedSpeedCell(for: item.displaySpeedText)
} else {
Text("-")
.foregroundStyle(.tertiary)
}
}
.width(min: 80, ideal: 100)
TableColumn("ETA", value: \.displayETAText) { item in
if item.status == .downloading {
formattedETACell(for: item.displayETAText)
} else {
Text("-")
.foregroundStyle(.tertiary)
}
}
.width(min: 80, ideal: 100)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 100, ideal: 155)
}
.width(min: 200, ideal: 340)
TableColumn("Size", value: \.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 80, ideal: 100)
TableColumn("Progress", value: \.progress) { item in
progressBarCell(for: item)
}
.width(min: 100, ideal: 115)
TableColumn("Status", value: \.status.rawValue) { item in
Text(item.status.rawValue)
}
.width(min: 80, ideal: 105)
TableColumn("Speed", value: \.speedText) { item in
Text(item.speedText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("ETA", value: \.etaText) { item in
Text(item.etaText)
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 70, ideal: 90)
TableColumn("Date Added", value: \.createdAt) { item in
Text(formatted(item.createdAt))
.lineLimit(1)
.truncationMode(.tail)
}
.width(min: 100, ideal: 155)
}
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
for itemID in itemIDs {
if let item = controller.downloads.first(where: { $0.id == itemID }) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.animation(.default, value: sortedItems)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
} primaryAction: { itemIDs in
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
for target in targetItems {
if target.status == .completed {
openFile(target)
}
}
}
}
.overlay {
if items.isEmpty {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
.onAppear { sortedItems = items.sorted(using: sortOrder) }
.onChange(of: items) { _, newItems in
let existingIDs = Set(sortedItems.map(\.id))
let newIDs = Set(newItems.map(\.id))
if existingIDs != newIDs {
sortedItems = newItems.sorted(using: sortOrder)
} else {
let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
sortedItems = sortedItems.compactMap { itemsDict[$0.id] }
}
}
.onChange(of: sortOrder) { _, newOrder in
sortedItems = items.sorted(using: newOrder)
}
.confirmationDialog(
"Delete Download",
isPresented: Binding(
@@ -151,6 +177,82 @@ struct DownloadTable: View {
}
}
@ViewBuilder
private func combinedStatusCell(for item: DownloadItem) -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.fontWeight(.medium)
} else {
HStack(spacing: 8) {
ProgressView(value: item.progress)
.progressViewStyle(.linear)
.tint(statusColor(for: item.status))
if item.status == .downloading {
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.secondary)
.frame(width: 35, alignment: .trailing)
} else {
Text(item.status.rawValue.capitalized)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
}
}
}
private func parseSpeed(_ text: String) -> [String] {
var display = text
if let index = display.firstIndex(where: { $0.isLetter }) {
if display.distance(from: display.startIndex, to: index) > 0 {
let prevIndex = display.index(before: index)
if display[prevIndex] != " " {
display.insert(" ", at: index)
}
}
}
return display.split(separator: " ", maxSplits: 1).map(String.init)
}
@ViewBuilder
private func formattedSpeedCell(for text: String) -> some View {
let components = parseSpeed(text)
if components.count == 2 {
HStack(alignment: .firstTextBaseline, spacing: 1) {
Text(components[0])
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
Text(components[1])
.font(.system(size: 10, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
.lineLimit(1)
.truncationMode(.tail)
} else {
Text(components.joined(separator: " "))
.font(.system(size: 13, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
@ViewBuilder
private func formattedETACell(for text: String) -> some View {
Text(text)
.font(.system(size: 13, weight: .medium, design: .rounded))
.monospacedDigit()
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
@ViewBuilder
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
@@ -171,7 +273,7 @@ struct DownloadTable: View {
showInFinder(target)
}
} label: {
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "magnifyingglass")
}
Divider()
@@ -182,7 +284,7 @@ struct DownloadTable: View {
controller.resume(target)
}
} label: {
Label("Start", systemImage: "play.fill")
Label("Resume", systemImage: "play.fill")
}
}
@@ -192,7 +294,7 @@ struct DownloadTable: View {
controller.pause(target)
}
} label: {
Label("Stop", systemImage: "stop.fill")
Label("Pause", systemImage: "pause.fill")
}
}
@@ -232,12 +334,16 @@ struct DownloadTable: View {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(value: target.id)
if targetItems.allSatisfy({ $0.status == .completed }) {
Button {
NSPasteboard.general.clearContents()
let paths = targetItems.map { $0.destinationPath }.joined(separator: "\n")
if !paths.isEmpty {
NSPasteboard.general.setString(paths, forType: .string)
}
} label: {
Label(targetItems.count > 1 ? "Copy File Paths" : "Copy File Path", systemImage: "doc.on.doc")
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
@@ -245,7 +351,23 @@ struct DownloadTable: View {
Button(role: .destructive) {
pendingDeleteItems = itemIDs
} label: {
Label("Remove", systemImage: "trash")
Label("Remove from List", systemImage: "trash")
}
Divider()
Button {
for target in targetItems {
openWindow(id: "download-properties", value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
} else {
Button {
openWindow(id: "add-downloads")
} label: {
Label("Add Download", systemImage: "plus")
}
}
}
@@ -272,32 +394,7 @@ struct DownloadTable: View {
}
}
@ViewBuilder
private func progressBarCell(for item: DownloadItem) -> some View {
if item.status == .completed {
Text("Completed")
.foregroundStyle(.green)
.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)
}
}
.frame(height: 16)
}
}
private func formatted(_ date: Date?) -> String {
guard let date else { return "-" }
@@ -0,0 +1,87 @@
import SwiftUI
struct DuplicateDownloadItem: Identifiable, Equatable {
let id: UUID
var pendingItem: PendingDownload
var resolution: DuplicateResolutionAction = .rename
let reason: DuplicateReason
enum DuplicateReason: Equatable {
case existingURL(String)
case existingFile(String)
}
}
enum DuplicateResolutionAction: String, CaseIterable, Identifiable {
case rename = "Rename"
case replace = "Replace"
case skip = "Skip"
var id: String { rawValue }
}
struct DuplicateResolutionView: View {
@Binding var conflicts: [DuplicateDownloadItem]
let onConfirm: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
Text("Duplicate Downloads Detected")
.font(.headline)
Text("Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
List($conflicts) { $conflict in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(conflict.pendingItem.fileName)
.lineLimit(1)
.font(.body.weight(.medium))
Text(reasonText(for: conflict.reason))
.font(.caption)
.foregroundStyle(.orange)
}
Spacer()
Picker("", selection: $conflict.resolution) {
ForEach(DuplicateResolutionAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.frame(width: 100)
}
.padding(.vertical, 4)
}
.frame(minHeight: 200)
Divider()
HStack {
Button("Cancel", action: onCancel)
.keyboardShortcut(.cancelAction)
Spacer()
Button("Continue", action: onConfirm)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
}
.padding(16)
}
.frame(width: 500)
}
private func reasonText(for reason: DuplicateDownloadItem.DuplicateReason) -> String {
switch reason {
case .existingURL(let msg): return msg
case .existingFile(let msg): return msg
}
}
}
+22 -2
View File
@@ -57,10 +57,30 @@ enum FileClassifier {
static func fileName(from url: URL) -> String {
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
if !pathName.isEmpty, pathName != "/" {
return pathName
return sanitizedFileName(pathName)
}
let host = url.host(percentEncoded: false) ?? "download"
return "\(host)-download"
return sanitizedFileName("\(host)-download")
}
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
let separators = CharacterSet(charactersIn: "/\\")
let invalidCharacters = separators
.union(.newlines)
.union(.controlCharacters)
let components = rawValue
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: invalidCharacters)
.filter { !$0.isEmpty }
let clean = components.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !clean.isEmpty, clean != ".", clean != ".." else {
return fallback
}
return String(clean.prefix(255))
}
}
+62 -10
View File
@@ -2,23 +2,29 @@ import SwiftUI
@main
struct FirelinkApp: App {
@StateObject private var updateChecker: ReleaseUpdateChecker
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
@State private var lastURLSchemeInvocation: Date = .distantPast
@State private var urlSchemeInvocationCount: Int = 0
// Server must be retained to keep listening
private let extensionServer: LocalExtensionServer?
init() {
self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
extensionServer?.start()
controller.extensionServerPort = extensionServer?.port
}
var body: some Scene {
@@ -27,17 +33,48 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.environmentObject(updateChecker)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.task {
updateChecker.checkAutomaticallyIfNeeded()
}
.onOpenURL { url in
controller.pendingPasteboardText = url.absoluteString
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
let now = Date()
if now.timeIntervalSince(lastURLSchemeInvocation) > 5 {
urlSchemeInvocationCount = 0
}
guard urlSchemeInvocationCount < 3 else { return }
urlSchemeInvocationCount += 1
lastURLSchemeInvocation = now
if url.scheme == "firelink" {
if url.host == "add",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems,
let link = queryItems.first(where: { $0.name == "url" })?.value,
link.count < 65536 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = link
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
return
}
guard url.absoluteString.count < 65536 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
controller.pendingPasteboardText = url.absoluteString
controller.pendingReferer = nil
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
}
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
WindowGroup("Add Downloads", id: "add-downloads") {
Window("Add Downloads", id: "add-downloads") {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
@@ -46,7 +83,7 @@ struct FirelinkApp: App {
}
.windowResizability(.contentSize)
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
if let downloadID {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
@@ -67,7 +104,7 @@ struct FirelinkApp: App {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
}
.keyboardShortcut("n", modifiers: [.command])
Divider()
Button("Start Queue") {
@@ -87,9 +124,9 @@ struct FirelinkApp: App {
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
guard let url = menuBarIconURL(),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.size = NSSize(width: 23, height: 23)
img.isTemplate = true
return img
}() {
@@ -99,4 +136,19 @@ struct FirelinkApp: App {
}
}
}
private func menuBarIconURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeIcon = projectRoot
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
}
}
+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
}
+92 -19
View File
@@ -1,10 +1,12 @@
import Foundation
import Network
import AppKit
import Combine
import CryptoKit
final class LocalExtensionServer: @unchecked Sendable {
private enum Constants {
static let port = NWEndpoint.Port(rawValue: 6412)!
static let portRange = 6412...6422
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
@@ -12,29 +14,50 @@ final class LocalExtensionServer: @unchecked Sendable {
private let listener: NWListener
private let downloadController: DownloadController
private let settings: AppSettings
private let queue = DispatchQueue(label: "local.firelink.server")
let port: UInt16
private let tokenLock = NSLock()
private var _pairingToken: String = ""
private var cancellables = Set<AnyCancellable>()
init?(downloadController: DownloadController) {
@MainActor
init?(downloadController: DownloadController, settings: AppSettings) {
self.downloadController = downloadController
self.settings = settings
let parameters = NWParameters.tcp
var createdListener: NWListener?
for portValue in 6412...6422 {
var selectedPort: UInt16?
for portValue in Constants.portRange {
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
do {
createdListener = try NWListener(using: parameters)
selectedPort = UInt16(portValue)
break
} catch {
continue
}
}
guard let createdListener else {
print("Failed to create listener on ports 6412-6422")
return nil
}
self.listener = createdListener
self.port = selectedPort ?? 6412
settings.$extensionPairingToken
.sink { [weak self] token in
self?.tokenLock.withLock { self?._pairingToken = token }
}
.store(in: &cancellables)
}
private var currentPairingToken: String {
tokenLock.withLock { _pairingToken }
}
func start() {
@@ -49,12 +72,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
}
@@ -65,22 +93,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)
}
}
@@ -94,8 +125,8 @@ 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-Headers: Content-Type")
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Signature, X-Firelink-Timestamp")
}
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
@@ -114,12 +145,8 @@ 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.hasPrefix("127.0.0.1:") || host.hasPrefix("localhost:") || host == "127.0.0.1" || host == "localhost"
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
guard isLocalhost else {
return .forbidden
}
@@ -128,6 +155,41 @@ final class LocalExtensionServer: @unchecked Sendable {
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
}
let expectedToken = currentPairingToken
guard !expectedToken.isEmpty else {
return .forbidden
}
let bodyString = String(data: request.body, encoding: .utf8) ?? ""
guard let signatureHex = request.header(named: "x-firelink-signature"),
let timestampStr = request.header(named: "x-firelink-timestamp"),
let timestamp = TimeInterval(timestampStr) else {
return .forbidden
}
let now = Date().timeIntervalSince1970 * 1000
guard abs(now - timestamp) < 60000 else {
return .forbidden
}
let keyData = expectedToken.data(using: .utf8) ?? Data()
let key = SymmetricKey(data: keyData)
let messageData = (timestampStr + bodyString).data(using: .utf8) ?? Data()
let hmac = HMAC<SHA256>.authenticationCode(for: messageData, using: key)
let expectedSignature = hmac.map { String(format: "%02x", $0) }.joined()
guard signatureHex.lowercased() == expectedSignature 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
}
@@ -139,6 +201,8 @@ final class LocalExtensionServer: @unchecked Sendable {
struct Payload: Decodable {
let urls: [String]
let referer: String?
let silent: Bool?
let filename: String?
}
do {
@@ -160,9 +224,18 @@ final class LocalExtensionServer: @unchecked Sendable {
}
Task { @MainActor in
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
let shouldAsk = self.settings.askWhereToSaveEachFile || payload.silent != true
if shouldAsk {
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
self.downloadController.pendingReferer = payload.referer
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
NSApp.activate(ignoringOtherApps: true)
} else {
for validURL in validURLs {
self.downloadController.add(urlText: validURL, filename: payload.filename)
}
self.downloadController.startQueue()
}
}
return .ok
+28
View File
@@ -0,0 +1,28 @@
import Foundation
enum MediaDetector {
private static let supportedDomains: Set<String> = [
"youtube.com", "youtu.be",
"twitter.com", "x.com",
"vimeo.com",
"twitch.tv",
"instagram.com",
"tiktok.com",
"facebook.com", "fb.watch",
"reddit.com", "v.redd.it",
"soundcloud.com"
]
static func isSupportedMedia(url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
for domain in supportedDomains {
if host == domain || host.hasSuffix(".\(domain)") {
// Ignore raw files that happen to be hosted on these domains, if any,
// though usually these domains serve web pages for media.
return true
}
}
return false
}
}
+391
View File
@@ -0,0 +1,391 @@
import Foundation
final class MediaDownloadEngine: @unchecked Sendable {
struct Handle {
let cancel: @Sendable () -> Void
}
enum EngineError: LocalizedError {
case missingEngine(String)
case launchFailed(String)
var errorDescription: String? {
switch self {
case .missingEngine(let msg): return msg
case .launchFailed(let msg): return msg
}
}
}
func start(
item: DownloadItem,
cookieSource: BrowserCookieSource,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void,
completion: @escaping @Sendable (Result<URL, Error>) -> Void
) async throws -> Handle {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
}
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
let process = Process()
process.executableURL = ytDlpURL
var arguments = [
"--newline",
"--ffmpeg-location", ffmpegURL.path,
"--no-check-formats",
"--fragment-retries", "10",
"--retry-sleep", "0",
"--skip-unavailable-fragments",
"--extractor-args", "youtube:player_client=tv,web",
"--extractor-args", "youtube:skip=webpage",
"--compat-options", "no-youtube-unavailable-videos",
"-o", item.destinationPath
]
if let format = item.mediaFormatSelector {
arguments.append("-f")
arguments.append(format)
if item.isAudioOnlyMedia == true {
let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
} else {
let mergeFormat = item.fileName.fileExtension(defaultValue: "mp4")
arguments.append(contentsOf: ["--merge-output-format", mergeFormat])
}
}
let tempConfigDir = MediaExtractionEngine.appendCommonArguments(
to: &arguments,
cookieSource: cookieSource,
credentials: item.credentials,
transferOptions: item.transferOptions
)
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
arguments.append(contentsOf: ["--proxy", proxyURI])
}
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append(contentsOf: ["--limit-rate", "\(speedLimitKiBPerSecond)K"])
}
appendParallelDownloadArguments(to: &arguments, connectionsPerServer: item.connectionsPerServer)
arguments.append(item.url.absoluteString)
process.arguments = arguments
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer()
let outputPathTracker = YTDLPOutputPathTracker()
let completionGate = CompletionGate(completion)
let outputHandler = YTDLPOutputHandler(
parser: parser,
outputPathTracker: outputPathTracker,
progress: progress,
messageUpdate: messageUpdate
)
let readGroup = DispatchGroup()
readGroup.enter()
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
readGroup.leave()
} else if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
readGroup.enter()
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
readGroup.leave()
} else {
errorBuffer.append(data)
if let text = String(data: data, encoding: .utf8) {
outputHandler.handle(text)
}
}
}
process.terminationHandler = { finishedProcess in
if let tempConfigDir {
try? FileManager.default.removeItem(at: tempConfigDir)
}
readGroup.notify(queue: .global()) {
if finishedProcess.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))))
}
}
}
var didRun = false
defer {
if !didRun, let tempConfigDir {
try? FileManager.default.removeItem(at: tempConfigDir)
}
}
try process.run()
didRun = true
messageUpdate("Fetching media data...")
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
return Handle(cancel: {
if process.isRunning {
process.terminate()
}
})
}
private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
let expectedURL = URL(fileURLWithPath: item.destinationPath)
if FileManager.default.fileExists(atPath: expectedURL.path) {
return expectedURL
}
if let observedURL = tracker.lastExistingOutputURL {
return observedURL
}
let baseName = expectedURL.deletingPathExtension().lastPathComponent
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 mostRecent ?? expectedURL
}
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
guard !message.isEmpty else {
return "Exit code \(status)"
}
if message.localizedCaseInsensitiveContains("Sign in to confirm") ||
message.localizedCaseInsensitiveContains("not a bot") ||
message.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
return "YouTube requires browser cookies for this video. Choose a browser in Settings > Engine, then retry."
}
if message.localizedCaseInsensitiveContains("n challenge solving failed") ||
message.localizedCaseInsensitiveContains("supported JavaScript runtime") {
return "YouTube challenge solving failed. Install Deno or Node, then retry."
}
return message
}
private func appendParallelDownloadArguments(to arguments: inout [String], connectionsPerServer: Int) {
let connections = min(max(connectionsPerServer, 1), 16)
guard connections > 1 else { return }
arguments.append(contentsOf: ["--concurrent-fragments", "\(connections)"])
// Use yt-dlp's native concurrent downloader instead of aria2c to ensure progress parsing works via stdout
}
}
final class YTDLPOutputHandler: @unchecked Sendable {
private let parser: YTDLPProgressParser
private let outputPathTracker: YTDLPOutputPathTracker
private let progress: @Sendable (DownloadProgress) -> Void
private let messageUpdate: @Sendable (String) -> Void
init(
parser: YTDLPProgressParser,
outputPathTracker: YTDLPOutputPathTracker,
progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void
) {
self.parser = parser
self.outputPathTracker = outputPathTracker
self.progress = progress
self.messageUpdate = messageUpdate
}
func handle(_ text: String) {
for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line)
outputPathTracker.observe(stringLine)
if let update = parser.parse(stringLine) {
progress(update)
messageUpdate("Downloading Media")
} else if let message = statusMessage(for: stringLine) {
messageUpdate(message)
}
}
}
private func statusMessage(for line: String) -> String? {
if line.contains("[Merger]") || line.contains("[ExtractAudio]") || line.contains("[Fixup") {
return "Processing Media..."
}
if line.contains("[youtube]") && line.localizedCaseInsensitiveContains("Downloading") {
return "Fetching YouTube data..."
}
if line.contains("[info]") && line.localizedCaseInsensitiveContains("Downloading") {
return "Preparing media stream..."
}
if line.localizedCaseInsensitiveContains("Sign in to confirm") ||
line.localizedCaseInsensitiveContains("not a bot") ||
line.localizedCaseInsensitiveContains("Use --cookies-from-browser") {
return "YouTube requires browser cookies"
}
if line.localizedCaseInsensitiveContains("n challenge solving failed") ||
line.localizedCaseInsensitiveContains("supported JavaScript runtime") {
return "YouTube challenge solver unavailable"
}
if line.contains("Destination:") {
return "Starting media download..."
}
return nil
}
}
final class YTDLPOutputPathTracker: @unchecked Sendable {
private let lock = NSLock()
private var observedPaths: [String] = []
private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
var lastExistingOutputURL: URL? {
lock.withLock {
observedPaths
.reversed()
.map { URL(fileURLWithPath: $0) }
.first { FileManager.default.fileExists(atPath: $0.path) }
}
}
func observe(_ line: String) {
let candidates = pathCandidates(from: line)
guard !candidates.isEmpty else { return }
lock.withLock {
for candidate in candidates where !observedPaths.contains(candidate) {
observedPaths.append(candidate)
}
}
}
private func pathCandidates(from line: String) -> [String] {
var paths: [String] = []
if line.contains("Destination:"),
let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
destination.hasPrefix("/") {
paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
}
for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
paths.append(quoted)
}
return paths
}
private func quotedCaptures(in text: String) -> [String] {
guard let quotedPathRegex else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return quotedPathRegex.matches(in: text, range: range).compactMap { match in
guard match.numberOfRanges > 1,
let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
}
private extension String {
func fileExtension(defaultValue: String) -> String {
let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return ext.isEmpty ? defaultValue : ext
}
}
final class YTDLPProgressParser: @unchecked Sendable {
private let percentageRegex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)%"#)
private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
func parse(_ line: String) -> DownloadProgress? {
if line.contains("[download]") && line.contains("%") {
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: 1
)
} else if line.contains("[#") && line.contains("DL:") {
let fraction = (Double(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"\(([\d.]+)%\)"#)) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"DL:([^\s\]]+)"#)) ?? "-"
let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
return DownloadProgress(
fraction: min(max(fraction, 0), 1),
bytesText: size,
speedText: speed,
etaText: eta,
connectionCount: cn
)
}
return nil
}
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
guard let regex else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, range: range), match.numberOfRanges > 1 else {
return nil
}
guard let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
+108
View File
@@ -0,0 +1,108 @@
import Foundation
import Combine
enum AddonState: Equatable, Sendable {
case notInstalled
case installed(version: String)
case failed(error: String)
}
enum AddonType: String, CaseIterable, Sendable {
case ytDlp = "yt-dlp"
case ffmpeg
var binaryName: String {
switch self {
case .ytDlp: return "yt-dlp"
case .ffmpeg: return "ffmpeg"
}
}
}
@MainActor
final class MediaEngineManager: ObservableObject {
static let shared = MediaEngineManager()
@Published var ytDlpState: AddonState = .notInstalled
@Published var ffmpegState: AddonState = .notInstalled
private init() {
checkLocalInstallation()
Task.detached { [weak self] in
await self?.prewarmYtDlp()
}
}
nonisolated private func prewarmYtDlp() async {
guard let path = await MainActor.run(body: { binaryPath(for: .ytDlp) }) else { return }
let process = Process()
process.executableURL = path
process.arguments = ["--version"]
process.standardOutput = nil
process.standardError = nil
try? process.run()
process.waitUntilExit()
}
func binaryPath(for addon: AddonType) -> URL? {
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
#if SWIFT_PACKAGE
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
#endif
}
return nil
}
func checkLocalInstallation() {
for addon in AddonType.allCases {
if binaryPath(for: addon) != nil {
setState(for: addon, to: .installed(version: "Bundled"))
} else {
setState(for: addon, to: .notInstalled)
}
}
}
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) {
case .installed:
return false
case .notInstalled, .failed:
return true
}
}
guard !missingAddons.isEmpty else { return }
for missing in missingAddons {
setState(for: missing, to: .failed(error: "Bundled executable missing"))
}
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
}
private func state(for addon: AddonType) -> AddonState {
switch addon {
case .ytDlp: return ytDlpState
case .ffmpeg: return ffmpegState
}
}
private func setState(for addon: AddonType, to state: AddonState) {
switch addon {
case .ytDlp: ytDlpState = state
case .ffmpeg: ffmpegState = state
}
}
}
@@ -0,0 +1,528 @@
import Foundation
struct RawMediaFormat: Decodable, Sendable, Equatable {
let format_id: String?
let ext: String?
let resolution: String?
let format_note: String?
let vcodec: String?
let acodec: String?
let height: Int?
let filesize: Int64?
let filesize_approx: Int64?
}
struct MediaMetadata: Decodable, Sendable, Equatable {
let id: String?
let title: String?
let uploader: String?
let channel: String?
let thumbnail: URL?
let duration: Double?
let formats: [RawMediaFormat]?
var displayUploader: String? {
channel ?? uploader
}
}
enum MediaType: String, Sendable, Equatable {
case video = "Video"
case audio = "Audio"
}
struct CleanFormatOption: Identifiable, Equatable, Sendable {
var id: String { name }
let name: String
let formatSelector: String
let isAudioOnly: Bool
let symbol: String
let outputExtension: String
let detail: String
let estimatedBytes: Int64?
let mediaType: MediaType
let qualityName: String
let containerName: String
}
enum MediaExtractionEngine {
private final class CacheEntry {
let metadata: MediaMetadata
let options: [CleanFormatOption]
let date: Date
init(metadata: MediaMetadata, options: [CleanFormatOption], date: Date) {
self.metadata = metadata
self.options = options
self.date = date
}
}
nonisolated(unsafe) private static let metadataCache = NSCache<NSURL, CacheEntry>()
private static let metadataTimeoutSeconds: UInt64 = 120
enum ExtractionError: Error, LocalizedError {
case processFailed(String)
case invalidOutput
case parsingFailed(Error)
case timedOut
var errorDescription: String? {
switch self {
case .processFailed(let msg): return "Extraction failed: \(msg)"
case .invalidOutput: return "Invalid output from media engine."
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
}
}
}
static func fetchMetadata(
for url: URL,
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions,
proxyConfiguration: DownloadProxyConfiguration
) async throws -> (MediaMetadata, [CleanFormatOption]) {
if let cached = metadataCache.object(forKey: url as NSURL), Date().timeIntervalSince(cached.date) < 300 {
return (cached.metadata, cached.options)
}
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw ExtractionError.processFailed("yt-dlp binary not found.")
}
let ytDlpPath = ytDlpURL.path
var args = [
"-J",
"--no-warnings",
"--ignore-no-formats-error",
"--no-playlist",
"--no-check-formats",
"--extractor-args", "youtube:player_client=tv,web",
"--extractor-args", "youtube:skip=webpage",
"--compat-options", "no-youtube-unavailable-videos"
]
if let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg),
FileManager.default.isExecutableFile(atPath: ffmpegURL.path) {
args.append(contentsOf: ["--ffmpeg-location", ffmpegURL.path])
}
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
args.append(contentsOf: ["--proxy", proxyURI])
}
let tempConfigDir = appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
defer {
if let tempConfigDir {
try? FileManager.default.removeItem(at: tempConfigDir)
}
}
args.append(url.absoluteString)
let data = try await YTDLPMetadataProcess(
executableURL: URL(fileURLWithPath: ytDlpPath),
arguments: args
).run(timeoutSeconds: metadataTimeoutSeconds)
guard !data.isEmpty else {
throw ExtractionError.invalidOutput
}
do {
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: data)
let options = extractOptions(from: metadata)
metadataCache.setObject(CacheEntry(metadata: metadata, options: options, date: Date()), forKey: url as NSURL)
return (metadata, options)
} catch {
throw ExtractionError.parsingFailed(error)
}
}
static func appendCommonArguments(
to args: inout [String],
cookieSource: BrowserCookieSource,
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions
) -> URL? {
if let browserName = cookieSource.ytDlpBrowserName {
args.append(contentsOf: ["--cookies-from-browser", browserName])
}
appendJavaScriptRuntimeArguments(to: &args)
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
args.append(contentsOf: ["--add-header", header.headerLine])
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines),
!cookieHeader.isEmpty {
args.append(contentsOf: ["--add-header", "Cookie: \(cookieHeader)"])
}
var tempConfigDir: URL?
if let credentials, !credentials.isEmpty {
let configContent = "--username \"\(credentials.username)\"\n--password \"\(credentials.password)\"\n"
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-yt-dlp-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
let fileURL = tempDir.appendingPathComponent("yt-dlp.conf")
try? configContent.write(to: fileURL, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
args.append(contentsOf: ["--config-locations", fileURL.path])
tempConfigDir = tempDir
}
return tempConfigDir
}
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"
]) {
runtimes.append("deno:\(denoPath)")
}
if let nodePath = executablePath(named: "node", candidates: [
"/opt/homebrew/bin/node",
"/usr/local/bin/node",
"/usr/bin/node"
]) {
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? {
var safeCandidates = candidates
safeCandidates.append(contentsOf: [
"/opt/homebrew/bin/\(name)",
"/usr/local/bin/\(name)",
"/usr/bin/\(name)",
"/opt/local/bin/\(name)"
])
for candidate in safeCandidates {
if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
private static func extractOptions(from metadata: MediaMetadata) -> [CleanFormatOption] {
var options: [CleanFormatOption] = []
let rawFormats = metadata.formats ?? []
let standardResolutions = [
(2160, "4K"),
(1440, "1440p"),
(1080, "1080p"),
(720, "720p"),
(480, "480p"),
(360, "360p")
]
let availableResolutions = standardResolutions.filter { resolution, _ in
rawFormats.contains { format in
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
}
}
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
let videoContainers = [
("mp4", "MP4"),
("mkv", "MKV"),
("webm", "WebM")
]
for (height, qualityName) in videoQualities {
for (container, containerName) in videoContainers {
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
options.append(CleanFormatOption(
name: "\(qualityName) \(containerName)",
formatSelector: videoSelector(height: height, container: container),
isAudioOnly: false,
symbol: "play.tv.fill",
outputExtension: container,
detail: optionDetail(
base: height == nil ? "Best available video" : "Up to \(qualityName)",
estimatedBytes: estimatedBytes
),
estimatedBytes: estimatedBytes,
mediaType: .video,
qualityName: qualityName,
containerName: containerName
))
}
}
if hasAudioFormat(rawFormats, preferredExtension: nil) {
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
options.append(CleanFormatOption(
name: "Audio MP3",
formatSelector: "bestaudio/best",
isAudioOnly: true,
symbol: "music.note",
outputExtension: "mp3",
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes,
mediaType: .audio,
qualityName: "Best",
containerName: "MP3"
))
}
if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
options.append(CleanFormatOption(
name: "Audio M4A",
formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "m4a",
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes,
mediaType: .audio,
qualityName: "Best",
containerName: "M4A"
))
}
if hasAudioFormat(rawFormats, preferredExtension: "webm") {
let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
options.append(CleanFormatOption(
name: "Audio Opus",
formatSelector: "bestaudio[ext=webm]/bestaudio/best",
isAudioOnly: true,
symbol: "waveform",
outputExtension: "opus",
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes,
mediaType: .audio,
qualityName: "Best",
containerName: "Opus"
))
}
return options
}
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
formats.contains { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
}
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
formats.contains { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
}
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
let videoBytes = formats
.filter { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
guard let videoBytes else { return nil }
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
estimatedAudioBytes(formats, preferredExtension: nil) ??
0
return videoBytes + audioBytes
}
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
let preferred = formats
.filter { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
if preferred != nil || preferredExtension == nil {
return preferred
}
return estimatedAudioBytes(formats, preferredExtension: nil)
}
private static func isVideo(_ format: RawMediaFormat) -> Bool {
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
return true
}
private static func isAudio(_ format: RawMediaFormat) -> Bool {
let acodec = format.acodec?.lowercased()
let vcodec = format.vcodec?.lowercased()
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
}
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
guard let height else { return true }
guard let formatHeight = format.height else { return false }
return formatHeight <= height && formatHeight >= height - 100
}
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
format.filesize ?? format.filesize_approx
}
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
guard let estimatedBytes, estimatedBytes > 0 else { return base }
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
}
private static func videoSelector(height: Int?, container: String) -> String {
let filter = heightFilter(height)
switch container {
case "mp4":
return "bestvideo\(filter)[ext=mp4]+bestaudio[ext=m4a]/best\(filter)[ext=mp4]/bestvideo\(filter)+bestaudio/best\(filter)"
case "webm":
return "bestvideo\(filter)[ext=webm]+bestaudio[ext=webm]/best\(filter)[ext=webm]/bestvideo\(filter)+bestaudio/best\(filter)"
default:
return "bestvideo\(filter)+bestaudio/best\(filter)"
}
}
private static func heightFilter(_ height: Int?) -> String {
guard let height else { return "" }
return "[height<=\(height)]"
}
}
private final class YTDLPMetadataProcess: @unchecked Sendable {
private let executableURL: URL
private let arguments: [String]
private let lock = NSLock()
private var process: Process?
init(executableURL: URL, arguments: [String]) {
self.executableURL = executableURL
self.arguments = arguments
}
func run(timeoutSeconds: UInt64) async throws -> Data {
try await withTaskCancellationHandler {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
try await self.runProcess()
}
group.addTask {
try await Task.sleep(for: .seconds(timeoutSeconds))
self.terminate()
throw MediaExtractionEngine.ExtractionError.timedOut
}
guard let result = try await group.next() else {
throw MediaExtractionEngine.ExtractionError.invalidOutput
}
group.cancelAll()
return result
}
} onCancel: {
self.terminate()
}
}
private func runProcess() async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
let outputPipe = Pipe()
let errorPipe = Pipe()
let outputBuffer = LockedDataBuffer(maxBytes: 64 * 1024 * 1024)
let errorBuffer = LockedDataBuffer()
process.executableURL = executableURL
process.arguments = arguments
process.standardOutput = outputPipe
process.standardError = errorPipe
process.standardInput = nil
outputPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
} else {
outputBuffer.append(data)
}
}
errorPipe.fileHandleForReading.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
} else {
errorBuffer.append(data)
}
}
lock.withLock {
self.process = process
}
process.terminationHandler = { finishedProcess in
// Allow a brief moment for final pipe data to flush
DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 {
continuation.resume(returning: outputBuffer.data)
} 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 \(finishedProcess.terminationStatus)" : message
)
)
}
}
}
do {
try process.run()
outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile()
} 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))
}
}
}
private func terminate() {
let p = lock.withLock { self.process }
guard let p, p.isRunning else { return }
p.terminate()
Task.detached {
try? await Task.sleep(nanoseconds: 500_000_000)
if p.isRunning {
kill(p.processIdentifier, SIGKILL)
}
}
}
}
+26 -4
View File
@@ -93,10 +93,9 @@ struct DownloadRequestHeader: Codable, Equatable, Sendable {
var value: String
var normalized: DownloadRequestHeader {
DownloadRequestHeader(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
)
let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
let cleanValue = value.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "")
return DownloadRequestHeader(name: cleanName, value: cleanValue)
}
var isEmpty: Bool {
@@ -188,6 +187,24 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var queueID: UUID?
var rpcPort: Int?
var rpcSecret: String?
var mediaFormatSelector: String?
var isAudioOnlyMedia: Bool?
private enum CodingKeys: String, CodingKey {
case id, url, fileName, category, destinationDirectory, connectionsPerServer
case credentials, checksum, requestHeaders, cookieHeader, mirrorURLs, speedLimitKiBPerSecond
case status, progress, speedText, etaText, connectionCount, sizeBytes, bytesText, message
case createdAt, lastTryAt, autoResumeOnLaunch, queueID
case mediaFormatSelector, isAudioOnlyMedia
}
var displaySpeedText: String {
status == .downloading ? speedText : "-"
}
var displayETAText: String {
status == .downloading ? etaText : "-"
}
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
@@ -259,6 +276,11 @@ struct PendingDownload: Identifiable, Equatable, Sendable {
var sizeBytes: Int64?
var mimeType: String?
var state: MetadataState = .pending
var isMedia: Bool = false
var mediaOptions: [CleanFormatOption] = []
var selectedMediaOption: CleanFormatOption?
var mediaMetadata: MediaMetadata?
var destinationPath: String {
defaultDirectory.appendingPathComponent(fileName).path
+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
}
}
+60 -32
View File
@@ -18,13 +18,13 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
var shortName: String {
switch self {
case .sunday: "S"
case .monday: "M"
case .tuesday: "T"
case .wednesday: "W"
case .thursday: "T"
case .friday: "F"
case .saturday: "S"
case .sunday: "Su"
case .monday: "Mo"
case .tuesday: "Tu"
case .wednesday: "We"
case .thursday: "Th"
case .friday: "Fr"
case .saturday: "Sa"
}
}
}
@@ -32,10 +32,12 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
struct SchedulerSettings: Codable, Equatable {
var isEnabled: Bool = false
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
var stopTimeEnabled: Bool = false
var stopTime: Date = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: Date()) ?? Date()
var isEveryday: Bool = true
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
var postQueueAction: PostQueueAction = .doNothing
var targetQueueIDs: Set<UUID> = []
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
}
@MainActor
@@ -49,7 +51,7 @@ final class SchedulerController: ObservableObject {
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.SchedulerSettings.v1"
// We only trigger once per minute to prevent multiple triggers in the same minute
private var lastTriggeredMinute: Date?
@@ -62,17 +64,20 @@ final class SchedulerController: ObservableObject {
} else {
self.settings = SchedulerSettings()
}
if self.settings.targetQueueIDs.isEmpty {
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
}
checkAutomationPermission()
startTimer()
$settings
.dropFirst()
.sink { _ in
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
}
.store(in: &cancellables)
// Observe downloads to check if we should trigger post-action
downloadController.$downloads
.dropFirst()
@@ -81,7 +86,7 @@ final class SchedulerController: ObservableObject {
}
.store(in: &cancellables)
}
func saveSettings() {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey)
@@ -109,62 +114,85 @@ final class SchedulerController: ObservableObject {
let startHour = calendar.component(.hour, from: settings.startTime)
let startMinute = calendar.component(.minute, from: settings.startTime)
let currentHour = calendar.component(.hour, from: now)
let currentMinute = calendar.component(.minute, from: now)
let currentWeekday = calendar.component(.weekday, from: now)
if startHour == currentHour && startMinute == currentMinute {
let shouldRun: Bool
if settings.isEveryday {
shouldRun = true
} else {
let day = SchedulerDay(rawValue: currentWeekday)
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
}
let shouldRunToday: Bool
if settings.isEveryday {
shouldRunToday = true
} else {
let day = SchedulerDay(rawValue: currentWeekday)
shouldRunToday = day.map { settings.selectedDays.contains($0) } ?? false
}
if shouldRun {
if shouldRunToday {
if startHour == currentHour && startMinute == currentMinute {
lastTriggeredMinute = now
triggerQueues()
}
if settings.stopTimeEnabled {
let stopHour = calendar.component(.hour, from: settings.stopTime)
let stopMinute = calendar.component(.minute, from: settings.stopTime)
if stopHour == currentHour && stopMinute == currentMinute {
lastTriggeredMinute = now
pauseQueues()
}
}
}
}
private func pauseQueues() {
let targetQueueIDs = effectiveTargetQueueIDs()
for queueID in targetQueueIDs {
downloadController.pauseActiveDownloads(queueID: queueID)
}
}
private func triggerQueues() {
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
let targetQueueIDs = effectiveTargetQueueIDs()
let runnableQueueIDs = targetQueueIDs.filter { queueID in
downloadController.queues.contains(where: { $0.id == queueID }) &&
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
}
guard !runnableQueueIDs.isEmpty else { return }
isRunning = true
for queueID in runnableQueueIDs {
downloadController.startQueue(queueID: queueID)
}
checkIfRunningFinished()
}
private func checkIfRunningFinished() {
guard isRunning else { return }
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
let targetQueueIDs = effectiveTargetQueueIDs()
let hasActiveItems = targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
}
if !hasActiveItems {
isRunning = false
performPostAction()
}
}
private func effectiveTargetQueueIDs() -> Set<UUID> {
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
}
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
var scriptCode = ""
switch settings.postQueueAction {
case .sleep:
@@ -176,9 +204,9 @@ final class SchedulerController: ObservableObject {
case .doNothing:
break
}
guard !scriptCode.isEmpty else { return }
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
+151 -92
View File
@@ -4,31 +4,32 @@ struct SchedulerView: View {
@EnvironmentObject private var downloadController: DownloadController
@EnvironmentObject private var schedulerController: SchedulerController
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var startTime: Date = Date()
@State private var stopTimeEnabled: Bool = false
@State private var stopTime: Date = Date()
@State private var isEveryday: Bool = true
@State private var selectedDays: Set<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = []
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 20) {
Group {
timeSelectionSection
queueSelectionSection
postActionSection
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
Divider()
permissionsSection
}
.padding(24)
@@ -49,7 +50,7 @@ struct SchedulerView: View {
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
@@ -57,15 +58,15 @@ struct SchedulerView: View {
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Settings") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
@@ -76,104 +77,154 @@ struct SchedulerView: View {
}
.padding(24)
}
private var timeSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Start Time")
.font(.headline)
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
Toggle("Everyday", isOn: $isEveryday)
if !isEveryday {
HStack(spacing: 12) {
ForEach(SchedulerDay.allCases) { day in
Toggle(day.shortName, isOn: Binding(
get: { selectedDays.contains(day) },
set: { isSelected in
if isSelected {
selectedDays.insert(day)
} else {
GroupBox {
VStack(alignment: .leading, spacing: 16) {
HStack {
Label("Timing", systemImage: "clock")
.font(.headline)
Spacer()
}
HStack(spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("Start Time")
.foregroundStyle(.secondary)
DatePicker("Start", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
}
VStack(alignment: .leading, spacing: 8) {
Toggle("Stop Time", isOn: $stopTimeEnabled)
.foregroundStyle(.secondary)
DatePicker("Stop", selection: $stopTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
.disabled(!stopTimeEnabled)
.opacity(stopTimeEnabled ? 1.0 : 0.5)
}
}
Divider()
Toggle("Run Every Day", isOn: $isEveryday)
if !isEveryday {
HStack(spacing: 8) {
ForEach(SchedulerDay.allCases) { day in
Button(action: {
if selectedDays.contains(day) {
selectedDays.remove(day)
}
}
))
.toggleStyle(.button)
}
}
}
}
}
private var queueSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Queues to Start")
.font(.headline)
if downloadController.queues.isEmpty {
Text("No queues available")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(downloadController.queues) { queue in
Toggle(queue.name, isOn: Binding(
get: { targetQueueIDs.contains(queue.id) },
set: { isSelected in
if isSelected {
targetQueueIDs.insert(queue.id)
} else {
targetQueueIDs.remove(queue.id)
selectedDays.insert(day)
}
}) {
Text(day.shortName)
.fontWeight(.medium)
.frame(width: 28, height: 28)
}
))
.buttonStyle(.borderless)
.background(selectedDays.contains(day) ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
.foregroundStyle(selectedDays.contains(day) ? Color.white : Color.primary)
.clipShape(Circle())
}
}
}
}
.padding(8)
}
}
private var queueSelectionSection: some View {
GroupBox {
VStack(alignment: .leading, spacing: 16) {
HStack {
Label("Queues to Schedule", systemImage: "list.bullet.rectangle")
.font(.headline)
Spacer()
}
if downloadController.queues.isEmpty {
Text("No queues available")
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 12) {
ForEach(downloadController.queues) { queue in
Toggle(queue.name, isOn: Binding(
get: { targetQueueIDs.contains(queue.id) },
set: { isSelected in
if isSelected {
targetQueueIDs.insert(queue.id)
} else {
targetQueueIDs.remove(queue.id)
}
}
))
}
}
}
}
.padding(8)
}
}
private var postActionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("After Completion")
.font(.headline)
Picker("Action", selection: $postQueueAction) {
ForEach(PostQueueAction.allCases) { action in
Text(action.rawValue).tag(action)
GroupBox {
VStack(alignment: .leading, spacing: 16) {
HStack {
Label("After Completion", systemImage: "powersleep")
.font(.headline)
Spacer()
}
Text("Choose what happens after scheduled downloads finish.")
.font(.subheadline)
.foregroundStyle(.secondary)
Picker("Action", selection: $postQueueAction) {
ForEach(PostQueueAction.allCases) { action in
Text(action.rawValue).tag(action)
}
}
.labelsHidden()
.pickerStyle(.radioGroup)
}
.labelsHidden()
.pickerStyle(.radioGroup)
.padding(8)
}
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("System Permissions")
.font(.headline)
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if schedulerController.hasAutomationPermission {
Button("Revoke Permissions") {
schedulerController.openAutomationPermissionSettings()
GroupBox {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("System Permissions", systemImage: "lock.shield")
.font(.headline)
Spacer()
}
.buttonStyle(.bordered)
} else {
Button("Grant Permission") {
schedulerController.requestAutomationPermission()
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if schedulerController.hasAutomationPermission {
Button("Revoke Permissions") {
schedulerController.openAutomationPermissionSettings()
}
.buttonStyle(.bordered)
} else {
Button("Grant Permission") {
schedulerController.requestAutomationPermission()
}
.buttonStyle(.borderedProminent)
}
.buttonStyle(.borderedProminent)
}
.padding(8)
}
}
private var toastView: some View {
VStack {
Spacer()
@@ -192,23 +243,31 @@ struct SchedulerView: View {
}
.allowsHitTesting(false)
}
private func loadState() {
isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime
stopTimeEnabled = schedulerController.settings.stopTimeEnabled
stopTime = schedulerController.settings.stopTime
isEveryday = schedulerController.settings.isEveryday
selectedDays = schedulerController.settings.selectedDays
postQueueAction = schedulerController.settings.postQueueAction
targetQueueIDs = schedulerController.settings.targetQueueIDs
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
? [DownloadQueue.mainQueueID]
: schedulerController.settings.targetQueueIDs
}
private func saveState() {
schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime
schedulerController.settings.stopTimeEnabled = stopTimeEnabled
schedulerController.settings.stopTime = stopTime
schedulerController.settings.isEveryday = isEveryday
schedulerController.settings.selectedDays = selectedDays
schedulerController.settings.postQueueAction = postQueueAction
schedulerController.settings.targetQueueIDs = targetQueueIDs
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
? [DownloadQueue.mainQueueID]
: targetQueueIDs
schedulerController.saveSettings()
}
}
@@ -0,0 +1,181 @@
import AppKit
import SwiftUI
struct AboutSettingsPane: View {
@EnvironmentObject private var updateChecker: ReleaseUpdateChecker
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 {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 16) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 64, height: 64)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.bold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 8)
}
Section("Updates") {
VStack(alignment: .leading, spacing: 16) {
updateStatusView
Divider()
.padding(.vertical, 2)
Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
}
.padding(.vertical, 8)
.animation(.easeInOut, value: updateChecker.state)
}
Section {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Created by NimBold")
Spacer()
Link(destination: projectURL) {
HStack(spacing: 4) {
if let imgPath = Bundle.main.path(forResource: "GitHubTemplate", ofType: "png"),
let nsImage = NSImage(contentsOfFile: imgPath) {
Image(nsImage: nsImage)
.resizable()
.renderingMode(.template)
.frame(width: 14, height: 14)
.foregroundStyle(.secondary)
}
Text("Source Code")
}
}
.buttonStyle(.plain)
}
HStack {
Text("Powered by")
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)
}
Text("Copyright © 2026 NimBold. All rights reserved.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.font(.caption)
.padding(.vertical, 4)
}
}
.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, _):
Image(systemName: "checkmark.seal.fill")
.font(.title2)
.foregroundStyle(.green)
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)
}
}
@@ -0,0 +1,72 @@
import SwiftUI
struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
LabeledContent {
Stepper("\(settings.perServerConnections)", value: $settings.perServerConnections, in: 1...16)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Default connections:")
Text("For new downloads")
.font(.caption)
.foregroundStyle(.secondary)
}
}
LabeledContent {
Stepper("\(settings.maxConcurrentDownloads)", value: $settings.maxConcurrentDownloads, in: 1...12)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Parallel downloads:")
Text("Max simultaneous active files")
.font(.caption)
.foregroundStyle(.secondary)
}
}
LabeledContent {
HStack {
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Global speed limit:")
Text("0 = unlimited speed")
.font(.caption)
.foregroundStyle(.secondary)
}
}
LabeledContent {
Stepper("\(settings.maxAutomaticRetries)", value: $settings.maxAutomaticRetries, in: 0...10)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text("Automatic retries:")
Text("If a connection fails")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Toggle(isOn: $settings.showNotifications) {
VStack(alignment: .leading, spacing: 2) {
Text("Show notification when download completes")
Text("Alerts you in Notification Center")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Toggle(isOn: $settings.playCompletionSound) {
Text("Play sound when download completes")
}
.disabled(!settings.showNotifications)
}
}
.formStyle(.grouped)
}
}
@@ -0,0 +1,105 @@
import SwiftUI
import AppKit
struct EngineSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@StateObject private var engineManager = MediaEngineManager.shared
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
var body: some View {
Form {
Section {
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
LabeledContent("Binary") {
if let url = executableURL {
Button("Reveal in Finder") {
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "")
}
} else {
Text("Not found")
.foregroundStyle(.red)
}
}
} header: {
Text("Core Downloader (Aria2)")
} footer: {
if executableURL == nil {
Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.")
.foregroundStyle(.red)
} else {
Text("Handles core HTTP/FTP and BitTorrent downloads.")
}
}
Section {
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
LabeledContent("Browser Cookies") {
HStack {
Spacer()
Picker("", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source)
}
}
.labelsHidden()
.fixedSize()
}
}
} header: {
Text("Media Extractors")
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Powers video and audio extraction from supported sites.")
if settings.mediaCookieSource != .none {
Text(settings.mediaCookieSource.statusDetail)
}
}
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
@ViewBuilder
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
LabeledContent(title) {
HStack(spacing: 8) {
switch state {
case .notInstalled:
Text("Missing")
.foregroundStyle(.red)
case .installed(let version):
Text(version)
.foregroundStyle(.secondary)
.font(.system(.body, design: .monospaced))
case .failed(let error):
Text("Error")
.foregroundStyle(.red)
.help(error)
}
if let path {
Button("Reveal") {
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: "")
}
}
}
}
}
}
@@ -0,0 +1,246 @@
import AppKit
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: 12) {
Image(systemName: "puzzlepiece.extension.fill")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text("Connect Browser Extension")
.font(.title3.weight(.bold))
Text("Capture downloads directly from your browser in three easy steps.")
.foregroundStyle(.secondary)
.font(.subheadline)
}
Spacer()
}
.padding(.bottom, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
Section {
KeychainAccessCard()
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
Section {
HStack(spacing: 8) {
// 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)
if status == errSecSuccess {
settings.extensionPairingToken = Data(bytes).base64EncodedString()
} else {
settings.extensionPairingToken = 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)
} header: {
Text("Setup Guide")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
Section {
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)
} else {
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
.font(.footnote)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
} header: {
Text("Status")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
}
.formStyle(.grouped)
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
}
}
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: 8) {
HStack(alignment: .top) {
// Step Number Badge
ZStack {
Circle()
.fill(Color.secondary.opacity(0.1))
.frame(width: 20, height: 20)
Text("\(stepNumber)")
.font(.system(.caption2, design: .rounded).weight(.bold))
.foregroundStyle(.primary)
}
Spacer()
// Icon
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(iconColor.opacity(0.15))
.frame(width: 28, height: 28)
Image(systemName: icon)
.font(.system(size: 14))
.foregroundStyle(iconColor)
}
}
// Text Content
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
Text(description)
.font(.caption2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 4)
// Action Button
VStack(spacing: 6) {
if let actionText = actionText, let action = action {
Button(action: action) {
Text(actionText)
.font(.caption2.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(4)
}
.buttonStyle(.plain)
}
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
Button(action: secondaryAction) {
Text(secondaryActionText)
.font(.caption2.weight(.medium))
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(Color(nsColor: .controlBackgroundColor))
.foregroundColor(.primary)
.cornerRadius(4)
.overlay(
RoundedRectangle(cornerRadius: 4)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(10)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -0,0 +1,66 @@
import SwiftUI
struct KeychainAccessCard: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
HStack(spacing: settings.isKeychainAccessGranted ? 12 : 16) {
ZStack {
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 6 : 10)
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
.frame(width: settings.isKeychainAccessGranted ? 28 : 36, height: settings.isKeychainAccessGranted ? 28 : 36)
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
.font(.system(size: settings.isKeychainAccessGranted ? 14 : 18))
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
}
VStack(alignment: .leading, spacing: 2) {
Text(settings.isKeychainAccessGranted ? "Keychain Access Granted" : "Keychain Access")
.font(settings.isKeychainAccessGranted ? .subheadline.weight(.medium) : .headline)
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .primary)
if !settings.isKeychainAccessGranted {
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: settings.isKeychainAccessGranted ? 8 : 16)
if settings.isKeychainAccessGranted {
Button(role: .destructive) {
settings.revokeKeychainAccess()
} label: {
Text("Revoke")
}
.controlSize(.small)
} 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(settings.isKeychainAccessGranted ? 8 : 12)
.background(
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
.fill(Color(nsColor: .controlBackgroundColor))
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
)
.overlay(
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
)
}
}
@@ -0,0 +1,262 @@
import AppKit
import SwiftUI
struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section(footer: Text("When enabled, you can choose the download location each time you add a download. Otherwise, files are saved automatically.")) {
Toggle("Ask where to save each file before downloading", isOn: $settings.askWhereToSaveEachFile)
}
Section(footer: Text("Folders will be created automatically when saving.")) {
BulkDirectoryPickerRow()
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
.formStyle(.grouped)
}
}
struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
@State private var message = ""
var body: some View {
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("Folder path", text: $path, prompt: Text("Folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
.onSubmit {
applyPath()
}
Button("Choose...") {
selectFolder()
}
}
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
Text(displayMessage)
.font(.caption)
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
.onAppear {
syncPathFromSettings()
}
.onChange(of: settings.downloadDirectories[category]) { _, _ in
syncPathFromSettings()
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url {
path = url.path
settings.setDirectory(url.path, for: category)
message = "Saved."
}
}
private func syncPathFromSettings() {
path = settings.downloadDirectories[category] ?? settings.destinationDirectory(for: category).path
message = ""
}
private func applyPath() {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
message = "Enter a folder path."
return
}
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
guard isDirectory.boolValue else {
message = "This path points to a file, not a folder."
return
}
} else {
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: expanded, isDirectory: true),
withIntermediateDirectories: true
)
} catch {
message = "Could not create folder: \(error.localizedDescription)"
return
}
}
guard FileManager.default.isWritableFile(atPath: expanded) else {
message = "Firelink cannot write to this folder."
return
}
settings.setDirectory(expanded, for: category)
path = expanded
message = "Saved."
}
private func statusMessage(for path: String) -> String? {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "Enter a folder path." }
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
if !isDirectory.boolValue {
return "This path points to a file, not a folder."
}
return FileManager.default.isWritableFile(atPath: expanded)
? nil
: "Firelink cannot write to this folder."
}
return nil
}
private func isErrorMessage(_ message: String) -> Bool {
message == "This path points to a file, not a folder." ||
message.hasPrefix("Could not create folder:") ||
message == "Firelink cannot write to this folder." ||
message == "Enter a folder path."
}
}
struct BulkDirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
@State private var path = ""
@State private var message = ""
var body: some View {
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
TextField("Base folder path", text: $path, prompt: Text("Base folder path"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.leading)
.font(.system(.body, design: .monospaced))
.onSubmit {
applyPath()
}
Button("Choose...") {
selectFolder()
}
}
if !message.isEmpty {
Text(message)
.font(.caption)
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
}
}
} label: {
Label("All Categories", systemImage: "folder.fill.badge.plus")
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
if panel.runModal() == .OK, let url = panel.url {
path = url.path
applyPath()
}
}
private func applyPath() {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
message = "Enter a base folder path."
return
}
let expanded = NSString(string: trimmed).expandingTildeInPath
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
guard isDirectory.boolValue else {
message = "This path points to a file, not a folder."
return
}
} else {
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: expanded, isDirectory: true),
withIntermediateDirectories: true
)
} catch {
message = "Could not create folder: \(error.localizedDescription)"
return
}
}
guard FileManager.default.isWritableFile(atPath: expanded) else {
message = "Firelink cannot write to this folder."
return
}
for category in DownloadCategory.allCases {
let categoryPath = (expanded as NSString).appendingPathComponent(category.rawValue)
do {
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: categoryPath, isDirectory: true),
withIntermediateDirectories: true
)
settings.setDirectory(categoryPath, for: category)
} catch {
message = "Could not create category folder \(category.rawValue): \(error.localizedDescription)"
return
}
}
message = "Created all categories in base folder."
path = ""
}
private func isErrorMessage(_ message: String) -> Bool {
message == "This path points to a file, not a folder." ||
message.hasPrefix("Could not create folder:") ||
message.hasPrefix("Could not create category folder") ||
message == "Firelink cannot write to this folder." ||
message == "Enter a base folder path."
}
}
@@ -0,0 +1,51 @@
import SwiftUI
struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("macOS Integration") {
Toggle("Show badge on Dock icon", isOn: $settings.showDockBadge)
Text("Displays the number of active downloads on the Firelink Dock icon.")
.font(.caption)
.foregroundStyle(.secondary)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
@@ -0,0 +1,83 @@
import SwiftUI
struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section("Proxy") {
Picker("Mode", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
if settings.proxySettings.mode == .custom {
Text("aria2 uses an HTTP-style proxy for all protocols. SOCKS proxies are not supported by aria2.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Section("Identity") {
TextField("User Agent", text: $settings.customUserAgent, prompt: Text("e.g. Mozilla/5.0..."))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Text("Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
return "Downloads ignore configured proxies."
case .system:
return "Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
return "Downloads use \(proxyURI)."
} else {
return "Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
}
}
@@ -0,0 +1,17 @@
import SwiftUI
struct PowerSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
@@ -0,0 +1,102 @@
import AppKit
import SwiftUI
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(activeTab.rawValue)
.font(.largeTitle.weight(.semibold))
.padding(.bottom, 24)
selectedPane
.frame(maxWidth: 720, alignment: .leading)
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var selectedPane: some View {
switch activeTab {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
@@ -0,0 +1,231 @@
import SwiftUI
import AppKit
struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var searchText = ""
@State private var selection = Set<SiteLogin.ID>()
@State private var editingLogin: SiteLogin?
@State private var showEditor = false
var filteredLogins: [SiteLogin] {
if searchText.isEmpty {
return settings.siteLogins
} else {
return settings.siteLogins.filter {
$0.urlPattern.localizedCaseInsensitiveContains(searchText) ||
$0.username.localizedCaseInsensitiveContains(searchText)
}
}
}
var body: some View {
Form {
Section {
KeychainAccessCard()
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
if settings.isKeychainAccessGranted {
Section {
VStack(spacing: 0) {
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search Logins", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(8)
.background(Color(NSColor.textBackgroundColor))
Divider()
if settings.siteLogins.isEmpty {
ContentUnavailableView("No saved logins", systemImage: "key")
.frame(minHeight: 250)
} else if filteredLogins.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(minHeight: 250)
} else {
Table(filteredLogins, selection: $selection) {
TableColumn("URL Pattern") { login in
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
.contextMenu {
contextMenuActions(for: login)
}
}
TableColumn("Username") { login in
Text(login.username)
.contextMenu {
contextMenuActions(for: login)
}
}
}
.frame(minHeight: 200, idealHeight: 250, maxHeight: 300)
}
Divider()
HStack {
Button {
editingLogin = nil
showEditor = true
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 4)
Divider()
.frame(height: 16)
Button {
deleteSelected()
} label: {
Image(systemName: "minus")
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.horizontal, 4)
.disabled(selection.isEmpty)
Spacer()
}
.padding(.vertical, 4)
.padding(.horizontal, 4)
.background(Color(NSColor.windowBackgroundColor))
}
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
} header: {
Text("Saved Logins")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
}
.formStyle(.grouped)
.sheet(isPresented: $showEditor) {
LoginEditorSheet(login: editingLogin)
}
}
@ViewBuilder
private func contextMenuActions(for login: SiteLogin) -> some View {
Button("Edit") {
editingLogin = login
showEditor = true
}
Divider()
Button("Copy Username") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(login.username, forType: .string)
}
Button("Copy Password") {
if let password = KeychainCredentialStore.password(for: login.id) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(password, forType: .string)
}
}
Divider()
Button("Delete", role: .destructive) {
if let index = settings.siteLogins.firstIndex(where: { $0.id == login.id }) {
settings.deleteSiteLogins(at: IndexSet(integer: index))
}
selection.remove(login.id)
}
}
private func deleteSelected() {
let indices = settings.siteLogins.enumerated().compactMap { index, login in
selection.contains(login.id) ? index : nil
}
settings.deleteSiteLogins(at: IndexSet(indices))
selection.removeAll()
}
}
struct LoginEditorSheet: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var settings: AppSettings
let login: SiteLogin?
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
VStack(spacing: 0) {
Text(login == nil ? "Add Login" : "Edit Login")
.font(.headline)
.padding()
Form {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField(login == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
}
.padding(.horizontal)
.padding(.bottom)
if !settings.message.isEmpty && (settings.message.hasPrefix("Add a") || settings.message.hasPrefix("A login") || settings.message.hasPrefix("Could not")) {
Text(settings.message)
.foregroundColor(.red)
.font(.caption)
.padding(.bottom)
}
HStack {
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button(login == nil ? "Add" : "Save") {
settings.message = ""
settings.saveSiteLogin(
id: login?.id,
urlPattern: urlPattern,
username: username,
password: password
)
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
dismiss()
}
}
.keyboardShortcut(.defaultAction)
}
.padding()
}
.frame(width: 400)
.onAppear {
if let login = login {
urlPattern = login.urlPattern
username = login.username
}
settings.message = ""
}
}
}
@@ -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))
}
}
-722
View File
@@ -1,722 +0,0 @@
import AppKit
import SwiftUI
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(activeTab.rawValue)
.font(.largeTitle.weight(.semibold))
.padding(.bottom, 24)
selectedPane
.frame(maxWidth: 720, alignment: .leading)
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var selectedPane: some View {
switch activeTab {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
private struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
private struct EngineSettingsPane: View {
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
private struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
}
}
private struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
.formStyle(.grouped)
}
}
private struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
var body: some View {
LabeledContent {
HStack(spacing: 8) {
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url {
settings.setDirectory(url.path, for: category)
}
}
}
private struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
Form {
Section("Add Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField("Password", text: $password)
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
}
} label: {
Label("Add Login", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
Section("Saved Logins") {
if settings.siteLogins.isEmpty {
Text("No saved logins.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
}
}
.formStyle(.grouped)
}
}
private struct PowerSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct IntegrationSettingsPane: View {
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
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(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") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
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)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
+20 -39
View File
@@ -21,31 +21,6 @@ enum DownloadSidebarFilter: Hashable {
}
}
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
case downloads = "Downloads"
case lookAndFeel = "Look and feel"
case network = "Network"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case integration = "Integrations"
case about = "About"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
case .engine: "terminal"
case .integration: "puzzlepiece.extension"
case .about: "info.circle"
}
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
@@ -57,6 +32,7 @@ enum SidebarSelection: Hashable {
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Environment(\.controlActiveState) private var controlActiveState
@Binding var selection: SidebarSelection
@State private var queueBeingRenamed: DownloadQueue?
@State private var queueBeingRemoved: DownloadQueue?
@@ -95,10 +71,11 @@ struct SidebarView: View {
selection = .queue(queue.id)
} label: {
Label("Add new queue", systemImage: "plus")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler)
@@ -121,8 +98,8 @@ struct SidebarView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(selection == .settings ? Color.accentColor : Color.clear)
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
.background(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.accentColor : Color.secondary.opacity(0.5)) : Color.clear)
.foregroundStyle(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.white : Color.primary) : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.horizontal, 8)
.padding(.vertical, 8)
@@ -163,7 +140,7 @@ struct SidebarView: View {
Button("Delete Queue", role: .destructive) {
controller.removeQueue(id: queue.id)
if selection == .queue(queue.id) {
selection = .downloads(.unfinished)
selection = .downloads(.all)
}
queueBeingRemoved = nil
}
@@ -181,8 +158,9 @@ struct SidebarView: View {
.tag(SidebarSelection.downloads(.category(category)))
}
@ViewBuilder
private func queueRow(for queue: DownloadQueue) -> some View {
Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
let row = Label(queue.name, systemImage: queue.isMain ? "list.bullet.rectangle" : "list.bullet")
.badge(controller.queueCount(for: queue.id))
.tag(SidebarSelection.queue(queue.id))
.onDrop(
@@ -193,17 +171,20 @@ struct SidebarView: View {
controller: controller
)
)
.contextMenu {
if !queue.isMain {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
if queue.isMain {
row
} else {
row.contextMenu {
Button("Rename") {
queueBeingRenamed = queue
queueName = queue.name
}
Button("Delete", role: .destructive) {
queueBeingRemoved = queue
}
}
}
}
}
+86 -45
View File
@@ -1,23 +1,33 @@
import SwiftUI
enum SpeedUnit: String, CaseIterable, Identifiable {
case kbs = "KB/s"
case mbs = "MB/s"
var id: String { rawValue }
}
struct SpeedLimiterView: View {
@EnvironmentObject private var settings: AppSettings
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var speedLimitKiBPerSecond: Int = 1024
@State private var displayedSpeedValue: Int = 1
@State private var limitUnit: SpeedUnit = .mbs
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
limitSelectionSection
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
VStack(alignment: .leading, spacing: 20) {
GroupBox {
limitSelectionSection
.padding(8)
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
}
.padding(24)
}
@@ -33,7 +43,7 @@ struct SpeedLimiterView: View {
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
@@ -41,15 +51,15 @@ struct SpeedLimiterView: View {
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Limit") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
@@ -60,40 +70,64 @@ struct SpeedLimiterView: View {
}
.padding(24)
}
private var limitSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Global Speed Limit")
.font(.headline)
VStack(alignment: .leading, spacing: 20) {
HStack {
Label("Global Speed Limit", systemImage: "speedometer")
.font(.headline)
Spacer()
}
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack {
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
Text("Maximum Speed:")
}
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
HStack(spacing: 16) {
TextField("Speed", value: $displayedSpeedValue, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 80)
Text("KiB/s")
.multilineTextAlignment(.trailing)
Picker("Unit", selection: $limitUnit) {
ForEach(SpeedUnit.allCases) { unit in
Text(unit.rawValue).tag(unit)
}
}
.pickerStyle(.segmented)
.frame(width: 140)
}
Divider()
VStack(alignment: .leading, spacing: 12) {
Text("Quick Presets")
.font(.subheadline)
.foregroundStyle(.secondary)
HStack(spacing: 12) {
presetButton(title: "1 MB/s", value: 1, unit: .mbs)
presetButton(title: "5 MB/s", value: 5, unit: .mbs)
presetButton(title: "10 MB/s", value: 10, unit: .mbs)
}
}
// Helpful presets
HStack(spacing: 12) {
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
}
.buttonStyle(.bordered)
.padding(.top, 8)
}
}
private func presetButton(title: String, value: Int, unit: SpeedUnit) -> some View {
Button(action: {
displayedSpeedValue = value
limitUnit = unit
}) {
Text(title)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.buttonStyle(.bordered)
.controlSize(.regular)
}
private var toastView: some View {
VStack {
Spacer()
@@ -112,20 +146,27 @@ struct SpeedLimiterView: View {
}
.allowsHitTesting(false)
}
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
private func loadState() {
let currentLimit = settings.globalSpeedLimitKiBPerSecond
isEnabled = currentLimit > 0
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
}
private func saveState() {
// Clamp to ensure it doesn't break aria2
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
speedLimitKiBPerSecond = clampedSpeed
let effectiveLimit = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
if effectiveLimit % 1024 == 0 && effectiveLimit >= 1024 {
displayedSpeedValue = effectiveLimit / 1024
limitUnit = .mbs
} else {
displayedSpeedValue = effectiveLimit
limitUnit = .kbs
}
}
private func saveState() {
let valueInKbs = limitUnit == .mbs ? displayedSpeedValue * 1024 : displayedSpeedValue
let clampedSpeed = max(min(valueInKbs, 10_485_760), 1)
lastCustomSpeedLimit = clampedSpeed
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
}
+8
View File
@@ -53,6 +53,14 @@ enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
case .relaxed: return 14
}
}
var minRowHeight: CGFloat {
switch self {
case .compact: return 24
case .standard: return 32
case .relaxed: return 44
}
}
}
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
View File
+893
View File
@@ -0,0 +1,893 @@
Usage: yt-dlp [OPTIONS] URL [URL...]
Options:
General Options:
-h, --help Print this help text and exit
--version Print program version and exit
-U, --update Check if updates are available. Auto-update
is not supported for unpackaged executables;
Re-download the latest release
--no-update Do not check for updates (default)
--update-to [CHANNEL]@[TAG] Upgrade/downgrade to a specific version.
CHANNEL can be a repository as well. CHANNEL
and TAG default to "stable" and "latest"
respectively if omitted; See "UPDATE" for
details. Supported channels: stable,
nightly, master
-i, --ignore-errors Ignore download and postprocessing errors.
The download will be considered successful
even if the postprocessing fails
--no-abort-on-error Continue with next video on download errors;
e.g. to skip unavailable videos in a
playlist (default)
--abort-on-error Abort downloading of further videos if an
error occurs (Alias: --no-ignore-errors)
--list-extractors List all supported extractors and exit
--extractor-descriptions Output descriptions of all supported
extractors and exit
--use-extractors NAMES Extractor names to use separated by commas.
You can also use regexes, "all", "default"
and "end" (end URL matching); e.g. --ies
"holodex.*,end,youtube". Prefix the name
with a "-" to exclude it, e.g. --ies
default,-generic. Use --list-extractors for
a list of extractor names. (Alias: --ies)
--default-search PREFIX Use this prefix for unqualified URLs. E.g.
"gvsearch2:python" downloads two videos from
google videos for the search term "python".
Use the value "auto" to let yt-dlp guess
("auto_warning" to emit a warning when
guessing). "error" just throws an error. The
default value "fixup_error" repairs broken
URLs, but emits an error if this is not
possible instead of searching
--ignore-config Don't load any more configuration files
except those given to --config-locations.
For backward compatibility, if this option
is found inside the system configuration
file, the user configuration is not loaded.
(Alias: --no-config)
--no-config-locations Do not load any custom configuration files
(default). When given inside a configuration
file, ignore all previous --config-locations
defined in the current file
--config-locations PATH Location of the main configuration file;
either the path to the config or its
containing directory ("-" for stdin). Can be
used multiple times and inside other
configuration files
--plugin-dirs DIR Path to an additional directory to search
for plugins. This option can be used
multiple times to add multiple directories.
Use "default" to search the default plugin
directories (default)
--no-plugin-dirs Clear plugin directories to search,
including defaults and those provided by
previous --plugin-dirs
--js-runtimes RUNTIME[:PATH] Additional JavaScript runtime to enable,
with an optional location for the runtime
(either the path to the binary or its
containing directory). This option can be
used multiple times to enable multiple
runtimes. Supported runtimes are (in order
of priority, from highest to lowest): deno,
node, quickjs, bun. Only "deno" is enabled
by default. The highest priority runtime
that is both enabled and available will be
used. In order to use a lower priority
runtime when "deno" is available, --no-js-
runtimes needs to be passed before enabling
other runtimes
--no-js-runtimes Clear JavaScript runtimes to enable,
including defaults and those provided by
previous --js-runtimes
--remote-components COMPONENT Remote components to allow yt-dlp to fetch
when required. This option is currently not
needed if you are using an official
executable or have the requisite version of
the yt-dlp-ejs package installed. You can
use this option multiple times to allow
multiple components. Supported values:
ejs:npm (external JavaScript components from
npm), ejs:github (external JavaScript
components from yt-dlp-ejs GitHub). By
default, no remote components are allowed
--no-remote-components Disallow fetching of all remote components,
including any previously allowed by
--remote-components or defaults.
--flat-playlist Do not extract a playlist's URL result
entries; some entry metadata may be missing
and downloading may be bypassed
--no-flat-playlist Fully extract the videos of a playlist
(default)
--live-from-start Download livestreams from the start.
Currently experimental and only supported
for YouTube, Twitch, and TVer
--no-live-from-start Download livestreams from the current time
(default)
--wait-for-video MIN[-MAX] Wait for scheduled streams to become
available. Pass the minimum number of
seconds (or range) to wait between retries
--no-wait-for-video Do not wait for scheduled streams (default)
--mark-watched Mark videos watched (even with --simulate)
--no-mark-watched Do not mark videos watched (default)
--color [STREAM:]POLICY Whether to emit color codes in output,
optionally prefixed by the STREAM (stdout or
stderr) to apply the setting to. Can be one
of "always", "auto" (default), "never", or
"no_color" (use non color terminal
sequences). Use "auto-tty" or "no_color-tty"
to decide based on terminal support only.
Can be used multiple times
--compat-options OPTS Options that can help keep compatibility
with youtube-dl or youtube-dlc
configurations by reverting some of the
changes made in yt-dlp. See "Differences in
default behavior" for details
--alias ALIASES OPTIONS Create aliases for an option string. Unless
an alias starts with a dash "-", it is
prefixed with "--". Arguments are parsed
according to the Python string formatting
mini-language. E.g. --alias get-audio,-X "-S
aext:{0},abr -x --audio-format {0}" creates
options "--get-audio" and "-X" that takes an
argument (ARG0) and expands to "-S
aext:ARG0,abr -x --audio-format ARG0". All
defined aliases are listed in the --help
output. Alias options can trigger more
aliases; so be careful to avoid defining
recursive options. As a safety measure, each
alias may be triggered a maximum of 100
times. This option can be used multiple
times
-t, --preset-alias PRESET Applies a predefined set of options. e.g.
--preset-alias mp3. The following presets
are available: mp3, aac, mp4, mkv, sleep.
See the "Preset Aliases" section at the end
for more info. This option can be used
multiple times
Network Options:
--proxy URL Use the specified HTTP/HTTPS/SOCKS proxy. To
enable SOCKS proxy, specify a proper scheme,
e.g. socks5://user:pass@127.0.0.1:1080/.
Pass in an empty string (--proxy "") for
direct connection
--socket-timeout SECONDS Time to wait before giving up, in seconds
--source-address IP Client-side IP address to bind to
--impersonate CLIENT[:OS] Client to impersonate for requests. E.g.
chrome, chrome-110, chrome:windows-10. Pass
--impersonate="" to impersonate any client.
Note that forcing impersonation for all
requests may have a detrimental impact on
download speed and stability
--list-impersonate-targets List available clients to impersonate.
-4, --force-ipv4 Make all connections via IPv4
-6, --force-ipv6 Make all connections via IPv6
--enable-file-urls Enable file:// URLs. This is disabled by
default for security reasons.
Geo-restriction:
--geo-verification-proxy URL Use this proxy to verify the IP address for
some geo-restricted sites. The default proxy
specified by --proxy (or none, if the option
is not present) is used for the actual
downloading
--xff VALUE How to fake X-Forwarded-For HTTP header to
try bypassing geographic restriction. One of
"default" (only when known to be useful),
"never", an IP block in CIDR notation, or a
two-letter ISO 3166-2 country code
Video Selection:
-I, --playlist-items ITEM_SPEC Comma-separated playlist_index of the items
to download. You can specify a range using
"[START]:[STOP][:STEP]". For backward
compatibility, START-STOP is also supported.
Use negative indices to count from the right
and negative STEP to download in reverse
order. E.g. "-I 1:3,7,-5::2" used on a
playlist of size 15 will download the items
at index 1,2,3,7,11,13,15
--min-filesize SIZE Abort download if filesize is smaller than
SIZE, e.g. 50k or 44.6M
--max-filesize SIZE Abort download if filesize is larger than
SIZE, e.g. 50k or 44.6M
--date DATE Download only videos uploaded on this date.
The date can be "YYYYMMDD" or in the format
[now|today|yesterday][-
N[day|week|month|year]]. E.g. "--date
today-2weeks" downloads only videos uploaded
on the same day two weeks ago
--datebefore DATE Download only videos uploaded on or before
this date. The date formats accepted are the
same as --date
--dateafter DATE Download only videos uploaded on or after
this date. The date formats accepted are the
same as --date
--match-filters FILTER Generic video filter. Any "OUTPUT TEMPLATE"
field can be compared with a number or a
string using the operators defined in
"Filtering Formats". You can also simply
specify a field to match if the field is
present, use "!field" to check if the field
is not present, and "&" to check multiple
conditions. Use a "\" to escape "&" or
quotes if needed. If used multiple times,
the filter matches if at least one of the
conditions is met. E.g. --match-filters
!is_live --match-filters "like_count>?100 &
description~='(?i)\bcats \& dogs\b'" matches
only videos that are not live OR those that
have a like count more than 100 (or the like
field is not available) and also has a
description that contains the phrase "cats &
dogs" (caseless). Use "--match-filters -" to
interactively ask whether to download each
video
--no-match-filters Do not use any --match-filters (default)
--break-match-filters FILTER Same as "--match-filters" but stops the
download process when a video is rejected
--no-break-match-filters Do not use any --break-match-filters
(default)
--no-playlist Download only the video, if the URL refers
to a video and a playlist
--yes-playlist Download the playlist, if the URL refers to
a video and a playlist
--age-limit YEARS Download only videos suitable for the given
age
--download-archive FILE Download only videos not listed in the
archive file. Record the IDs of all
downloaded videos in it
--no-download-archive Do not use archive file (default)
--max-downloads NUMBER Abort after downloading NUMBER files
--break-on-existing Stop the download process when encountering
a file that is in the archive supplied with
the --download-archive option
--no-break-on-existing Do not stop the download process when
encountering a file that is in the archive
(default)
--break-per-input Alters --max-downloads, --break-on-existing,
--break-match-filters, and autonumber to
reset per input URL
--no-break-per-input --break-on-existing and similar options
terminates the entire download queue
--skip-playlist-after-errors N Number of allowed failures until the rest of
the playlist is skipped
Download Options:
-N, --concurrent-fragments N Number of fragments of a dash/hlsnative
video that should be downloaded concurrently
(default is 1)
-r, --limit-rate RATE Maximum download rate in bytes per second,
e.g. 50K or 4.2M
--throttled-rate RATE Minimum download rate in bytes per second
below which throttling is assumed and the
video data is re-extracted, e.g. 100K
-R, --retries RETRIES Number of retries (default is 10), or
"infinite"
--file-access-retries RETRIES Number of times to retry on file access
error (default is 3), or "infinite"
--fragment-retries RETRIES Number of retries for a fragment (default is
10), or "infinite" (DASH, hlsnative and ISM)
--retry-sleep [TYPE:]EXPR Time to sleep between retries in seconds
(optionally) prefixed by the type of retry
(http (default), fragment, file_access,
extractor) to apply the sleep to. EXPR can
be a number, linear=START[:END[:STEP=1]] or
exp=START[:END[:BASE=2]]. This option can be
used multiple times to set the sleep for the
different retry types, e.g. --retry-sleep
linear=1::2 --retry-sleep fragment:exp=1:20
--skip-unavailable-fragments Skip unavailable fragments for DASH,
hlsnative and ISM downloads (default)
(Alias: --no-abort-on-unavailable-fragments)
--abort-on-unavailable-fragments
Abort download if a fragment is unavailable
(Alias: --no-skip-unavailable-fragments)
--keep-fragments Keep downloaded fragments on disk after
downloading is finished
--no-keep-fragments Delete downloaded fragments after
downloading is finished (default)
--buffer-size SIZE Size of download buffer, e.g. 1024 or 16K
(default is 1024)
--resize-buffer The buffer size is automatically resized
from an initial value of --buffer-size
(default)
--no-resize-buffer Do not automatically adjust the buffer size
--http-chunk-size SIZE Size of a chunk for chunk-based HTTP
downloading, e.g. 10485760 or 10M (default
is disabled). May be useful for bypassing
bandwidth throttling imposed by a webserver
(experimental)
--playlist-random Download playlist videos in random order
--lazy-playlist Process entries in the playlist as they are
received. This disables n_entries,
--playlist-random and --playlist-reverse
--no-lazy-playlist Process videos in the playlist only after
the entire playlist is parsed (default)
--hls-use-mpegts Use the mpegts container for HLS videos;
allowing some players to play the video
while downloading, and reducing the chance
of file corruption if download is
interrupted. This is enabled by default for
live streams
--no-hls-use-mpegts Do not use the mpegts container for HLS
videos. This is default when not downloading
live streams
--download-sections REGEX Download only chapters that match the
regular expression. A "*" prefix denotes
time-range instead of chapter. Negative
timestamps are calculated from the end.
"*from-url" can be used to download between
the "start_time" and "end_time" extracted
from the URL. Needs ffmpeg. This option can
be used multiple times to download multiple
sections, e.g. --download-sections
"*10:15-inf" --download-sections "intro"
--downloader [PROTO:]NAME Name or path of the external downloader to
use (optionally) prefixed by the protocols
(http, ftp, m3u8, dash, rstp, rtmp, mms) to
use it for. Currently supports native,
aria2c, axel, curl, ffmpeg, httpie, wget.
You can use this option multiple times to
set different downloaders for different
protocols. E.g. --downloader aria2c
--downloader "dash,m3u8:native" will use
aria2c for http/ftp downloads, and the
native downloader for dash/m3u8 downloads
(Alias: --external-downloader)
--downloader-args NAME:ARGS Give these arguments to the external
downloader. Specify the downloader name and
the arguments separated by a colon ":". For
ffmpeg, arguments can be passed to different
positions using the same syntax as
--postprocessor-args. You can use this
option multiple times to give different
arguments to different downloaders (Alias:
--external-downloader-args)
Filesystem Options:
-a, --batch-file FILE File containing URLs to download ("-" for
stdin), one URL per line. Lines starting
with "#", ";" or "]" are considered as
comments and ignored
--no-batch-file Do not read URLs from batch file (default)
-P, --paths [TYPES:]PATH The paths where the files should be
downloaded. Specify the type of file and the
path separated by a colon ":". All the same
TYPES as --output are supported.
Additionally, you can also provide "home"
(default) and "temp" paths. All intermediary
files are first downloaded to the temp path
and then the final files are moved over to
the home path after download is finished.
This option is ignored if --output is an
absolute path
-o, --output [TYPES:]TEMPLATE Output filename template; see "OUTPUT
TEMPLATE" for details
--output-na-placeholder TEXT Placeholder for unavailable fields in
--output (default: "NA")
--restrict-filenames Restrict filenames to only ASCII characters,
and avoid "&" and spaces in filenames
--no-restrict-filenames Allow Unicode characters, "&" and spaces in
filenames (default)
--windows-filenames Force filenames to be Windows-compatible
--no-windows-filenames Sanitize filenames only minimally
--trim-filenames LENGTH Limit the filename length (excluding
extension) to the specified number of
characters
-w, --no-overwrites Do not overwrite any files
--force-overwrites Overwrite all video and metadata files. This
option includes --no-continue
--no-force-overwrites Do not overwrite the video, but overwrite
related files (default)
-c, --continue Resume partially downloaded files/fragments
(default)
--no-continue Do not resume partially downloaded
fragments. If the file is not fragmented,
restart download of the entire file
--part Use .part files instead of writing directly
into output file (default)
--no-part Do not use .part files - write directly into
output file
--mtime Use the Last-modified header to set the file
modification time
--no-mtime Do not use the Last-modified header to set
the file modification time (default)
--write-description Write video description to a .description
file
--no-write-description Do not write video description (default)
--write-info-json Write video metadata to a .info.json file
(this may contain personal information)
--no-write-info-json Do not write video metadata (default)
--write-playlist-metafiles Write playlist metadata in addition to the
video metadata when using --write-info-json,
--write-description etc. (default)
--no-write-playlist-metafiles Do not write playlist metadata when using
--write-info-json, --write-description etc.
--clean-info-json Remove some internal metadata such as
filenames from the infojson (default)
--no-clean-info-json Write all fields to the infojson
--write-comments Retrieve video comments to be placed in the
infojson. The comments are fetched even
without this option if the extraction is
known to be quick (Alias: --get-comments)
--no-write-comments Do not retrieve video comments unless the
extraction is known to be quick (Alias:
--no-get-comments)
--load-info-json FILE JSON file containing the video information
(created with the "--write-info-json"
option)
--cookies FILE Netscape formatted file to read cookies from
and dump cookie jar in
--no-cookies Do not read/dump cookies from/to file
(default)
--cookies-from-browser BROWSER[+KEYRING][:PROFILE][::CONTAINER]
The name of the browser to load cookies
from. Currently supported browsers are:
brave, chrome, chromium, edge, firefox,
opera, safari, vivaldi, whale. Optionally,
the KEYRING used for decrypting Chromium
cookies on Linux, the name/path of the
PROFILE to load cookies from, and the
CONTAINER name (if Firefox) ("none" for no
container) can be given with their
respective separators. By default, all
containers of the most recently accessed
profile are used. Currently supported
keyrings are: basictext, gnomekeyring,
kwallet, kwallet5, kwallet6
--no-cookies-from-browser Do not load cookies from browser (default)
--cache-dir DIR Location in the filesystem where yt-dlp can
store some downloaded information (such as
client ids and signatures) permanently. By
default ${XDG_CACHE_HOME}/yt-dlp
--no-cache-dir Disable filesystem caching
--rm-cache-dir Delete all filesystem cache files
Thumbnail Options:
--write-thumbnail Write thumbnail image to disk
--no-write-thumbnail Do not write thumbnail image to disk
(default)
--write-all-thumbnails Write all thumbnail image formats to disk
--list-thumbnails List available thumbnails of each video.
Simulate unless --no-simulate is used
Internet Shortcut Options:
--write-link Write an internet shortcut file, depending
on the current platform (.url, .webloc or
.desktop). The URL may be cached by the OS
--write-url-link Write a .url Windows internet shortcut. The
OS caches the URL based on the file path
--write-webloc-link Write a .webloc macOS internet shortcut
--write-desktop-link Write a .desktop Linux internet shortcut
Verbosity and Simulation Options:
-q, --quiet Activate quiet mode. If used with --verbose,
print the log to stderr
--no-quiet Deactivate quiet mode. (Default)
--no-warnings Ignore warnings
-s, --simulate Do not download the video and do not write
anything to disk
--no-simulate Download the video even if printing/listing
options are used
--ignore-no-formats-error Ignore "No video formats" error. Useful for
extracting metadata even if the videos are
not actually available for download
(experimental)
--no-ignore-no-formats-error Throw error when no downloadable video
formats are found (default)
--skip-download Do not download the video but write all
related files (Alias: --no-download)
-O, --print [WHEN:]TEMPLATE Field name or output template to print to
screen, optionally prefixed with when to
print it, separated by a ":". Supported
values of "WHEN" are the same as that of
--use-postprocessor (default: video).
Implies --quiet. Implies --simulate unless
--no-simulate or later stages of WHEN are
used. This option can be used multiple times
--print-to-file [WHEN:]TEMPLATE FILE
Append given template to the file. The
values of WHEN and TEMPLATE are the same as
that of --print. FILE uses the same syntax
as the output template. This option can be
used multiple times
-j, --dump-json Quiet, but print JSON information for each
video. Simulate unless --no-simulate is
used. See "OUTPUT TEMPLATE" for a
description of available keys
-J, --dump-single-json Quiet, but print JSON information for each
URL or infojson passed. Simulate unless
--no-simulate is used. If the URL refers to
a playlist, the whole playlist information
is dumped in a single line
--force-write-archive Force download archive entries to be written
as far as no errors occur, even if -s or
another simulation option is used (Alias:
--force-download-archive)
--newline Output progress bar as new lines
--no-progress Do not print progress bar
--progress Show progress bar, even if in quiet mode
--console-title Display progress in console titlebar
--progress-template [TYPES:]TEMPLATE
Template for progress outputs, optionally
prefixed with one of "download:" (default),
"download-title:" (the console title),
"postprocess:", or "postprocess-title:".
The video's fields are accessible under the
"info" key and the progress attributes are
accessible under "progress" key. E.g.
--console-title --progress-template
"download-
title:%(info.id)s-%(progress.eta)s"
--progress-delta SECONDS Time between progress output (default: 0)
-v, --verbose Print various debugging information
--dump-pages Print downloaded pages encoded using base64
to debug problems (very verbose)
--write-pages Write downloaded intermediary pages to files
in the current directory to debug problems
--print-traffic Display sent and read HTTP traffic
Workarounds:
--encoding ENCODING Force the specified encoding (experimental)
--legacy-server-connect Explicitly allow HTTPS connection to servers
that do not support RFC 5746 secure
renegotiation
--no-check-certificates Suppress HTTPS certificate validation
--prefer-insecure Use an unencrypted connection to retrieve
information about the video (Currently
supported only for YouTube)
--add-headers FIELD:VALUE Specify a custom HTTP header and its value,
separated by a colon ":". You can use this
option multiple times
--bidi-workaround Work around terminals that lack
bidirectional text support. Requires bidiv
or fribidi executable in PATH
--sleep-requests SECONDS Number of seconds to sleep between requests
during data extraction
--sleep-interval SECONDS Number of seconds to sleep before each
download. This is the minimum time to sleep
when used along with --max-sleep-interval
(Alias: --min-sleep-interval)
--max-sleep-interval SECONDS Maximum number of seconds to sleep. Can only
be used along with --min-sleep-interval
--sleep-subtitles SECONDS Number of seconds to sleep before each
subtitle download
Video Format Options:
-f, --format FORMAT Video format code, see "FORMAT SELECTION"
for more details
-S, --format-sort SORTORDER Sort the formats by the fields given, see
"Sorting Formats" for more details
--format-sort-reset Disregard previous user specified sort order
and reset to the default
--format-sort-force Force user specified sort order to have
precedence over all fields, see "Sorting
Formats" for more details (Alias: --S-force)
--no-format-sort-force Some fields have precedence over the user
specified sort order (default)
--video-multistreams Allow multiple video streams to be merged
into a single file
--no-video-multistreams Only one video stream is downloaded for each
output file (default)
--audio-multistreams Allow multiple audio streams to be merged
into a single file
--no-audio-multistreams Only one audio stream is downloaded for each
output file (default)
--prefer-free-formats Prefer video formats with free containers
over non-free ones of the same quality. Use
with "-S ext" to strictly prefer free
containers irrespective of quality
--no-prefer-free-formats Don't give any special preference to free
containers (default)
--check-formats Make sure formats are selected only from
those that are actually downloadable
--check-all-formats Check all formats for whether they are
actually downloadable
--no-check-formats Do not check that the formats are actually
downloadable
-F, --list-formats List available formats of each video.
Simulate unless --no-simulate is used
--merge-output-format FORMAT Containers that may be used when merging
formats, separated by "/", e.g. "mp4/mkv".
Ignored if no merge is required. (currently
supported: avi, flv, mkv, mov, mp4, webm)
Subtitle Options:
--write-subs Write subtitle file
--no-write-subs Do not write subtitle file (default)
--write-auto-subs Write automatically generated subtitle file
(Alias: --write-automatic-subs)
--no-write-auto-subs Do not write auto-generated subtitles
(default) (Alias: --no-write-automatic-subs)
--list-subs List available subtitles of each video.
Simulate unless --no-simulate is used
--sub-format FORMAT Subtitle format; accepts formats preference
separated by "/", e.g. "srt" or
"ass/srt/best"
--sub-langs LANGS Languages of the subtitles to download (can
be regex) or "all" separated by commas, e.g.
--sub-langs "en.*,ja" (where "en.*" is a
regex pattern that matches "en" followed by
0 or more of any character). You can prefix
the language code with a "-" to exclude it
from the requested languages, e.g. --sub-
langs all,-live_chat. Use --list-subs for a
list of available language tags
Authentication Options:
-u, --username USERNAME Login with this account ID
-p, --password PASSWORD Account password. If this option is left
out, yt-dlp will ask interactively
-2, --twofactor TWOFACTOR Two-factor authentication code
-n, --netrc Use .netrc authentication data
--netrc-location PATH Location of .netrc authentication data;
either the path or its containing directory.
Defaults to ~/.netrc
--netrc-cmd NETRC_CMD Command to execute to get the credentials
for an extractor.
--video-password PASSWORD Video-specific password
--ap-mso MSO Adobe Pass multiple-system operator (TV
provider) identifier, use --ap-list-mso for
a list of available MSOs
--ap-username USERNAME Multiple-system operator account login
--ap-password PASSWORD Multiple-system operator account password.
If this option is left out, yt-dlp will ask
interactively
--ap-list-mso List all supported multiple-system operators
--client-certificate CERTFILE Path to client certificate file in PEM
format. May include the private key
--client-certificate-key KEYFILE
Path to private key file for client
certificate
--client-certificate-password PASSWORD
Password for client certificate private key,
if encrypted. If not provided, and the key
is encrypted, yt-dlp will ask interactively
Post-Processing Options:
-x, --extract-audio Convert video files to audio-only files
(requires ffmpeg and ffprobe)
--audio-format FORMAT Format to convert the audio to when -x is
used. (currently supported: best (default),
aac, alac, flac, m4a, mp3, opus, vorbis,
wav). You can specify multiple rules using
similar syntax as --remux-video
--audio-quality QUALITY Specify ffmpeg audio quality to use when
converting the audio with -x. Insert a value
between 0 (best) and 10 (worst) for VBR or a
specific bitrate like 128K (default 5)
--remux-video FORMAT Remux the video into another container if
necessary (currently supported: avi, flv,
gif, mkv, mov, mp4, webm, aac, aiff, alac,
flac, m4a, mka, mp3, ogg, opus, vorbis,
wav). If the target container does not
support the video/audio codec, remuxing will
fail. You can specify multiple rules; e.g.
"aac>m4a/mov>mp4/mkv" will remux aac to m4a,
mov to mp4 and anything else to mkv
--recode-video FORMAT Re-encode the video into another format if
necessary. The syntax and supported formats
are the same as --remux-video
--postprocessor-args NAME:ARGS Give these arguments to the postprocessors.
Specify the postprocessor/executable name
and the arguments separated by a colon ":"
to give the argument to the specified
postprocessor/executable. Supported PP are:
Merger, ModifyChapters, SplitChapters,
ExtractAudio, VideoRemuxer, VideoConvertor,
Metadata, EmbedSubtitle, EmbedThumbnail,
SubtitlesConvertor, ThumbnailsConvertor,
FixupStretched, FixupM4a, FixupM3u8,
FixupTimestamp and FixupDuration. The
supported executables are: AtomicParsley,
FFmpeg and FFprobe. You can also specify
"PP+EXE:ARGS" to give the arguments to the
specified executable only when being used by
the specified postprocessor. Additionally,
for ffmpeg/ffprobe, "_i"/"_o" can be
appended to the prefix optionally followed
by a number to pass the argument before the
specified input/output file, e.g. --ppa
"Merger+ffmpeg_i1:-v quiet". You can use
this option multiple times to give different
arguments to different postprocessors.
(Alias: --ppa)
-k, --keep-video Keep the intermediate video file on disk
after post-processing
--no-keep-video Delete the intermediate video file after
post-processing (default)
--post-overwrites Overwrite post-processed files (default)
--no-post-overwrites Do not overwrite post-processed files
--embed-subs Embed subtitles in the video (only for mp4,
webm and mkv videos)
--no-embed-subs Do not embed subtitles (default)
--embed-thumbnail Embed thumbnail in the video as cover art
--no-embed-thumbnail Do not embed thumbnail (default)
--embed-metadata Embed metadata to the video file. Also
embeds chapters/infojson if present unless
--no-embed-chapters/--no-embed-info-json are
used (Alias: --add-metadata)
--no-embed-metadata Do not add metadata to file (default)
(Alias: --no-add-metadata)
--embed-chapters Add chapter markers to the video file
(Alias: --add-chapters)
--no-embed-chapters Do not add chapter markers (default) (Alias:
--no-add-chapters)
--embed-info-json Embed the infojson as an attachment to
mkv/mka video files
--no-embed-info-json Do not embed the infojson as an attachment
to the video file
--parse-metadata [WHEN:]FROM:TO
Parse additional metadata like title/artist
from other fields; see "MODIFYING METADATA"
for details. Supported values of "WHEN" are
the same as that of --use-postprocessor
(default: pre_process)
--replace-in-metadata [WHEN:]FIELDS REGEX REPLACE
Replace text in a metadata field using the
given regex. This option can be used
multiple times. Supported values of "WHEN"
are the same as that of --use-postprocessor
(default: pre_process)
--xattrs Write metadata to the video file's xattrs
(using Dublin Core and XDG standards)
--concat-playlist POLICY Concatenate videos in a playlist. One of
"never", "always", or "multi_video"
(default; only when the videos form a single
show). All the video files must have the
same codecs and number of streams to be
concatenable. The "pl_video:" prefix can be
used with "--paths" and "--output" to set
the output filename for the concatenated
files. See "OUTPUT TEMPLATE" for details
--fixup POLICY Automatically correct known faults of the
file. One of never (do nothing), warn (only
emit a warning), detect_or_warn (the
default; fix the file if we can, warn
otherwise), force (try fixing even if the
file already exists)
--ffmpeg-location PATH Location of the ffmpeg binary; either the
path to the binary or its containing
directory
--exec [WHEN:]CMD Execute a command, optionally prefixed with
when to execute it, separated by a ":".
Supported values of "WHEN" are the same as
that of --use-postprocessor (default:
after_move). The same syntax as the output
template can be used to pass any field as
arguments to the command. If no fields are
passed, %(filepath,_filename|)q is appended
to the end of the command. This option can
be used multiple times
--no-exec Remove any previously defined --exec
--convert-subs FORMAT Convert the subtitles to another format
(currently supported: ass, lrc, srt, vtt).
Use "--convert-subs none" to disable
conversion (default) (Alias: --convert-
subtitles)
--convert-thumbnails FORMAT Convert the thumbnails to another format
(currently supported: jpg, png, webp). You
can specify multiple rules using similar
syntax as "--remux-video". Use "--convert-
thumbnails none" to disable conversion
(default)
--split-chapters Split video into multiple files based on
internal chapters. The "chapter:" prefix can
be used with "--paths" and "--output" to set
the output filename for the split files. See
"OUTPUT TEMPLATE" for details
--no-split-chapters Do not split video based on chapters
(default)
--remove-chapters REGEX Remove chapters whose title matches the
given regular expression. The syntax is the
same as --download-sections. This option can
be used multiple times
--no-remove-chapters Do not remove any chapters from the file
(default)
--force-keyframes-at-cuts Force keyframes at cuts when
downloading/splitting/removing sections.
This is slow due to needing a re-encode, but
the resulting video may have fewer artifacts
around the cuts
--no-force-keyframes-at-cuts Do not force keyframes around the chapters
when cutting/splitting (default)
--use-postprocessor NAME[:ARGS]
The (case-sensitive) name of plugin
postprocessors to be enabled, and
(optionally) arguments to be passed to it,
separated by a colon ":". ARGS are a
semicolon ";" delimited list of NAME=VALUE.
The "when" argument determines when the
postprocessor is invoked. It can be one of
"pre_process" (after video extraction),
"after_filter" (after video passes filter),
"video" (after --format; before
--print/--output), "before_dl" (before each
video download), "post_process" (after each
video download; default), "after_move"
(after moving the video file to its final
location), "after_video" (after downloading
and processing all formats of a video), or
"playlist" (at end of playlist). This option
can be used multiple times to add different
postprocessors
SponsorBlock Options:
Make chapter entries for, or remove various segments (sponsor,
introductions, etc.) from downloaded YouTube videos using the
SponsorBlock API (https://sponsor.ajay.app)
--sponsorblock-mark CATS SponsorBlock categories to create chapters
for, separated by commas. Available
categories are sponsor, intro, outro,
selfpromo, preview, filler, interaction,
music_offtopic, hook, poi_highlight,
chapter, all and default (=all). You can
prefix the category with a "-" to exclude
it. See [1] for descriptions of the
categories. E.g. --sponsorblock-mark
all,-preview [1] https://wiki.sponsor.ajay.a
pp/w/Segment_Categories
--sponsorblock-remove CATS SponsorBlock categories to be removed from
the video file, separated by commas. If a
category is present in both mark and remove,
remove takes precedence. The syntax and
available categories are the same as for
--sponsorblock-mark except that "default"
refers to "all,-filler" and poi_highlight,
chapter are not available
--sponsorblock-chapter-title TEMPLATE
An output template for the title of the
SponsorBlock chapters created by
--sponsorblock-mark. The only available
fields are start_time, end_time, category,
categories, name, category_names. Defaults
to "[SponsorBlock]: %(category_names)l"
--no-sponsorblock Disable both --sponsorblock-mark and
--sponsorblock-remove
--sponsorblock-api URL SponsorBlock API location, defaults to
https://sponsor.ajay.app
Extractor Options:
--extractor-retries RETRIES Number of retries for known extractor errors
(default is 3), or "infinite"
--allow-dynamic-mpd Process dynamic DASH manifests (default)
(Alias: --no-ignore-dynamic-mpd)
--ignore-dynamic-mpd Do not process dynamic DASH manifests
(Alias: --no-allow-dynamic-mpd)
--hls-split-discontinuity Split HLS playlists to different formats at
discontinuities such as ad breaks
--no-hls-split-discontinuity Do not split HLS playlists into different
formats at discontinuities such as ad breaks
(default)
--extractor-args IE_KEY:ARGS Pass ARGS arguments to the IE_KEY extractor.
See "EXTRACTOR ARGUMENTS" for details. You
can use this option multiple times to give
arguments for different extractors
Preset Aliases:
Predefined aliases for convenience and ease of use. Note that future
versions of yt-dlp may add or adjust presets, but the existing preset
names will not be changed or removed
-t mp3 -f 'ba[acodec^=mp3]/ba/b' -x --audio-format
mp3
-t aac -f
'ba[acodec^=aac]/ba[acodec^=mp4a.40.]/ba/b'
-x --audio-format aac
-t mp4 --merge-output-format mp4 --remux-video mp4
-S vcodec:h264,lang,quality,res,fps,hdr:12,a
codec:aac
-t mkv --merge-output-format mkv --remux-video mkv
-t sleep --sleep-subtitles 5 --sleep-requests 0.75
--sleep-interval 10 --max-sleep-interval 20
See full documentation at https://github.com/yt-dlp/yt-dlp#readme
+84
View File
@@ -0,0 +1,84 @@
import sys
from PIL import Image, ImageDraw
def process_images(src_path):
img = Image.open(src_path).convert("RGBA")
# Crop the 28px black padding
img = img.crop((28, 28, 1226, 1226))
width, height = img.size
pixels = img.load()
# Lighter color (+1) at top, original color (0) at bottom
bg_color = pixels[100, 100]
# Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect
top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255)
bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255)
new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load()
for y in range(height):
ratio = y / float(height - 1)
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
grad_color = (grad_r, grad_g, grad_b, 255)
for x in range(width):
p = pixels[x, y]
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
# Replace pure black corners or background with gradient
if p[0] < 15 and p[1] < 15 and p[2] < 15:
new_pixels[x, y] = grad_color
elif dist < 15:
new_pixels[x, y] = grad_color
elif dist < 60:
alpha = (dist - 15) / 45.0
r = int(p[0] * alpha + grad_color[0] * (1 - alpha))
g = int(p[1] * alpha + grad_color[1] * (1 - alpha))
b = int(p[2] * alpha + grad_color[2] * (1 - alpha))
new_pixels[x, y] = (r, g, b, 255)
else:
new_pixels[x, y] = p
img = new_img
radius = int(width * 0.225)
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
img.putalpha(mask)
# Save standard png
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
img_1024.save("Resources/AppIcon.png")
# Save Firefox extension icons
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
img_48.save("Extensions/Firefox/icons/icon-48.png")
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
img_128.save("Extensions/Firefox/icons/icon-128.png")
# MenuBarIconTemplate (64x64 monochrome)
data = img.getdata()
new_data = []
for item in data:
r, g, b, a = item
if r > 100 and r > b * 1.5 and a > 0:
alpha = min(255, max(0, int((r - 40) * 1.2)))
new_data.append((0, 0, 0, alpha))
else:
new_data.append((0, 0, 0, 0))
menu_bar_full = Image.new("RGBA", img.size)
menu_bar_full.putdata(new_data)
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
print("Done generating main PNGs")
if __name__ == '__main__':
process_images(sys.argv[1])
+1
View File
File diff suppressed because one or more lines are too long
+50
View File
@@ -0,0 +1,50 @@
import Foundation
struct RawMediaFormat: Decodable {
let format_id: String?
}
struct MediaMetadata: Decodable {
let id: String?
let formats: [RawMediaFormat]?
}
let executableURL = URL(fileURLWithPath: "/Users/nima/Documents/Code/Firelink/Sources/Firelink/yt-dlp")
let arguments = [
"-J",
"--no-warnings",
"--ignore-no-formats-error",
"--no-playlist",
"--force-ipv4",
"https://www.youtube.com/watch?v=jNQXAC9IVRw"
]
let process = Process()
let outputPipe = Pipe()
let errorPipe = Pipe()
process.executableURL = executableURL
process.arguments = arguments
process.standardOutput = outputPipe
process.standardError = errorPipe
do {
try process.run()
process.waitUntilExit()
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
if process.terminationStatus == 0 {
do {
let metadata = try JSONDecoder().decode(MediaMetadata.self, from: outputData)
print("Successfully decoded metadata with \(metadata.formats?.count ?? 0) formats")
} catch {
print("Decoding failed: \(error)")
}
} else {
print("Failed! Exit code: \(process.terminationStatus)")
print(String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "")
}
} catch {
print("Process run threw error: \(error.localizedDescription)")
}
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long