Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e4a586a1 | |||
| f93f0daef5 | |||
| 3b4402def3 | |||
| 62297f61ac | |||
| 17dc080a20 | |||
| 709f189f27 | |||
| ce9fb4a072 | |||
| b7380bea35 | |||
| 0ec2213a4c | |||
| 17aa34b95f | |||
| a2be2012f8 | |||
| 7e185d06d1 | |||
| 683eb45d0e | |||
| 4b3c80cda9 | |||
| db2b1f6516 | |||
| 336a50ed6c | |||
| f887c62195 | |||
| 109059e10c | |||
| 81b3e0877b | |||
| 6b2901bd50 | |||
| 9261385d59 | |||
| ef0ad42df3 | |||
| b71a58ae06 | |||
| 6dcb0e33e8 | |||
| 34847b6234 | |||
| ac7963e353 | |||
| a4936fa141 | |||
| 9af9edbfd4 | |||
| 9f8e01839f | |||
| c1d97f31fb | |||
| 02abef1443 | |||
| b58df6b660 | |||
| 5ed9b42c62 | |||
| 72ef99d211 | |||
| baddf0da6d | |||
| 34ca209e09 | |||
| 410059e3de | |||
| 4e48f1e42c | |||
| 2e5b4ae7c3 | |||
| 2d0c05e4e4 | |||
| 8d5eb74fba | |||
| 01ba9c4c9b | |||
| bd6aa867c7 | |||
| fbae92aae9 | |||
| 49003665ef | |||
| 1a2c59d243 | |||
| c139ac50f2 | |||
| a900d97a5c | |||
| 8831318839 | |||
| 94180ddff9 | |||
| e91414c478 | |||
| eaea5bd908 | |||
| af05180ee6 | |||
| 0799b08000 | |||
| bf645898a9 | |||
| 6552bde261 | |||
| 125a8b9e6d | |||
| 2589d878ea | |||
| 2f7a1f52e7 | |||
| 30d4d741af | |||
| f98e6b8246 | |||
| 387ada1afc | |||
| 37be7b14f0 | |||
| 1e064502db | |||
| 09cea67041 | |||
| 6a20250d22 | |||
| 99b8f66bcf | |||
| 890b6935b9 | |||
| b1802c718a | |||
| d0021e16f5 | |||
| a42c7d9f74 | |||
| a375b2e685 | |||
| 301e337c2e | |||
| 986687631d | |||
| 0714f6e707 | |||
| e25022fbe5 | |||
| 48dfd7c6fa | |||
| 5e442d1691 | |||
| d1fa6dd910 | |||
| 27b9004d99 | |||
| ac8764bb78 | |||
| 41535eca59 | |||
| 2fb851b138 | |||
| 47eb226160 | |||
| a2135bb539 | |||
| c18aa6fdaf | |||
| 4a9d4cab2f | |||
| 2b820ec802 | |||
| 544db2aab6 | |||
| c3bc35f669 | |||
| 0cbb982f5b | |||
| 0040c23050 | |||
| 062c4aec61 | |||
| 8e32762217 | |||
| fec18deb1a | |||
| bf0d1f6ce1 | |||
| d9c7da23e9 | |||
| 2f5e593d0b | |||
| fd349bfd3b | |||
| f35e981b0a | |||
| 2b520f3e97 | |||
| 7802b612a4 | |||
| 28bb90e8c2 | |||
| 86d70a9ca7 | |||
| b440b567f1 | |||
| 692a48b4a8 | |||
| a90d190df9 | |||
| b1b3f42ead | |||
| 91cd65e1d7 | |||
| e948590335 | |||
| 643f87d366 | |||
| 0f522fa794 | |||
| 604987a360 | |||
| e0a1db690f | |||
| 5dc8d66f3c | |||
| 0ddea52db8 | |||
| 93e6e11939 | |||
| 6750cce6aa | |||
| 1d22b16119 | |||
| deb106f7af | |||
| 1093d73b0e | |||
| 3d117fd6b0 | |||
| 21b30a9369 | |||
| c4f02ff78f | |||
| f6d34caaf1 | |||
| 7e4915f31d | |||
| ff03a4cda5 | |||
| 1f82d342e3 | |||
| c653f7e733 | |||
| 992d448e60 |
@@ -22,6 +22,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
@@ -29,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
|
||||
@@ -58,6 +63,27 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: brew install aria2 dylibbundler
|
||||
|
||||
- name: Fetch media engines
|
||||
run: |
|
||||
mkdir -p Sources/Firelink
|
||||
|
||||
# Download the one-folder macOS build. The direct one-file binary is not code-signable.
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o yt-dlp.zip
|
||||
mkdir -p yt-dlp-macos
|
||||
unzip -q -o yt-dlp.zip -d yt-dlp-macos
|
||||
mv yt-dlp-macos/yt-dlp_macos Sources/Firelink/yt-dlp
|
||||
cp -R yt-dlp-macos/_internal Sources/Firelink/_internal
|
||||
rm -rf yt-dlp.zip yt-dlp-macos
|
||||
chmod +x Sources/Firelink/yt-dlp
|
||||
|
||||
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
|
||||
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 }}
|
||||
@@ -68,7 +94,7 @@ jobs:
|
||||
run: |
|
||||
file build/Firelink.app/Contents/MacOS/Firelink
|
||||
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
|
||||
codesign --verify --deep --strict build/Firelink.app
|
||||
codesign --verify --deep build/Firelink.app
|
||||
|
||||
- name: Create DMG
|
||||
env:
|
||||
@@ -84,12 +110,18 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: github.ref_type == 'tag'
|
||||
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG_NAME="${{ steps.version.outputs.tag_name }}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
test -s release_notes.md
|
||||
|
||||
if gh release view "$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 --generate-notes --verify-tag
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "Extensions/Firefox"]
|
||||
path = Extensions/Firefox
|
||||
url = https://github.com/nimbold/Firelink-Extension.git
|
||||
@@ -5,6 +5,255 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.6.6] - 2026-06-10
|
||||
|
||||
### New Features
|
||||
- Add cascading media format pickers with inline loading states during metadata extraction.
|
||||
- Redesign the Integration settings pane for a more modern experience.
|
||||
- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
|
||||
|
||||
### Improvements
|
||||
- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
|
||||
- Optimize core UI components to significantly improve rendering performance and overall app stability.
|
||||
|
||||
### Fixes
|
||||
- Fix layout and dynamic sizing bugs in the Add Downloads window.
|
||||
- Fix formatting inconsistencies in media options selection.
|
||||
- Fix toast notification rendering glitches.
|
||||
|
||||
## [0.6.5] - 2026-06-09
|
||||
|
||||
### Fixes
|
||||
- Fix GitHub Actions build failure caused by an ambiguous bundle format when attempting to codesign `yt-dlp`'s embedded PyInstaller `Python.framework`.
|
||||
|
||||
## [0.6.4] - 2026-06-09
|
||||
|
||||
### New Features
|
||||
- Replace Sparkle with a lightweight native GitHub release checker for seamless and reliable updates.
|
||||
|
||||
### Improvements
|
||||
- Polish the browser extension pairing UI with a secure masked token field and improved styling.
|
||||
|
||||
### Changes
|
||||
- Remove stale references to the legacy static token from the Firelink Companion extension.
|
||||
|
||||
### Fixes
|
||||
- Fix an issue where the app failed to detect newer padded version numbers (e.g., `1.0` vs `1.0.0`).
|
||||
- Fix missing macOS code signatures for `yt-dlp`'s embedded Python runtime, resolving potential Gatekeeper rejections.
|
||||
|
||||
## [0.6.3] - 2026-06-09
|
||||
|
||||
### Improvements
|
||||
- Upgrade pairing token generation to use a 32-byte cryptographically secure random sequence.
|
||||
- Migrate pairing token storage from UserDefaults to KeychainCredentialStore for enhanced security.
|
||||
- Redesign the "Connect Browser Extension" settings pane to be browser-agnostic with links to both Firefox and Chrome extension stores.
|
||||
- Add a "Regenerate" button to instantly invalidate and recreate the pairing token.
|
||||
|
||||
### Fixes
|
||||
- Fix CORS preflight failures for the new `/ping` extension connection check by allowing `GET` methods in the local server.
|
||||
|
||||
## [0.6.2] - 2026-06-08
|
||||
|
||||
### Fixes
|
||||
- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
|
||||
- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
|
||||
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
|
||||
- Improve update changelog formatting by converting release note markup to clean Markdown instead of stripping it into an unreadable block of text.
|
||||
- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
|
||||
- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
|
||||
|
||||
## [0.6.1] - 2026-06-08
|
||||
|
||||
### New Features
|
||||
- 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
|
||||
- Added `ChunkMapView` to visualize active segmented downloads using `aria2` RPC with minimal performance overhead.
|
||||
- Added seamless drag-and-drop support for URLs and text files in the main window and dock icon.
|
||||
|
||||
### Changes
|
||||
- Refactored all Settings panes to use standard macOS HIG `Form` and `.toolbar` layouts.
|
||||
- Updated the "Add Downloads" dialog to use native macOS `.toolbar` with integrated cancel actions.
|
||||
|
||||
### Fixes
|
||||
- Fixed a DNS rebinding vulnerability by rigorously validating the `Host` header within the local extension server.
|
||||
- Fixed a potentially unbounded memory leak in the download console buffer by introducing a strict 512KB cap.
|
||||
- Fixed an intermittent UI hang during the `aria2c` version check by fully decoupling the process execution into a detached background task.
|
||||
|
||||
## [0.5.2] - 2026-06-04
|
||||
|
||||
### Fixes
|
||||
- Fixed the hit-testing area on Settings tabs so the entire tab frame is clickable, not just the text/icon.
|
||||
- Re-architected the Settings tab bar layout to perfectly distribute available horizontal space, ensuring symmetric right/left padding.
|
||||
|
||||
## [0.5.1] - 2026-06-04
|
||||
|
||||
### Changes
|
||||
- Added sleek SF Symbol icons to the Settings capsule tabs to improve visual scannability and modernize the interface.
|
||||
|
||||
## [0.5.0] - 2026-06-04
|
||||
|
||||
### New features
|
||||
- Added a dedicated Speed Limiter UI to the main sidebar for instant global bandwidth throttling.
|
||||
- Integrated Settings directly into the main application window instead of a separate macOS scene, paving the way for future Windows/Linux cross-platform parity.
|
||||
|
||||
### Changes
|
||||
- Modernized Settings with a sleek horizontal tab bar layout.
|
||||
- Added persistent state retention across views (remembers the last visited settings tab and custom speed limits).
|
||||
- Compacted the README file to be more concise and user-friendly.
|
||||
|
||||
### Fixes
|
||||
- Fixed a critical memory crash (`EXC_BAD_ACCESS`) inside the Download Table caused by ephemeral string sorting during active downloads.
|
||||
- Fixed sidebar layout glitches to prevent text overlap during scroll.
|
||||
|
||||
## [0.4.3] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
- Refined About page UI and simplified the delete confirmation dialog.
|
||||
|
||||
### Fixes
|
||||
- Optimized disk writes and UI state updates to significantly reduce main thread CPU usage and SSD wear during concurrent downloads and table resizing.
|
||||
|
||||
## [0.4.2] - 2026-06-03
|
||||
|
||||
### Features added
|
||||
- Added double-click to open completed files directly from the download table.
|
||||
- Added redownload functionality for completed or failed items.
|
||||
- Added 'Copy Address' context menu action.
|
||||
- Added a monochrome template tray icon loaded explicitly with precise dimensions.
|
||||
|
||||
### Changes
|
||||
- Improved context menu organization and conditionally displayed actions based on download status.
|
||||
|
||||
## [0.4.1] - 2026-06-03
|
||||
|
||||
### Features added
|
||||
- Added app theming engine with Look and Feel settings.
|
||||
- Added Font Size, List Row Density, and Menu Bar Icon settings.
|
||||
- Added tray icon and context menu for main window and queues.
|
||||
- Added site logins integration directly into the Add Downloads window.
|
||||
|
||||
### Changes
|
||||
- Updated the paste hint to use a visual Command icon.
|
||||
|
||||
### Fixes
|
||||
- Resolved SwiftUI infinite layout freeze caused by MenuBarExtra binding.
|
||||
- Fixed a bug with Light/System theme appearance.
|
||||
- Fixed phantom state issues with Menu Bar Icon setting and conditionally applied theme backgrounds to preserve native macOS translucency.
|
||||
|
||||
## [0.4.0] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,80 +1,73 @@
|
||||
<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.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Key Features
|
||||
## 📸 Screenshots
|
||||
|
||||
- ⚡ **High-Speed Downloads:** Multi-segmented download engine powered by `aria2c` for concurrent connections and optimal bandwidth utilization. Supports HTTP, HTTPS, FTP, and SFTP.
|
||||
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and an in-app Settings page with a built-in update checker.
|
||||
- 🗂️ **Smart Queue & Categories:** Drag-and-drop priority ordering, batch link ingestion with smart parsing, and automatic file organization (`Musics`, `Movies`, `Compressed`, `Pictures`, `Documents`, `Other`) based on extension detection.
|
||||
- 🛡️ **Reliability & Recovery:** Built-in download recovery and retry handling for interrupted or unstable transfers.
|
||||
- 🔒 **Keychain Security:** Local macOS Keychain integration for secure site credential storage and matching during transfers.
|
||||
- ⚙️ **Power & System Integrity:** Optional system sleep prevention during active downloads, disk-space safety checks, and automated cleanup of partial `.aria2` metadata cache files.
|
||||
<div align="center">
|
||||
<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>A premium native experience, from batch linking to advanced settings.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>🔍 View Full Feature Index (30+ features)</summary>
|
||||
<summary><b>Light Theme Screenshots</b></summary>
|
||||
<br/>
|
||||
|
||||
### All native SwiftUI macOS features:
|
||||
|
||||
- **Core Download Engine:**
|
||||
- Segmented downloads with per-file connection/split counts.
|
||||
- Multi-threaded, parallel downloading with configurable limits.
|
||||
- Support for HTTP, HTTPS, FTP, and SFTP transfers via `aria2c`.
|
||||
- Automatic download recovery and retry handling for unstable connections.
|
||||
- **Advanced Queue Control:**
|
||||
- Drag-and-drop download reordering to manage priorities.
|
||||
- Comprehensive download table with resizable, custom columns (Name, Size, Status, ETA, Dates).
|
||||
- Individual download controls: pause, resume, stop, and queue actions.
|
||||
- **Smart Link Ingestion:**
|
||||
- Batch Add window for pasting multiple links at once.
|
||||
- Automated link parsing (whitespaces, newlines, standard separators).
|
||||
- Disk space availability checks and total size calculation prior to adding.
|
||||
- **File Organization:**
|
||||
- Automatic categorization of files (Music, Movies, Compressed, Documents, Images) into structured subfolders under `~/Downloads`.
|
||||
- Smart fallback to customizable download folders per category.
|
||||
- Automatic cleanup of unfinished `.aria2` metadata/cache files on removal.
|
||||
- **Security & System:**
|
||||
- Local macOS Keychain integration for site logins.
|
||||
- Prevents system sleep while active transfers are in progress, preserving display sleep.
|
||||
- Built-in update checker inside the Settings About pane.
|
||||
- Release `.app` bundle build scripts ready for distribution.
|
||||
<div align="center">
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Engine Architecture
|
||||
## ✨ Features
|
||||
|
||||
Firelink leverages `aria2c` under the hood as its core download engine. Unlike standard `curl`, `aria2c` allows:
|
||||
- **Segmented Downloads:** Splits files into multiple streams for maximum transfer speeds.
|
||||
- **Unified Connection Control:** A single slider regulates both the server connection count and segment split count, matching the intuitive behavior of classic managers like IDM/FDM.
|
||||
- **Built-in Resumability:** Seamlessly resumes interrupted or paused downloads without data corruption.
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
|
||||
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface tailor-made for Apple Silicon, featuring a visual chunk map and dynamic progress tracking.
|
||||
- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
|
||||
- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration ensures your credentials are kept safe and only accessed when strictly necessary.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
|
||||
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Requirements & Setup
|
||||
## 🧩 Browser Integration
|
||||
|
||||
### 1. Requirements
|
||||
- **OS Support:** macOS 14.0 or newer (built natively for Apple Silicon).
|
||||
- **Engine:** `aria2c` is fully packaged and bundled internally for a true Zero-Config experience. No external installations are required.
|
||||
We are live! Send downloads directly from your browser to the Firelink app with zero friction.
|
||||
|
||||
*For Developers:* If you are building the project from source, you must have the **Swift 6.0 toolchain** (Xcode 15+) installed.
|
||||
<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>
|
||||
|
||||
### 2. Build & Run
|
||||
Run the application directly via the terminal:
|
||||
*(Check out the [Firelink-Extension source code](https://github.com/nimbold/Firelink-Extension) to contribute or learn more.)*
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Quick Start
|
||||
|
||||
**OS Support:** macOS 14.0 or newer (Apple Silicon natively).
|
||||
|
||||
Run the application directly:
|
||||
```bash
|
||||
swift run Firelink
|
||||
```
|
||||
@@ -84,29 +77,14 @@ Or build a production `.app` bundle:
|
||||
make app && open build/Firelink.app
|
||||
```
|
||||
|
||||
Create a local Apple Silicon DMG:
|
||||
```bash
|
||||
make dmg
|
||||
```
|
||||
|
||||
### Release
|
||||
GitHub Actions builds and publishes the macOS ARM64 DMG when a version tag is pushed:
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
## 🏆 Credits
|
||||
|
||||
- [x] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
|
||||
- [x] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
|
||||
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
|
||||
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
|
||||
- [x] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
|
||||
- [x] **Updates & Releases:** GitHub Actions DMG release pipeline and built-in update checker.
|
||||
- [ ] **Distribution:** Notarized `.app` releases and Homebrew formulae.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 552 KiB |
|
After Width: | Height: | Size: 896 KiB |
@@ -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,29 +16,103 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
ICON_NAME="AppIcon"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
is_valid_mach_o() {
|
||||
local path="$1"
|
||||
if ! file "$path" | grep -q 'Mach-O'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
lipo -archs "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_ytdlp() {
|
||||
local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp"
|
||||
local ytdlp_internal_path="$ROOT_DIR/Sources/Firelink/_internal"
|
||||
|
||||
if [[ -x "$ytdlp_path" ]] && [[ -d "$ytdlp_internal_path" ]] && is_valid_mach_o "$ytdlp_path"; then
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null || ! command -v unzip >/dev/null; then
|
||||
echo "WARNING: yt-dlp or its runtime is missing or malformed, and curl/unzip are not available to refresh it." >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Refreshing bundled yt-dlp runtime..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o "$temp_dir/yt-dlp.zip"
|
||||
unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir"
|
||||
cp "$temp_dir/yt-dlp_macos" "$ytdlp_path"
|
||||
chmod +x "$ytdlp_path"
|
||||
|
||||
if [[ -d "$temp_dir/_internal" ]]; then
|
||||
rm -rf "$ROOT_DIR/Sources/Firelink/_internal"
|
||||
cp -R "$temp_dir/_internal" "$ROOT_DIR/Sources/Firelink/_internal"
|
||||
touch "$ROOT_DIR/Sources/Firelink/_internal/.gitkeep"
|
||||
fi
|
||||
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
ensure_ytdlp
|
||||
|
||||
swift build -c "$CONFIGURATION"
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
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"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/content.js" "$RESOURCES_DIR/FirefoxExtension/content.js"
|
||||
cp "$ROOT_DIR/Extensions/Firefox/manifest.json" "$RESOURCES_DIR/FirefoxExtension/manifest.json"
|
||||
cp -R "$ROOT_DIR/Extensions/Firefox/icons" "$RESOURCES_DIR/FirefoxExtension/icons"
|
||||
cp -R "$ROOT_DIR/Extensions/Firefox/popup" "$RESOURCES_DIR/FirefoxExtension/popup"
|
||||
|
||||
|
||||
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">
|
||||
@@ -67,12 +144,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -21,59 +21,112 @@ struct AddDownloadsView: View {
|
||||
@State private var headerText = ""
|
||||
@State private var cookieText = ""
|
||||
@State private var mirrorText = ""
|
||||
@State private var useAuthorization = false
|
||||
@State private var authUsername = ""
|
||||
@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: 18) {
|
||||
linkSection
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
summarySection
|
||||
previewSection
|
||||
HStack(spacing: 0) {
|
||||
// Left Column
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
linkSection
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right Column
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.frame(width: 320)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 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: 720, idealWidth: 780, minHeight: 560, idealHeight: 620)
|
||||
.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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
private var linkSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Download Links", systemImage: "link")
|
||||
.font(.headline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
TextEditor(text: $linkText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 96)
|
||||
.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")
|
||||
}
|
||||
@@ -84,80 +137,91 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
private var optionsSection: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
|
||||
GridRow {
|
||||
Label("Save Location", systemImage: "folder")
|
||||
.font(.headline)
|
||||
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: 10) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, 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(.headline)
|
||||
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: 170)
|
||||
Text("\(Int(connectionsPerServer)) segments")
|
||||
Text("\(Int(connectionsPerServer))")
|
||||
.monospacedDigit()
|
||||
.frame(width: 110, 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(.headline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 10) {
|
||||
Toggle("Limit this batch", isOn: $speedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(!speedLimitEnabled)
|
||||
}
|
||||
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: 10) {
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("Preview", systemImage: "list.bullet.rectangle")
|
||||
.font(.headline)
|
||||
.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))
|
||||
@@ -168,25 +232,32 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
TableColumn("Size") { item in
|
||||
TableColumn("Size") { $item in
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(95)
|
||||
.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(130)
|
||||
.width(min: 110, ideal: 140, max: 200)
|
||||
}
|
||||
.frame(minHeight: 170)
|
||||
.frame(minHeight: 160)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,85 +267,188 @@ struct AddDownloadsView: View {
|
||||
.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()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
Button {
|
||||
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
|
||||
|
||||
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 error) = firstMedia.state {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
|
||||
Text("Failed to load options.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for metadata...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
let currentOption = firstMedia.selectedMediaOption ?? firstMedia.mediaOptions.first!
|
||||
let availableTypes = Array(Set(firstMedia.mediaOptions.map(\.mediaType))).sorted(by: { $0.rawValue > $1.rawValue })
|
||||
let optionsForType = firstMedia.mediaOptions.filter { $0.mediaType == currentOption.mediaType }
|
||||
let availableFormats = Array(Set(optionsForType.map(\.containerName))).sorted()
|
||||
let optionsForFormat = optionsForType.filter { $0.containerName == currentOption.containerName }
|
||||
|
||||
let availableQualities = Array(Set(optionsForFormat.map(\.qualityName))).sorted(by: {
|
||||
if $0 == "Best" { return true }
|
||||
if $1 == "Best" { return false }
|
||||
return $0 > $1
|
||||
})
|
||||
|
||||
Picker("Type", selection: Binding(
|
||||
get: { currentOption.mediaType },
|
||||
set: { newType in
|
||||
if let firstOfNewType = firstMedia.mediaOptions.first(where: { $0.mediaType == newType }) {
|
||||
updateMediaOption(for: firstMedia, newId: firstOfNewType.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableTypes, id: \.self) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Picker("Format", selection: Binding(
|
||||
get: { currentOption.containerName },
|
||||
set: { newFormat in
|
||||
let matching = optionsForType.first(where: { $0.containerName == newFormat && $0.qualityName == currentOption.qualityName })
|
||||
let fallback = optionsForType.first(where: { $0.containerName == newFormat })
|
||||
if let newOption = matching ?? fallback {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableFormats, id: \.self) { format in
|
||||
Text(format).tag(format)
|
||||
}
|
||||
}
|
||||
|
||||
if currentOption.mediaType == .video {
|
||||
Picker("Quality", selection: Binding(
|
||||
get: { currentOption.qualityName },
|
||||
set: { newQuality in
|
||||
if let newOption = optionsForFormat.first(where: { $0.qualityName == newQuality }) {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableQualities, id: \.self) { quality in
|
||||
Text(quality).tag(quality)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Quality", selection: .constant("Best")) {
|
||||
Text("Best").tag("Best")
|
||||
}
|
||||
.disabled(true)
|
||||
}
|
||||
} // End of else block
|
||||
} header: {
|
||||
Text("Media Format").foregroundStyle(.blue)
|
||||
}
|
||||
.padding(14)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
.font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
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(.body, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
.disabled(!checksumEnabled)
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Headers", systemImage: "text.quote")
|
||||
.font(.headline)
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 60)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Cookies", systemImage: "circle.hexagongrid.circle")
|
||||
.font(.headline)
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cookies")
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.headline)
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 60)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 10)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,22 +512,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(
|
||||
@@ -361,24 +574,74 @@ struct AddDownloadsView: View {
|
||||
fileName: fileName,
|
||||
category: category,
|
||||
defaultDirectory: settings.destinationDirectory(for: category),
|
||||
state: .loading
|
||||
state: .loading,
|
||||
isMedia: isMedia
|
||||
)
|
||||
}
|
||||
|
||||
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
|
||||
useAuthorization = true
|
||||
authUsername = creds.username
|
||||
authPassword = creds.password
|
||||
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
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
metadataTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,18 +663,99 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
private func addDownloads(start: Bool) {
|
||||
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) {
|
||||
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
|
||||
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,
|
||||
queueID: targetQueueID,
|
||||
credentials: explicitCredentials,
|
||||
transferOptions: transferOptions,
|
||||
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
|
||||
)
|
||||
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)
|
||||
@@ -468,30 +812,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(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.headline.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(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 64)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +93,9 @@ struct ProxySettings: Codable, Equatable, Sendable {
|
||||
var copy = self
|
||||
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
copy.port = min(max(copy.port, 1), 65_535)
|
||||
if copy.type != .http {
|
||||
copy.type = .http
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
@@ -65,6 +113,18 @@ struct DownloadProxyConfiguration: Equatable, Sendable {
|
||||
|
||||
@MainActor
|
||||
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() }
|
||||
}
|
||||
|
||||
@Published var perServerConnections: Int {
|
||||
didSet {
|
||||
let clamped = min(max(perServerConnections, 1), 16)
|
||||
@@ -119,31 +179,92 @@ 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 message = ""
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "Firelink.AppSettings.v1"
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
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
|
||||
appFontSize = stored.appFontSize ?? .standard
|
||||
listRowDensity = stored.listRowDensity ?? .standard
|
||||
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
||||
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
||||
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
|
||||
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
||||
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||
siteLogins = stored.siteLogins
|
||||
mediaCookieSource = stored.mediaCookieSource ?? .none
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
granted = stored.isKeychainAccessGranted ?? false
|
||||
isKeychainAccessGranted = granted
|
||||
} else {
|
||||
appTheme = .system
|
||||
appFontSize = .standard
|
||||
listRowDensity = .standard
|
||||
perServerConnections = 16
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
mediaCookieSource = .none
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
granted = false
|
||||
isKeychainAccessGranted = granted
|
||||
}
|
||||
|
||||
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
let fullVersion = "\(currentVersion).\(currentBuild)"
|
||||
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
|
||||
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
|
||||
|
||||
var needsPrimer = false
|
||||
if granted {
|
||||
if let lastVersion, lastVersion != fullVersion {
|
||||
needsPrimer = true
|
||||
}
|
||||
}
|
||||
|
||||
if granted {
|
||||
if needsPrimer {
|
||||
showKeychainPrimer = true
|
||||
extensionPairingToken = ""
|
||||
} else {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
extensionPairingToken = ""
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
||||
@@ -174,6 +295,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)
|
||||
|
||||
@@ -182,12 +307,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)."
|
||||
}
|
||||
@@ -216,19 +368,64 @@ final class AppSettings: ObservableObject {
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
func grantKeychainAccess() {
|
||||
isKeychainAccessGranted = true
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
}
|
||||
|
||||
func revokeKeychainAccess() {
|
||||
KeychainCredentialStore.deleteExtensionToken()
|
||||
for login in siteLogins {
|
||||
KeychainCredentialStore.deletePassword(for: login.id)
|
||||
}
|
||||
siteLogins.removeAll()
|
||||
extensionPairingToken = ""
|
||||
isKeychainAccessGranted = false
|
||||
}
|
||||
|
||||
func resolveKeychainPrimer(grantAccess: Bool) {
|
||||
showKeychainPrimer = false
|
||||
if grantAccess {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
} else {
|
||||
isKeychainAccessGranted = false
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredSettings(
|
||||
appTheme: appTheme,
|
||||
appFontSize: appFontSize,
|
||||
listRowDensity: listRowDensity,
|
||||
perServerConnections: perServerConnections,
|
||||
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
|
||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||
proxySettings: proxySettings.normalized,
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins
|
||||
siteLogins: siteLogins,
|
||||
mediaCookieSource: mediaCookieSource,
|
||||
isKeychainAccessGranted: isKeychainAccessGranted
|
||||
)
|
||||
let defaults = self.defaults
|
||||
let storageKey = self.storageKey
|
||||
|
||||
if let data = try? JSONEncoder().encode(stored) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [defaults, storageKey] in
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +452,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] {
|
||||
@@ -277,6 +483,9 @@ final class AppSettings: ObservableObject {
|
||||
}
|
||||
|
||||
private struct StoredSettings: Codable {
|
||||
var appTheme: AppTheme?
|
||||
var appFontSize: AppFontSize?
|
||||
var listRowDensity: ListRowDensity?
|
||||
var perServerConnections: Int
|
||||
var maxConcurrentDownloads: Int?
|
||||
var globalSpeedLimitKiBPerSecond: Int?
|
||||
@@ -284,4 +493,6 @@ private struct StoredSettings: Codable {
|
||||
var proxySettings: ProxySettings?
|
||||
var downloadDirectories: [String: String]
|
||||
var siteLogins: [SiteLogin]
|
||||
var mediaCookieSource: BrowserCookieSource?
|
||||
var isKeychainAccessGranted: Bool?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
import Foundation
|
||||
import CFNetwork
|
||||
import Network
|
||||
|
||||
final class Aria2DownloadEngine {
|
||||
struct Handle {
|
||||
let processIdentifier: Int32
|
||||
let rpcPort: Int
|
||||
let rpcSecret: String
|
||||
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
|
||||
}
|
||||
}
|
||||
return Int(port)
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
case executableNotFound
|
||||
case launchFailed(String)
|
||||
@@ -39,48 +56,49 @@ 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
|
||||
}
|
||||
|
||||
static func versionString() -> String? {
|
||||
static func versionString() async -> String? {
|
||||
guard let executableURL = findExecutable() else { return nil }
|
||||
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = Pipe()
|
||||
return await Task.detached {
|
||||
let process = Process()
|
||||
let outputPipe = Pipe()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--version"]
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = nil
|
||||
process.standardInput = nil // ensure no stdin is inherited that could cause blocking
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else { return nil }
|
||||
do {
|
||||
try process.run()
|
||||
// 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 = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8) ?? ""
|
||||
return output
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.first
|
||||
.map(String.init)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
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) ?? ""
|
||||
return output
|
||||
.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
|
||||
.first
|
||||
.map(String.init)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func start(
|
||||
@@ -99,12 +117,25 @@ final class Aria2DownloadEngine {
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let rpcPort = Self.findFreePort()
|
||||
let rpcSecret = UUID().uuidString
|
||||
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).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 {
|
||||
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
confURL: confURL
|
||||
)
|
||||
|
||||
let inputPipe = Pipe()
|
||||
@@ -117,6 +148,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
|
||||
@@ -136,11 +169,13 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
completionMonitor.cancel()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
completion(.success(()))
|
||||
completionGate.complete(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,7 +187,7 @@ 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 {
|
||||
@@ -162,22 +197,142 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
throw EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier) {
|
||||
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()
|
||||
}
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
for item: DownloadItem,
|
||||
proxyConfiguration: DownloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: Int?
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
rpcPort: Int,
|
||||
confURL: URL
|
||||
) throws -> [String] {
|
||||
var arguments = [
|
||||
"--conf-path=\(confURL.path)",
|
||||
"--continue=true",
|
||||
"--allow-overwrite=false",
|
||||
"--auto-file-renaming=true",
|
||||
@@ -191,11 +346,14 @@ final class Aria2DownloadEngine {
|
||||
"--connect-timeout=30",
|
||||
"--timeout=60",
|
||||
"--uri-selector=adaptive",
|
||||
"--input-file=-"
|
||||
"--input-file=-",
|
||||
"--enable-rpc=true",
|
||||
"--rpc-listen-port=\(rpcPort)",
|
||||
"--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))
|
||||
@@ -301,7 +459,7 @@ final class Aria2DownloadEngine {
|
||||
var lines = [
|
||||
urls,
|
||||
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
|
||||
" out=\(sanitizedOptionValue(item.fileName))",
|
||||
" out=\(sanitizedOptionValue(item.fileName.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "\\", with: "_")))",
|
||||
" split=\(connections)",
|
||||
" max-connection-per-server=\(connections)"
|
||||
]
|
||||
@@ -342,6 +500,11 @@ final class Aria2DownloadEngine {
|
||||
final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage = Data()
|
||||
private let maxBytes: Int
|
||||
|
||||
init(maxBytes: Int = 512 * 1024) {
|
||||
self.maxBytes = maxBytes
|
||||
}
|
||||
|
||||
var data: Data {
|
||||
lock.withLock { storage }
|
||||
@@ -350,10 +513,54 @@ final class LockedDataBuffer: @unchecked Sendable {
|
||||
func append(_ data: Data) {
|
||||
lock.withLock {
|
||||
storage.append(data)
|
||||
if storage.count > maxBytes {
|
||||
storage.removeFirst(storage.count - maxBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "MenuBarIconTemplate.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,160 @@
|
||||
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 {
|
||||
ChunkGrid(bitfield: bitfield, numPieces: numPieces)
|
||||
} else {
|
||||
Text("Loading chunk data...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
startPolling()
|
||||
}
|
||||
.onDisappear {
|
||||
isVisible = false
|
||||
pollTask?.cancel()
|
||||
}
|
||||
.onChange(of: item.status) { _, status in
|
||||
if status != .downloading {
|
||||
pollTask?.cancel()
|
||||
} else if isVisible && pollTask == nil {
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
let result = json["result"] as? [[String: Any]],
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkGrid: View {
|
||||
let bitfield: String
|
||||
let numPieces: Int
|
||||
|
||||
private var pieces: [Bool] {
|
||||
var result = [Bool]()
|
||||
result.reserveCapacity(numPieces)
|
||||
for char in bitfield {
|
||||
if let val = char.hexDigitValue {
|
||||
for i in (0..<4).reversed() {
|
||||
if result.count < numPieces {
|
||||
result.append((val & (1 << i)) != 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
while result.count < numPieces {
|
||||
result.append(false)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let itemPieces = pieces
|
||||
Canvas { context, size in
|
||||
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.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
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.addRoundedRect(in: CGRect(x: cx, y: cy, width: boxSize - spacing, height: boxSize - spacing), cornerSize: cornerSize)
|
||||
}
|
||||
cx += boxSize
|
||||
if cx + boxSize > width {
|
||||
cx = 0
|
||||
cy += boxSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
|
||||
context.fill(completedPath, with: .color(Color.accentColor))
|
||||
}
|
||||
.frame(minHeight: 140)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var selection: Set<DownloadItem.ID> = []
|
||||
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
|
||||
@@ -12,8 +13,52 @@ struct ContentView: View {
|
||||
NavigationSplitView {
|
||||
SidebarView(selection: $sidebarSelection)
|
||||
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
|
||||
.themeBackground(settings.appTheme.theme.secondaryBackground)
|
||||
} detail: {
|
||||
detailView
|
||||
.themeBackground(settings.appTheme.theme.background)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
.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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if provider.canLoadObject(ofClass: String.self) {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + text
|
||||
} else {
|
||||
controller.pendingPasteboardText = text
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sheet(isPresented: $settings.showKeychainPrimer) {
|
||||
KeychainPrimerView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +71,10 @@ struct ContentView: View {
|
||||
queueView(queueID: queueID)
|
||||
case .scheduler:
|
||||
SchedulerView()
|
||||
case .speedLimiter:
|
||||
SpeedLimiterView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
SettingsPaneContainer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,42 +117,34 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem {
|
||||
Button {
|
||||
controller.startQueue(queueID: queueID)
|
||||
} label: {
|
||||
Label("Start Queue", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.disabled(!canStop)
|
||||
|
||||
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
||||
Button {
|
||||
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 ? "Start Queue" : "Start", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(!canStart)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -116,18 +155,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")",
|
||||
@@ -155,10 +210,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")
|
||||
}
|
||||
@@ -167,6 +223,30 @@ 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 }
|
||||
}
|
||||
|
||||
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:
|
||||
@@ -184,3 +264,55 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainPrimerView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "lock.shield")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Security Update")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: true)
|
||||
} label: {
|
||||
Text("Grant Secure Access")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: false)
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class DownloadController: ObservableObject {
|
||||
@@ -8,11 +9,15 @@ 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
|
||||
@@ -23,6 +28,10 @@ final class DownloadController: ObservableObject {
|
||||
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
|
||||
@@ -36,7 +45,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)
|
||||
@@ -44,7 +72,7 @@ final class DownloadController: ObservableObject {
|
||||
self?.saveDownloads()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
||||
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.saveDownloads()
|
||||
@@ -85,6 +113,23 @@ final class DownloadController: ObservableObject {
|
||||
Aria2DownloadEngine.findExecutable() != nil
|
||||
}
|
||||
|
||||
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) {
|
||||
guard let url = URL(string: urlText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
@@ -105,6 +150,10 @@ final class DownloadController: ObservableObject {
|
||||
queueID: normalizedQueueID(queueID)
|
||||
)
|
||||
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
|
||||
downloads.append(item)
|
||||
engineMessage = "Added \(fileName) to \(category.rawValue)."
|
||||
saveDownloads()
|
||||
@@ -116,6 +165,7 @@ final class DownloadController: ObservableObject {
|
||||
overrideDirectory: URL?,
|
||||
startImmediately: Bool,
|
||||
queueID: UUID = DownloadQueue.mainQueueID,
|
||||
credentials: DownloadCredentials? = nil,
|
||||
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
|
||||
speedLimitKiBPerSecond: Int? = nil
|
||||
) {
|
||||
@@ -124,13 +174,14 @@ 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: settings.credentials(for: pending.url),
|
||||
credentials: credentials ?? settings.credentials(for: pending.url),
|
||||
checksum: transferOptions.checksum,
|
||||
requestHeaders: transferOptions.requestHeaders,
|
||||
cookieHeader: transferOptions.cookieHeader,
|
||||
@@ -139,10 +190,18 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
for item in items {
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
}
|
||||
|
||||
downloads.append(contentsOf: items)
|
||||
engineMessage = "Added \(items.count) download\(items.count == 1 ? "" : "s")."
|
||||
saveDownloads()
|
||||
@@ -174,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."
|
||||
@@ -183,6 +245,35 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func pauseActiveDownloads(queueID: UUID? = nil) {
|
||||
let targetQueueID = queueID.map(normalizedQueueID)
|
||||
let activeItems = downloads.filter { item in
|
||||
item.status == .downloading && (targetQueueID == nil || item.queueID == targetQueueID)
|
||||
}
|
||||
|
||||
guard !activeItems.isEmpty else { return }
|
||||
|
||||
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."
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
}
|
||||
|
||||
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
@@ -190,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 {
|
||||
@@ -203,6 +296,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
|
||||
@@ -243,9 +337,29 @@ final class DownloadController: ObservableObject {
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func redownload(_ item: DownloadItem) {
|
||||
trashFiles(for: item)
|
||||
restrictQueueToAutoResume = false
|
||||
update(item.id) {
|
||||
$0.status = .queued
|
||||
$0.progress = 0
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
$0.message = "Redownloading"
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
queuePumpScope = queuePumpScope.includingItem(item.id)
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -253,6 +367,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
@@ -267,14 +382,18 @@ final class DownloadController: ObservableObject {
|
||||
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 {
|
||||
removeCacheFiles(for: item)
|
||||
}
|
||||
KeychainCredentialStore.deletePassword(for: item.id)
|
||||
downloads.removeAll { $0.id == item.id }
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
|
||||
@@ -353,7 +472,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
|
||||
}
|
||||
@@ -363,6 +486,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)
|
||||
}) {
|
||||
@@ -389,66 +513,209 @@ 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: 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.25 {
|
||||
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()
|
||||
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 {
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
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 {
|
||||
do {
|
||||
let handle = try engine.start(
|
||||
item: item,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
return
|
||||
}
|
||||
self.handleDownloadFailure(itemID: item.id, error: error)
|
||||
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)
|
||||
}
|
||||
|
||||
self.pumpQueue()
|
||||
self.updateSleepActivity()
|
||||
}
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Starting..."
|
||||
}
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.message = "Process \(handle.processIdentifier)"
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
} catch {
|
||||
handleDownloadFailure(itemID: item.id, error: error)
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
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])
|
||||
@@ -466,8 +733,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
|
||||
@@ -478,28 +745,42 @@ final class DownloadController: ObservableObject {
|
||||
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||
$0.message = "Properties updated"
|
||||
}
|
||||
if let password = credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: id)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +800,14 @@ 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 {
|
||||
@@ -532,6 +821,9 @@ final class DownloadController: ObservableObject {
|
||||
$0.autoResumeOnLaunch = false
|
||||
}
|
||||
saveDownloads()
|
||||
if let item = downloads.first(where: { $0.id == itemID }) {
|
||||
showNotification(title: "Download Failed", body: item.fileName)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -548,16 +840,51 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -655,14 +982,23 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
private func saveDownloads() {
|
||||
do {
|
||||
let directory = storageURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let state = StoredDownloadState(queues: queues, downloads: downloads)
|
||||
let data = try JSONEncoder().encode(state)
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to save downloads: \(error)")
|
||||
let queuesCopy = queues
|
||||
let downloadsCopy = downloads.map(\.redactedForPersistence)
|
||||
let storageURL = self.storageURL
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task.detached(priority: .background) {
|
||||
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)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to save downloads: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,13 +1020,38 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
var shouldResumeRecoveredDownloads = false
|
||||
var shouldRewriteStoredDownloads = isLegacyDownloadList
|
||||
self.queues = normalizedQueues(state.queues)
|
||||
self.downloads = state.downloads.map { item in
|
||||
var adjusted = item
|
||||
let redacted = adjusted.redactedForPersistence
|
||||
if redacted != adjusted {
|
||||
adjusted = redacted
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
adjusted.queueID = validQueueID(adjusted.queueID)
|
||||
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 {
|
||||
adjusted.status = .queued
|
||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||
@@ -706,7 +1067,7 @@ final class DownloadController: ObservableObject {
|
||||
return adjusted
|
||||
}
|
||||
|
||||
if shouldResumeRecoveredDownloads {
|
||||
if shouldResumeRecoveredDownloads || shouldRewriteStoredDownloads {
|
||||
saveDownloads()
|
||||
}
|
||||
return shouldResumeRecoveredDownloads
|
||||
@@ -739,6 +1100,39 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func showNotification(title: String, body: String) {
|
||||
pendingNotifications.append((title: title, body: body))
|
||||
notificationDebounceTask?.cancel()
|
||||
|
||||
notificationDebounceTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
|
||||
guard granted, let self else { return }
|
||||
Task { @MainActor in
|
||||
let items = self.pendingNotifications
|
||||
self.pendingNotifications.removeAll()
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
if items.count == 1 {
|
||||
content.title = items[0].title
|
||||
content.body = items[0].body
|
||||
} else {
|
||||
content.title = "\(items.count) Downloads Completed"
|
||||
content.body = "Multiple items have finished downloading."
|
||||
}
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredDownloadState: Codable {
|
||||
@@ -746,6 +1140,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
|
||||
|
||||
|
||||
@@ -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,28 @@ enum DownloadMetadataFetcher {
|
||||
return pending
|
||||
}
|
||||
|
||||
if isAutoFetch, let host = url.host, isPrivateHost(host) {
|
||||
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 +91,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 +128,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -80,39 +82,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,43 +170,65 @@ 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))
|
||||
}
|
||||
|
||||
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)
|
||||
CompactEditor(title: "Headers", text: $headerText)
|
||||
.disabled(transferSettingsLocked)
|
||||
CompactEditor(title: "Mirrors", text: $mirrorText)
|
||||
.disabled(transferSettingsLocked)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Progress") {
|
||||
ProgressView(value: item.progress)
|
||||
InfoGrid(item: item)
|
||||
if item.status == .downloading && item.rpcPort != nil {
|
||||
Section {
|
||||
CollapsibleGroup(title: "Chunk Map", isExpanded: $showsChunkMap) {
|
||||
ChunkMapView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -178,10 +250,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 {
|
||||
@@ -214,7 +358,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
|
||||
@@ -278,31 +422,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,107 +2,6 @@ import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum DownloadColumn: String, CaseIterable, Identifiable, Codable {
|
||||
case priority = "#"
|
||||
case fileName = "File name"
|
||||
case size = "Size"
|
||||
case progress = "Progress"
|
||||
case status = "Status"
|
||||
case lastTry = "Last try date"
|
||||
case dateAdded = "Date added"
|
||||
case category = "Category"
|
||||
case connections = "Connections"
|
||||
case liveConnections = "Live conn."
|
||||
case speed = "Speed"
|
||||
case eta = "ETA"
|
||||
case destination = "Save location"
|
||||
case url = "URL"
|
||||
case message = "Message"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var width: CGFloat {
|
||||
switch self {
|
||||
case .priority: return 58
|
||||
case .fileName: return 340
|
||||
case .size: return 100
|
||||
case .status: return 105
|
||||
case .progress: return 115
|
||||
case .lastTry, .dateAdded: return 155
|
||||
case .category: return 105
|
||||
case .connections, .liveConnections: return 95
|
||||
case .speed, .eta: return 90
|
||||
case .destination: return 240
|
||||
case .url: return 280
|
||||
case .message: return 220
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SortDirection: String, Codable {
|
||||
case ascending
|
||||
case descending
|
||||
|
||||
mutating func toggle() {
|
||||
self = self == .ascending ? .descending : .ascending
|
||||
}
|
||||
}
|
||||
|
||||
final class TableSettings: ObservableObject {
|
||||
@Published var visibleColumns: Set<DownloadColumn> {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var columnWidths: [DownloadColumn: CGFloat] {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var sortColumn: DownloadColumn {
|
||||
didSet { save() }
|
||||
}
|
||||
@Published var sortDirection: SortDirection {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.TableSettings.v1"
|
||||
|
||||
init() {
|
||||
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
|
||||
let legacyDefaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
|
||||
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
|
||||
visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
|
||||
columnWidths = stored.columnWidths
|
||||
sortColumn = stored.sortColumn
|
||||
sortDirection = stored.sortDirection
|
||||
} else {
|
||||
visibleColumns = defaultVisibleColumns
|
||||
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
|
||||
sortColumn = .dateAdded
|
||||
sortDirection = .descending
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredTableSettings(
|
||||
visibleColumns: visibleColumns,
|
||||
columnWidths: columnWidths,
|
||||
sortColumn: sortColumn,
|
||||
sortDirection: sortDirection
|
||||
)
|
||||
if let data = try? JSONEncoder().encode(stored) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredTableSettings: Codable {
|
||||
var visibleColumns: Set<DownloadColumn>
|
||||
var columnWidths: [DownloadColumn: CGFloat]
|
||||
var sortColumn: DownloadColumn
|
||||
var sortDirection: SortDirection
|
||||
}
|
||||
|
||||
struct DownloadTable: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@@ -112,11 +11,12 @@ struct DownloadTable: View {
|
||||
let title: String
|
||||
var queueID: UUID?
|
||||
|
||||
@StateObject private var tableSettings = TableSettings()
|
||||
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
|
||||
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
||||
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
|
||||
@State private var lastSelectedIndex: Int?
|
||||
@State private var draggedItemID: DownloadItem.ID?
|
||||
|
||||
var sortedItems: [DownloadItem] {
|
||||
items.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -134,37 +34,95 @@ struct DownloadTable: View {
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
GeometryReader { proxy in
|
||||
let tableWidth = max(totalWidth, proxy.size.width)
|
||||
let trailingWidth = max(0, tableWidth - totalWidth)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
VStack(spacing: 0) {
|
||||
tableHeader(trailingWidth: trailingWidth)
|
||||
Divider()
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(sortedItems) { item in
|
||||
tableRow(for: item, tableWidth: tableWidth, trailingWidth: trailingWidth)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
.frame(width: tableWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.defaultScrollAnchor(.topLeading)
|
||||
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)
|
||||
}
|
||||
.frame(width: tableWidth, height: proxy.size.height, alignment: .topLeading)
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
.overlay {
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press Command-V to paste one or more links.")
|
||||
)
|
||||
.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("Progress", value: \.progress) { item in
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
statusCell(for: item)
|
||||
}
|
||||
.width(min: 115, ideal: 170)
|
||||
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
Text(item.displaySpeedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
Text(item.displayETAText)
|
||||
.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)
|
||||
}
|
||||
.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.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
@@ -186,7 +144,7 @@ struct DownloadTable: View {
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
|
||||
Button("Move File and Cache to Trash", role: .destructive) {
|
||||
Button("Move to Trash", role: .destructive) {
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
for item in items { controller.delete(item, deleteFiles: true) }
|
||||
selection.subtract(ids)
|
||||
@@ -206,301 +164,168 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func tableRow(for item: DownloadItem, tableWidth: CGFloat, trailingWidth: CGFloat) -> some View {
|
||||
DownloadRow(
|
||||
item: item,
|
||||
priorityNumber: priorityNumber(for: item),
|
||||
visibleColumns: orderedVisibleColumns,
|
||||
columnWidth: { width(for: $0) },
|
||||
trailingWidth: trailingWidth
|
||||
)
|
||||
.id(item.id)
|
||||
.frame(width: tableWidth, alignment: .leading)
|
||||
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
let index = sortedItems.firstIndex(where: { $0.id == item.id })
|
||||
|
||||
if NSEvent.modifierFlags.contains(.command) {
|
||||
if selection.contains(item.id) {
|
||||
selection.remove(item.id)
|
||||
} else {
|
||||
selection.insert(item.id)
|
||||
}
|
||||
lastSelectedIndex = index
|
||||
} else if NSEvent.modifierFlags.contains(.shift), let lastIndex = lastSelectedIndex, let currentIndex = index {
|
||||
let range = min(lastIndex, currentIndex)...max(lastIndex, currentIndex)
|
||||
let rangeIds = range.map { sortedItems[$0].id }
|
||||
selection.formUnion(rangeIds)
|
||||
} else {
|
||||
selection = [item.id]
|
||||
lastSelectedIndex = index
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
rowContextMenu(for: item)
|
||||
}
|
||||
.onDrag {
|
||||
draggedItemID = item.id
|
||||
return NSItemProvider(object: dragPayload(for: item) as NSString)
|
||||
}
|
||||
.onDrop(
|
||||
of: [.text],
|
||||
delegate: QueueDropDelegate(
|
||||
item: item,
|
||||
queueID: queueID,
|
||||
draggedItemID: $draggedItemID,
|
||||
controller: controller
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func tableHeader(trailingWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(orderedVisibleColumns) { column in
|
||||
ZStack(alignment: .trailing) {
|
||||
headerContent(for: column)
|
||||
|
||||
Rectangle()
|
||||
.fill(.secondary.opacity(0.18))
|
||||
.frame(width: 1, height: 20)
|
||||
.frame(width: 8, height: 34)
|
||||
.contentShape(Rectangle())
|
||||
.onHover { isHovering in
|
||||
if isHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let baseWidth = resizeBaseWidths[column] ?? width(for: column)
|
||||
resizeBaseWidths[column] = baseWidth
|
||||
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
|
||||
}
|
||||
.onEnded { _ in
|
||||
resizeBaseWidths[column] = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(width: width(for: column), height: 34)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if trailingWidth > 0 {
|
||||
Color.clear
|
||||
.frame(width: trailingWidth, height: 34)
|
||||
}
|
||||
}
|
||||
.background(.bar)
|
||||
.contextMenu {
|
||||
Section("Columns") {
|
||||
ForEach(availableColumns) { column in
|
||||
Toggle(column.rawValue, isOn: Binding(
|
||||
get: { tableSettings.visibleColumns.contains(column) },
|
||||
set: { isVisible in
|
||||
if isVisible {
|
||||
tableSettings.visibleColumns.insert(column)
|
||||
} else if tableSettings.visibleColumns.count > 1 {
|
||||
tableSettings.visibleColumns.remove(column)
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
if queueID == nil {
|
||||
Section("Sort By") {
|
||||
ForEach(availableColumns) { column in
|
||||
Button(column.rawValue) {
|
||||
tableSettings.sortColumn = column
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for item: DownloadItem) -> some View {
|
||||
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.assignToQueue(
|
||||
itemIDs: Set(targetItems.map(\.id)),
|
||||
queueID: queue.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = Set(targetItems.map(\.id))
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var orderedVisibleColumns: [DownloadColumn] {
|
||||
var columns = DownloadColumn.allCases.filter { tableSettings.visibleColumns.contains($0) }
|
||||
if queueID != nil, !columns.contains(.priority) {
|
||||
columns.insert(.priority, at: 0)
|
||||
} else if queueID == nil {
|
||||
columns.removeAll { $0 == .priority }
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
private var availableColumns: [DownloadColumn] {
|
||||
DownloadColumn.allCases.filter { $0 != .priority }
|
||||
}
|
||||
|
||||
private var totalWidth: CGFloat {
|
||||
orderedVisibleColumns.map { width(for: $0) }.reduce(0, +)
|
||||
}
|
||||
|
||||
private func width(for column: DownloadColumn) -> CGFloat {
|
||||
max(70, tableSettings.columnWidths[column] ?? column.width)
|
||||
}
|
||||
|
||||
private var sortedItems: [DownloadItem] {
|
||||
if queueID != nil {
|
||||
return items
|
||||
}
|
||||
|
||||
return items.sorted { lhs, rhs in
|
||||
let result = compare(lhs, rhs, by: tableSettings.sortColumn)
|
||||
if result == .orderedSame {
|
||||
return lhs.id.uuidString < rhs.id.uuidString
|
||||
}
|
||||
return tableSettings.sortDirection == .ascending ? result == .orderedAscending : result == .orderedDescending
|
||||
}
|
||||
}
|
||||
|
||||
private func compare(_ lhs: DownloadItem, _ rhs: DownloadItem, by column: DownloadColumn) -> ComparisonResult {
|
||||
switch column {
|
||||
case .priority:
|
||||
return compare(priorityNumber(for: lhs) ?? 0, priorityNumber(for: rhs) ?? 0)
|
||||
case .fileName: return lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName)
|
||||
case .size: return compare(lhs.sizeBytes ?? -1, rhs.sizeBytes ?? -1)
|
||||
case .status: return compare(lhs.status.rawValue, rhs.status.rawValue)
|
||||
case .progress: return compare(lhs.progress, rhs.progress)
|
||||
case .lastTry: return compare(lhs.lastTryAt ?? .distantPast, rhs.lastTryAt ?? .distantPast)
|
||||
case .dateAdded: return compare(lhs.createdAt, rhs.createdAt)
|
||||
case .category: return compare(lhs.category.rawValue, rhs.category.rawValue)
|
||||
case .connections: return compare(lhs.connectionsPerServer, rhs.connectionsPerServer)
|
||||
case .liveConnections: return compare(lhs.connectionCount, rhs.connectionCount)
|
||||
case .speed: return compare(lhs.speedText, rhs.speedText)
|
||||
case .eta: return compare(lhs.etaText, rhs.etaText)
|
||||
case .destination: return compare(lhs.destinationPath, rhs.destinationPath)
|
||||
case .url: return compare(lhs.url.absoluteString, rhs.url.absoluteString)
|
||||
case .message: return compare(lhs.message, rhs.message)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func headerContent(for column: DownloadColumn) -> some View {
|
||||
let content = HStack(spacing: 4) {
|
||||
Spacer(minLength: 0)
|
||||
Text(column.rawValue)
|
||||
.font(.caption.weight(.semibold))
|
||||
private func statusCell(for item: DownloadItem) -> some View {
|
||||
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.status == .downloading, !message.isEmpty {
|
||||
Text(message)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Text(item.status.rawValue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.multilineTextAlignment(.center)
|
||||
.layoutPriority(1)
|
||||
if queueID == nil && tableSettings.sortColumn == column {
|
||||
Image(systemName: tableSettings.sortDirection == .ascending ? "chevron.up" : "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(width: width(for: column), height: 34, alignment: .center)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
|
||||
if !targetItems.isEmpty {
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
if queueID == nil {
|
||||
Button {
|
||||
if tableSettings.sortColumn == column {
|
||||
tableSettings.sortDirection.toggle()
|
||||
} else {
|
||||
tableSettings.sortColumn = column
|
||||
tableSettings.sortDirection = .ascending
|
||||
for target in targetItems {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
content
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider()
|
||||
|
||||
Divider()
|
||||
|
||||
Menu("Controls") {
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.assignToQueue(
|
||||
itemIDs: Set(targetItems.map(\.id)),
|
||||
queueID: queue.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Move to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
|
||||
NSPasteboard.general.setString(urls, forType: .string)
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(id: "download-properties", value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = itemIDs
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func categoryColor(for category: DownloadCategory) -> Color {
|
||||
switch category {
|
||||
case .musics: return .pink
|
||||
case .movies: return .indigo
|
||||
case .compressed: return .orange
|
||||
case .pictures: return .teal
|
||||
case .documents: return .blue
|
||||
case .other: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(for status: DownloadStatus) -> Color {
|
||||
switch status {
|
||||
case .queued: return .secondary
|
||||
case .downloading: return .blue
|
||||
case .paused: return .orange
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
case .canceled: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressBarCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
content
|
||||
HStack(spacing: 4) {
|
||||
ProgressView(value: item.progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(statusColor(for: item.status))
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.frame(width: 35, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func priorityNumber(for item: DownloadItem) -> Int? {
|
||||
guard queueID != nil,
|
||||
let index = items.firstIndex(where: { $0.id == item.id }) else {
|
||||
return nil
|
||||
}
|
||||
return index + 1
|
||||
}
|
||||
|
||||
private func dragPayload(for item: DownloadItem) -> String {
|
||||
let draggedIDs = selection.contains(item.id) ? selection : [item.id]
|
||||
return draggedIDs
|
||||
.map(\.uuidString)
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func compare<T: Comparable>(_ lhs: T, _ rhs: T) -> ComparisonResult {
|
||||
if lhs < rhs { return .orderedAscending }
|
||||
if lhs > rhs { return .orderedDescending }
|
||||
return .orderedSame
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
|
||||
private func showInFinder(_ item: DownloadItem) {
|
||||
@@ -523,184 +348,13 @@ struct DownloadTable: View {
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadRow: View {
|
||||
let item: DownloadItem
|
||||
let priorityNumber: Int?
|
||||
let visibleColumns: [DownloadColumn]
|
||||
let columnWidth: (DownloadColumn) -> CGFloat
|
||||
let trailingWidth: CGFloat
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
ForEach(visibleColumns) { column in
|
||||
cell(for: column)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.frame(width: columnWidth(column), alignment: alignment(for: column))
|
||||
.clipped()
|
||||
}
|
||||
|
||||
if trailingWidth > 0 {
|
||||
Color.clear
|
||||
.frame(width: trailingWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cell(for column: DownloadColumn) -> some View {
|
||||
switch column {
|
||||
case .priority:
|
||||
Text(priorityNumber.map(String.init) ?? "-")
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
case .fileName:
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: item.category.symbolName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(categoryColor)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.fileName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
case .size:
|
||||
Text(ByteFormatter.string(item.sizeBytes))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .status, .progress:
|
||||
progressBarCell()
|
||||
case .lastTry:
|
||||
Text(formatted(item.lastTryAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .dateAdded:
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .category:
|
||||
Text(item.category.rawValue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .connections:
|
||||
Text("\(item.connectionsPerServer)")
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
case .liveConnections:
|
||||
Text("\(item.connectionCount)")
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
case .speed:
|
||||
Text(item.speedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .eta:
|
||||
Text(item.etaText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .destination:
|
||||
Text(item.destinationPath)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .url:
|
||||
Text(item.url.absoluteString)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .message:
|
||||
Text(item.message)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
private func alignment(for column: DownloadColumn) -> Alignment {
|
||||
column == .fileName ? .leading : .center
|
||||
}
|
||||
|
||||
private var categoryColor: Color {
|
||||
switch item.category {
|
||||
case .musics: return .pink
|
||||
case .movies: return .indigo
|
||||
case .compressed: return .orange
|
||||
case .pictures: return .teal
|
||||
case .documents: return .blue
|
||||
case .other: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch item.status {
|
||||
case .queued: return .secondary
|
||||
case .downloading: return .blue
|
||||
case .paused: return .orange
|
||||
case .completed: return .green
|
||||
case .failed: return .red
|
||||
case .canceled: return .gray
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressBarCell() -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
private func openFile(_ item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
NSWorkspace.shared.open(fileURL)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor)
|
||||
.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)
|
||||
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
|
||||
}
|
||||
}
|
||||
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
return date.formatted(date: .abbreviated, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QueueDropDelegate: DropDelegate {
|
||||
let item: DownloadItem
|
||||
let queueID: UUID?
|
||||
@Binding var draggedItemID: DownloadItem.ID?
|
||||
let controller: DownloadController
|
||||
|
||||
func dropEntered(info: DropInfo) {
|
||||
guard let queueID,
|
||||
let draggedItemID,
|
||||
draggedItemID != item.id else {
|
||||
return
|
||||
}
|
||||
|
||||
controller.moveDownload(draggedItemID, before: item.id, in: queueID)
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
draggedItemID = nil
|
||||
return queueID != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,53 +2,141 @@ 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
|
||||
|
||||
// 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, settings: settings)
|
||||
extensionServer?.start()
|
||||
controller.extensionServerPort = extensionServer?.port
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WindowGroup(id: "main") {
|
||||
ContentView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.environmentObject(updateChecker)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.task {
|
||||
updateChecker.checkAutomaticallyIfNeeded()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.scheme == "firelink" {
|
||||
if url.host == "add",
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let queryItems = components.queryItems,
|
||||
let link = queryItems.first(where: { $0.name == "url" })?.value {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = link
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
controller.pendingReferer = nil
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
|
||||
WindowGroup("Add Downloads", id: "add-downloads") {
|
||||
Window("Add Downloads", id: "add-downloads") {
|
||||
AddDownloadsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
WindowGroup("Download Properties", for: UUID.self) { $downloadID in
|
||||
WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
|
||||
if let downloadID {
|
||||
DownloadPropertiesWindow(downloadID: downloadID)
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
} else {
|
||||
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
}
|
||||
}
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
.commands {
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Add Downloads...") {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: [.command])
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Start Queue") {
|
||||
controller.startQueue()
|
||||
}
|
||||
.keyboardShortcut("r", modifiers: [.command])
|
||||
|
||||
Button("Stop Downloads") {
|
||||
controller.pauseActiveDownloads()
|
||||
}
|
||||
.disabled(controller.activeCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = menuBarIconURL(),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 23, height: 23)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
Image(nsImage: nsImage)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func menuBarIconURL() -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
|
||||
return bundled
|
||||
}
|
||||
|
||||
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||
let projectRoot = sourceFile
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceTreeIcon = projectRoot
|
||||
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import AppKit
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
static let portRange = 6412...6422
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let settings: AppSettings
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
init?(downloadController: DownloadController, settings: AppSettings) {
|
||||
self.downloadController = downloadController
|
||||
self.settings = settings
|
||||
let parameters = NWParameters.tcp
|
||||
|
||||
var createdListener: NWListener?
|
||||
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
|
||||
}
|
||||
|
||||
func start() {
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.handleConnection(connection)
|
||||
}
|
||||
listener.stateUpdateHandler = { state in
|
||||
print("LocalExtensionServer state: \(state)")
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
}
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
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, timeoutItem: DispatchWorkItem) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else {
|
||||
timeoutItem.cancel()
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
var requestData = accumulatedData
|
||||
if let data {
|
||||
requestData.append(data)
|
||||
}
|
||||
|
||||
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, timeoutItem: timeoutItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendResponse(_ status: HTTPStatus, connection: NWConnection, origin: String?) {
|
||||
var headers = [
|
||||
"HTTP/1.1 \(status.rawValue) \(status.reason)",
|
||||
"Content-Length: 0",
|
||||
"Connection: close"
|
||||
]
|
||||
|
||||
if let origin, isAllowedExtensionOrigin(origin) {
|
||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||
headers.append("Vary: Origin")
|
||||
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||
}
|
||||
|
||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||
connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
}
|
||||
|
||||
private func isAllowedExtensionOrigin(_ origin: String) -> Bool {
|
||||
guard let url = URL(string: origin),
|
||||
let scheme = url.scheme?.lowercased() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return scheme == "moz-extension" || scheme == "chrome-extension"
|
||||
}
|
||||
|
||||
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
|
||||
let host = request.header(named: "host") ?? ""
|
||||
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
|
||||
guard isLocalhost else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.method == "OPTIONS" {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
|
||||
guard let token = request.header(named: Constants.extensionRequestHeader),
|
||||
token == expectedToken else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
if request.path == "/ping" {
|
||||
return request.method == "GET" ? .ok : .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||
return .unsupportedMediaType
|
||||
}
|
||||
|
||||
struct Payload: Decodable {
|
||||
let urls: [String]
|
||||
let referer: String?
|
||||
}
|
||||
|
||||
do {
|
||||
let payload = try JSONDecoder().decode(Payload.self, from: request.body)
|
||||
let validURLs = payload.urls
|
||||
.prefix(Constants.maxURLCount)
|
||||
.compactMap { rawURL -> String? in
|
||||
let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: trimmed),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
Constants.allowedSchemes.contains(scheme) else {
|
||||
return nil
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
guard !validURLs.isEmpty else {
|
||||
return .badRequest
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
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)
|
||||
}
|
||||
|
||||
return .ok
|
||||
} catch {
|
||||
print("Failed to parse local request JSON: \(error)")
|
||||
return .badRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HTTPRequest {
|
||||
var method: String
|
||||
var path: String
|
||||
var headers: [String: String]
|
||||
var body: Data
|
||||
|
||||
init?(data: Data) {
|
||||
guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let headerData = data[..<headerRange.lowerBound]
|
||||
guard let headerString = String(data: headerData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
|
||||
guard let requestLine = lines.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let requestParts = requestLine.split(separator: " ", maxSplits: 2)
|
||||
guard requestParts.count >= 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsedHeaders: [String: String] = [:]
|
||||
for line in lines.dropFirst() {
|
||||
guard let colonIndex = line.firstIndex(of: ":") else {
|
||||
continue
|
||||
}
|
||||
let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = line[line.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
parsedHeaders[name] = value
|
||||
}
|
||||
|
||||
let bodyStart = headerRange.upperBound
|
||||
let expectedBodyLength = parsedHeaders["content-length"].flatMap(Int.init) ?? 0
|
||||
guard expectedBodyLength >= 0,
|
||||
data.count >= bodyStart + expectedBodyLength else {
|
||||
return nil
|
||||
}
|
||||
|
||||
method = String(requestParts[0]).uppercased()
|
||||
path = String(requestParts[1]).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
|
||||
headers = parsedHeaders
|
||||
body = data[bodyStart..<(bodyStart + expectedBodyLength)]
|
||||
}
|
||||
|
||||
func header(named name: String) -> String? {
|
||||
headers[name.lowercased()]
|
||||
}
|
||||
}
|
||||
|
||||
private enum HTTPStatus: Int {
|
||||
case ok = 200
|
||||
case noContent = 204
|
||||
case badRequest = 400
|
||||
case forbidden = 403
|
||||
case notFound = 404
|
||||
case methodNotAllowed = 405
|
||||
case payloadTooLarge = 413
|
||||
case unsupportedMediaType = 415
|
||||
|
||||
var reason: String {
|
||||
switch self {
|
||||
case .ok: "OK"
|
||||
case .noContent: "No Content"
|
||||
case .badRequest: "Bad Request"
|
||||
case .forbidden: "Forbidden"
|
||||
case .notFound: "Not Found"
|
||||
case .methodNotAllowed: "Method Not Allowed"
|
||||
case .payloadTooLarge: "Payload Too Large"
|
||||
case .unsupportedMediaType: "Unsupported Media Type"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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,
|
||||
"--force-ipv4",
|
||||
"-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])
|
||||
}
|
||||
}
|
||||
|
||||
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 group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
||||
} else {
|
||||
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
|
||||
}
|
||||
}
|
||||
|
||||
try process.run()
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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()
|
||||
}
|
||||
|
||||
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,478 @@
|
||||
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 static let metadataTimeoutSeconds: UInt64 = 75
|
||||
|
||||
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
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
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", "--force-ipv4"]
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
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)
|
||||
return (metadata, options)
|
||||
} catch {
|
||||
throw ExtractionError.parsingFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
static func appendCommonArguments(
|
||||
to args: inout [String],
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
) {
|
||||
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)"])
|
||||
}
|
||||
|
||||
if let credentials, !credentials.isEmpty {
|
||||
args.append(contentsOf: ["--username", credentials.username, "--password", credentials.password])
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
outputBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
self.process = process
|
||||
}
|
||||
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.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 \(process.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() {
|
||||
lock.withLock {
|
||||
if let process, process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,11 +186,27 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var lastTryAt: Date?
|
||||
var autoResumeOnLaunch: Bool?
|
||||
var queueID: UUID?
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
var mediaFormatSelector: String?
|
||||
var isAudioOnlyMedia: Bool?
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
}
|
||||
|
||||
var displayETAText: String {
|
||||
status == .downloading ? etaText : "-"
|
||||
}
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
|
||||
var sortableSize: Int64 {
|
||||
sizeBytes ?? 0
|
||||
}
|
||||
|
||||
var transferOptions: DownloadTransferOptions {
|
||||
DownloadTransferOptions(
|
||||
checksum: checksum,
|
||||
@@ -206,6 +222,27 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
}
|
||||
return "\(speedLimitKiBPerSecond) KiB/s"
|
||||
}
|
||||
|
||||
var redactedForPersistence: DownloadItem {
|
||||
var item = self
|
||||
if item.credentials != nil {
|
||||
item.credentials?.password = ""
|
||||
}
|
||||
item.cookieHeader = nil
|
||||
item.requestHeaders = item.requestHeaders?.filter { !$0.containsSensitiveValue }
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private extension DownloadRequestHeader {
|
||||
var containsSensitiveValue: Bool {
|
||||
switch normalized.name.lowercased() {
|
||||
case "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadProgress: Equatable, Sendable {
|
||||
@@ -232,6 +269,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ struct SchedulerSettings: Codable, Equatable {
|
||||
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 +49,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 +62,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 +84,7 @@ final class SchedulerController: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
|
||||
func saveSettings() {
|
||||
if let data = try? JSONEncoder().encode(settings) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
@@ -109,7 +112,7 @@ 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)
|
||||
@@ -131,40 +134,46 @@ final class SchedulerController: ObservableObject {
|
||||
}
|
||||
|
||||
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 +185,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)
|
||||
|
||||
@@ -4,7 +4,7 @@ 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()
|
||||
@@ -12,12 +12,12 @@ struct SchedulerView: View {
|
||||
@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) {
|
||||
@@ -27,7 +27,7 @@ struct SchedulerView: View {
|
||||
}
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
|
||||
|
||||
Divider()
|
||||
permissionsSection
|
||||
}
|
||||
@@ -49,7 +49,7 @@ struct SchedulerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
@@ -57,15 +57,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,18 +76,18 @@ 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
|
||||
@@ -107,12 +107,12 @@ struct SchedulerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
@@ -134,12 +134,12 @@ struct SchedulerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
@@ -149,17 +149,17 @@ struct SchedulerView: View {
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
@@ -173,7 +173,7 @@ struct SchedulerView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
@@ -192,23 +192,27 @@ struct SchedulerView: View {
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
|
||||
private func loadState() {
|
||||
isEnabled = schedulerController.settings.isEnabled
|
||||
startTime = schedulerController.settings.startTime
|
||||
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.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"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
|
||||
}
|
||||
|
||||
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) (\(buildNumber))")
|
||||
.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, _):
|
||||
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,46 @@
|
||||
import SwiftUI
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Global speed limit") {
|
||||
HStack {
|
||||
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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 Path") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.help(executableURL?.path ?? "")
|
||||
} 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") {
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
Text(source.rawValue).tag(source)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
} 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) {
|
||||
VStack(alignment: .trailing) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
.foregroundStyle(.red)
|
||||
case .installed(let version):
|
||||
Text(version)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Text("Error")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
|
||||
Text(path?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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 {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(systemName: "puzzlepiece.extension.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40, height: 40)
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connect Browser Extension")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Capture downloads directly from your browser in three easy steps.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
KeychainAccessCard()
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
HStack(spacing: 12) {
|
||||
// Step 1
|
||||
CompactStepCardView(
|
||||
stepNumber: 1,
|
||||
title: "Copy Token",
|
||||
description: "This secure token authorizes your browser extension.",
|
||||
icon: "doc.on.clipboard.fill",
|
||||
iconColor: .blue,
|
||||
actionText: "Copy Token",
|
||||
action: {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Regenerate",
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 2
|
||||
CompactStepCardView(
|
||||
stepNumber: 2,
|
||||
title: "Get Extension",
|
||||
description: "Install the Firelink Companion extension on your browser.",
|
||||
icon: "globe",
|
||||
iconColor: .orange,
|
||||
actionText: "Firefox Add-ons",
|
||||
action: {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Releases",
|
||||
secondaryAction: {
|
||||
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 3
|
||||
CompactStepCardView(
|
||||
stepNumber: 3,
|
||||
title: "Paste & Connect",
|
||||
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
|
||||
icon: "arrow.down.doc.fill",
|
||||
iconColor: .green,
|
||||
actionText: nil,
|
||||
action: nil
|
||||
)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
// Diagnostics
|
||||
HStack {
|
||||
Text("Diagnostics:")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactStepCardView: View {
|
||||
let stepNumber: Int
|
||||
let title: String
|
||||
let description: String
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let actionText: String?
|
||||
let action: (() -> Void)?
|
||||
var secondaryActionText: String? = nil
|
||||
var secondaryAction: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(alignment: .top) {
|
||||
// Step Number Badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.frame(width: 24, height: 24)
|
||||
.shadow(color: .black.opacity(0.1), radius: 1, y: 1)
|
||||
|
||||
Text("\(stepNumber)")
|
||||
.font(.system(.caption, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Spacer()
|
||||
// Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(iconColor.opacity(0.15))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Text Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
// Action Button
|
||||
VStack(spacing: 8) {
|
||||
if let actionText = actionText, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionText)
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
|
||||
Button(action: secondaryAction) {
|
||||
Text(secondaryActionText)
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundColor(.primary)
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct KeychainAccessCard: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Keychain Access")
|
||||
.font(.headline)
|
||||
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Label("Granted", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Button(role: .destructive) {
|
||||
settings.revokeKeychainAccess()
|
||||
} label: {
|
||||
Text("Revoke")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
settings.grantKeychainAccess()
|
||||
} label: {
|
||||
Text("Grant Access")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
|
||||
BulkDirectoryPickerRow()
|
||||
|
||||
GridRow {
|
||||
Divider()
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Folders will be created automatically when applied.")
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if let displayMessage = message.isEmpty ? statusMessage(for: path) : message, !displayMessage.isEmpty {
|
||||
Text(displayMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("All Categories", systemImage: "folder.fill.badge.plus")
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Base folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
if !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
|
||||
} else {
|
||||
Text("Automatically creates all category folders in the selected path.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,47 @@
|
||||
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("Menu Bar") {
|
||||
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,73 @@
|
||||
import SwiftUI
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,76 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SiteLoginsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var editingLoginID: UUID?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
KeychainAccessCard()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if editingLoginID != nil {
|
||||
Button("Cancel Edit") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
settings.saveSiteLogin(
|
||||
id: editingLoginID,
|
||||
urlPattern: urlPattern,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||
resetForm()
|
||||
}
|
||||
} label: {
|
||||
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
|
||||
}
|
||||
.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)
|
||||
.accessibilityHidden(true)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
Button {
|
||||
edit(login)
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private func edit(_ login: SiteLogin) {
|
||||
editingLoginID = login.id
|
||||
urlPattern = login.urlPattern
|
||||
username = login.username
|
||||
password = ""
|
||||
}
|
||||
|
||||
private func resetForm() {
|
||||
editingLoginID = nil
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
private enum SettingsSection: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case locations = "Locations"
|
||||
case network = "Network"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case about = "About"
|
||||
|
||||
static let orderedCases: [SettingsSection] = [
|
||||
.downloads,
|
||||
.locations,
|
||||
.network,
|
||||
.siteLogins,
|
||||
.power,
|
||||
.engine,
|
||||
.about
|
||||
]
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
|
||||
var groupTitle: String {
|
||||
switch self {
|
||||
case .engine, .about:
|
||||
"App"
|
||||
default:
|
||||
"Preferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var selection: SettingsSection = .downloads
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Settings")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 26)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
settingsSidebar
|
||||
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
selectedPane
|
||||
.frame(maxWidth: 720, alignment: .leading)
|
||||
.padding(28)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var settingsSidebar: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Preferences") {
|
||||
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
|
||||
Section("App") {
|
||||
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
|
||||
Label(section.rawValue, systemImage: section.symbolName)
|
||||
.tag(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.frame(width: 210)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var selectedPane: some View {
|
||||
switch selection {
|
||||
case .downloads:
|
||||
DownloadSettingsPane()
|
||||
case .network:
|
||||
NetworkSettingsPane()
|
||||
case .locations:
|
||||
LocationsSettingsPane()
|
||||
case .siteLogins:
|
||||
SiteLoginsSettingsPane()
|
||||
case .power:
|
||||
PowerSettingsPane()
|
||||
case .engine:
|
||||
EngineSettingsPane()
|
||||
case .about:
|
||||
AboutSettingsPane()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) (\(buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Developer") {
|
||||
LabeledContent("Created by") {
|
||||
Text("NimBold")
|
||||
}
|
||||
|
||||
LabeledContent("GitHub") {
|
||||
Link("@nimbold", destination: developerProfileURL)
|
||||
}
|
||||
|
||||
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("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("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 {
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
|
||||
private var version: String {
|
||||
Aria2DownloadEngine.versionString() ?? "Unavailable"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Section("Bandwidth") {
|
||||
Toggle("Limit total download speed", isOn: globalSpeedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Stepper(
|
||||
"Global cap: \(settings.globalSpeedLimitKiBPerSecond) KiB/s",
|
||||
value: globalSpeedLimitValue,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(settings.globalSpeedLimitKiBPerSecond == 0)
|
||||
|
||||
Text("Firelink splits this cap across the configured parallel download slots. Per-download limits can still be set lower in Add Downloads or Properties.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var globalSpeedLimitEnabled: Binding<Bool> {
|
||||
Binding {
|
||||
settings.globalSpeedLimitKiBPerSecond > 0
|
||||
} set: { isEnabled in
|
||||
settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0
|
||||
}
|
||||
}
|
||||
|
||||
private var globalSpeedLimitValue: Binding<Int> {
|
||||
Binding {
|
||||
max(settings.globalSpeedLimitKiBPerSecond, 1)
|
||||
} set: { newValue in
|
||||
settings.globalSpeedLimitKiBPerSecond = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocationsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset Defaults") {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DirectoryPickerRow: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
let category: DownloadCategory
|
||||
|
||||
@State private var path = ""
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.frame(width: 125, alignment: .leading)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
Text("URL Pattern")
|
||||
TextField("*.github.com", text: $urlPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Username")
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("Password")
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,37 @@ 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)
|
||||
case queue(UUID)
|
||||
case scheduler
|
||||
case speedLimiter
|
||||
case settings
|
||||
}
|
||||
|
||||
@@ -71,13 +98,37 @@ struct SidebarView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
|
||||
Section("Tools") {
|
||||
Label("Scheduler", systemImage: "calendar.badge.clock")
|
||||
.tag(SidebarSelection.scheduler)
|
||||
Label("Speed Limiter", systemImage: "speedometer")
|
||||
.tag(SidebarSelection.speedLimiter)
|
||||
}
|
||||
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(selection == .settings ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
.alert("Rename Queue", isPresented: Binding(
|
||||
get: { queueBeingRenamed != nil },
|
||||
set: { isPresented in
|
||||
@@ -122,30 +173,6 @@ struct SidebarView: View {
|
||||
} message: { queue in
|
||||
Text("Downloads in \(queue.name) will stay in All and Unfinished, but no longer belong to a queue.")
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
Divider()
|
||||
Button {
|
||||
selection = .settings
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
if selection == .settings {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color.accentColor.opacity(0.14))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
|
||||
private func folderRow(for category: DownloadCategory) -> some View {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import SwiftUI
|
||||
|
||||
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
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
limitSelectionSection
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.onAppear {
|
||||
loadState()
|
||||
}
|
||||
.overlay {
|
||||
if showSaveToast {
|
||||
toastView
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
Toggle(isOn: $isEnabled) {
|
||||
Text("Speed Limiter")
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
|
||||
private var limitSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Global Speed Limit")
|
||||
.font(.headline)
|
||||
|
||||
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)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// 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 var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Speed Limit Saved")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
.shadow(radius: 4, y: 2)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.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
|
||||
|
||||
lastCustomSpeedLimit = clampedSpeed
|
||||
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
enum AppFontSize: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case small = "Small"
|
||||
case standard = "Standard"
|
||||
case large = "Large"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var dynamicTypeSize: DynamicTypeSize {
|
||||
switch self {
|
||||
case .small: return .xSmall
|
||||
case .standard: return .medium
|
||||
case .large: return .xxLarge
|
||||
}
|
||||
}
|
||||
|
||||
var defaultFont: Font {
|
||||
switch self {
|
||||
case .small:
|
||||
return .system(size: 12)
|
||||
case .standard:
|
||||
return .system(size: 13)
|
||||
case .large:
|
||||
return .system(size: 15)
|
||||
}
|
||||
}
|
||||
|
||||
var controlSize: ControlSize {
|
||||
switch self {
|
||||
case .small:
|
||||
return .small
|
||||
case .standard:
|
||||
return .regular
|
||||
case .large:
|
||||
return .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||
case compact = "Compact"
|
||||
case standard = "Standard"
|
||||
case relaxed = "Relaxed"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var verticalPadding: CGFloat {
|
||||
switch self {
|
||||
case .compact: return 4
|
||||
case .standard: return 8
|
||||
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 {
|
||||
case system = "System Default"
|
||||
case light = "Light"
|
||||
case dark = "Dark"
|
||||
case dracula = "Dracula"
|
||||
case nord = "Nord"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var theme: Theme {
|
||||
switch self {
|
||||
case .system, .dark:
|
||||
return Theme.system
|
||||
case .light:
|
||||
return Theme.modernLight
|
||||
case .dracula:
|
||||
return Theme.dracula
|
||||
case .nord:
|
||||
return Theme.nord
|
||||
}
|
||||
}
|
||||
|
||||
var appearance: NSAppearance? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return NSAppearance(named: .aqua)
|
||||
case .dark, .dracula, .nord: return NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Theme: Equatable, Sendable {
|
||||
var background: Color?
|
||||
var secondaryBackground: Color?
|
||||
var text: Color?
|
||||
var secondaryText: Color?
|
||||
var accent: Color?
|
||||
|
||||
static let system = Theme(
|
||||
background: nil,
|
||||
secondaryBackground: nil,
|
||||
text: nil,
|
||||
secondaryText: nil,
|
||||
accent: nil
|
||||
)
|
||||
|
||||
static let modernLight = Theme(
|
||||
background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)),
|
||||
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)),
|
||||
text: Color.primary,
|
||||
secondaryText: Color.secondary,
|
||||
accent: Color.accentColor
|
||||
)
|
||||
|
||||
static let dracula = Theme(
|
||||
background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)),
|
||||
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)),
|
||||
text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)),
|
||||
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)),
|
||||
accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink
|
||||
)
|
||||
|
||||
static let nord = Theme(
|
||||
background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0
|
||||
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1
|
||||
text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4
|
||||
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3
|
||||
accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8
|
||||
)
|
||||
}
|
||||
|
||||
struct AppThemeModifier: ViewModifier {
|
||||
let theme: AppTheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.tint(theme.theme.accent)
|
||||
.onAppear {
|
||||
NSApp.appearance = theme.appearance
|
||||
}
|
||||
.onChange(of: theme) { _, newTheme in
|
||||
NSApp.appearance = newTheme.appearance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppFontSizeModifier: ViewModifier {
|
||||
let fontSize: AppFontSize
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(fontSize.defaultFont)
|
||||
.controlSize(fontSize.controlSize)
|
||||
.dynamicTypeSize(fontSize.dynamicTypeSize)
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeBackgroundModifier: ViewModifier {
|
||||
let color: Color?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let color {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(color)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func themeBackground(_ color: Color?) -> some View {
|
||||
modifier(ThemeBackgroundModifier(color: color))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TrayMenuView: View {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
|
||||
var body: some View {
|
||||
Button("Show main window") {
|
||||
openWindow(id: "main")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
Button("Add downloads") {
|
||||
openWindow(id: "add-downloads")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
Menu("Start queue") {
|
||||
ForEach(controller.queues) { queue in
|
||||
Button(queue.name) {
|
||||
controller.startQueue(queueID: queue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Stop downloads") {
|
||||
for download in controller.downloads where download.status == .downloading {
|
||||
controller.pause(download)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Exit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||