mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
31 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 |
@@ -31,11 +31,14 @@ jobs:
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
TAG_NAME="v${VERSION}"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Show build environment
|
||||
@@ -60,6 +63,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 }}
|
||||
@@ -70,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:
|
||||
@@ -86,15 +110,18 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish GitHub release
|
||||
if: github.ref_type == 'tag'
|
||||
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG_NAME="${{ steps.version.outputs.tag_name }}"
|
||||
awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{if(flag) exit} flag' CHANGELOG.md > release_notes.md
|
||||
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
test -s release_notes.md
|
||||
|
||||
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG_NAME" dist/*.dmg --clobber
|
||||
gh release edit "$TAG_NAME" --notes-file release_notes.md
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
|
||||
@@ -23,3 +23,8 @@ SparklePrivateKey*
|
||||
private-key*
|
||||
private_key*
|
||||
yt-dlp
|
||||
ffmpeg
|
||||
Sources/Firelink/_internal/
|
||||
!Sources/Firelink/_internal/
|
||||
Sources/Firelink/_internal/*
|
||||
!Sources/Firelink/_internal/.gitkeep
|
||||
|
||||
+84
-6
@@ -5,6 +5,84 @@ 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
|
||||
@@ -16,7 +94,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Integrate yt-dlp to DownloadController and add global queue support.
|
||||
- Implement smart progressive disclosure UI and media extraction engine.
|
||||
- Implement gatekeeper architecture for on-demand media engine binaries.
|
||||
- Inline Sparkle update checks to avoid unnecessary modals.
|
||||
- Inline update checks to avoid unnecessary modals.
|
||||
|
||||
### Changes
|
||||
- Add backward compatibility support for extension tokens.
|
||||
@@ -28,7 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Revert to plain mode without gradient.
|
||||
- Apply premium gradient to the correct new icon and app icon.
|
||||
- Remove redundant version string from up-to-date message.
|
||||
- Update appcast.xml with valid signature for new framework-embedded dmg.
|
||||
- Update release metadata for the framework-embedded dmg.
|
||||
|
||||
### Fixes
|
||||
- Cap max height of download links text editor.
|
||||
@@ -44,14 +122,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Block automatic metadata fetch for private IP addresses (security).
|
||||
- Actually update extension icons with the 1.9x gradient icon.
|
||||
- Correctly remove black padding and mask corners.
|
||||
- Harden Sparkle release metadata.
|
||||
- Correct Sparkle SUNoUpdateError code to prevent false error messages.
|
||||
- Harden release metadata.
|
||||
- Correct no-update handling to prevent false error messages.
|
||||
|
||||
## [0.5.7] - 2026-06-06
|
||||
|
||||
### New features
|
||||
- Replaced the basic in-app update checker with the industry-standard Sparkle 2 framework.
|
||||
- Added secure, automatic in-app updates using EdDSA cryptographic signatures.
|
||||
- Replaced the basic in-app update checker with an integrated release-checking flow.
|
||||
- Added secure update metadata checks before presenting new releases in the app.
|
||||
|
||||
## [0.5.6] - 2026-06-05
|
||||
|
||||
|
||||
+1
-1
Submodule Extensions/Firefox updated: c45809038e...fc078ef6ad
@@ -1,4 +1,4 @@
|
||||
.PHONY: build app dmg run verify clean
|
||||
.PHONY: build app dmg release run verify clean
|
||||
|
||||
build:
|
||||
swift build -c release
|
||||
@@ -9,6 +9,10 @@ app:
|
||||
dmg: app
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
release:
|
||||
Scripts/create_app_bundle.sh
|
||||
Scripts/create_dmg.sh
|
||||
|
||||
run:
|
||||
swift run Firelink
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sparkle-project/Sparkle",
|
||||
"state" : {
|
||||
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
|
||||
"version" : "2.9.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
+8
-7
@@ -10,18 +10,19 @@ let package = Package(
|
||||
products: [
|
||||
.executable(name: "Firelink", targets: ["Firelink"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Firelink",
|
||||
dependencies: [
|
||||
.product(name: "Sparkle", package: "Sparkle")
|
||||
],
|
||||
dependencies: [],
|
||||
path: "Sources/Firelink",
|
||||
exclude: [
|
||||
"_internal"
|
||||
],
|
||||
resources: [
|
||||
.process("Assets.xcassets")
|
||||
.process("Assets.xcassets"),
|
||||
.copy("yt-dlp"),
|
||||
.copy("ffmpeg")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -43,13 +43,13 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter—backed securely by `yt-dlp` and `ffmpeg` via our Add-on Gatekeeper.
|
||||
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
|
||||
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
|
||||
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes files into `Musics`, `Movies`, `Compressed`, and more.
|
||||
- 🛡️ **Reliable & Secure:** Deep Keychain integration for authenticated downloads, zero-configuration setup, and automatic recovery.
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
|
||||
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface tailor-made for Apple Silicon, featuring a visual chunk map and dynamic progress tracking.
|
||||
- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
|
||||
- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration ensures your credentials are kept safe and only accessed when strictly necessary.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
|
||||
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's
|
||||
DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
|
||||
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
|
||||
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
|
||||
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
@@ -15,6 +16,48 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
ICON_NAME="AppIcon"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
is_valid_mach_o() {
|
||||
local path="$1"
|
||||
if ! file "$path" | grep -q 'Mach-O'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
lipo -archs "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_ytdlp() {
|
||||
local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp"
|
||||
local ytdlp_internal_path="$ROOT_DIR/Sources/Firelink/_internal"
|
||||
|
||||
if [[ -x "$ytdlp_path" ]] && [[ -d "$ytdlp_internal_path" ]] && is_valid_mach_o "$ytdlp_path"; then
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null || ! command -v unzip >/dev/null; then
|
||||
echo "WARNING: yt-dlp or its runtime is missing or malformed, and curl/unzip are not available to refresh it." >&2
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Refreshing bundled yt-dlp runtime..."
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o "$temp_dir/yt-dlp.zip"
|
||||
unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir"
|
||||
cp "$temp_dir/yt-dlp_macos" "$ytdlp_path"
|
||||
chmod +x "$ytdlp_path"
|
||||
|
||||
if [[ -d "$temp_dir/_internal" ]]; then
|
||||
rm -rf "$ROOT_DIR/Sources/Firelink/_internal"
|
||||
cp -R "$temp_dir/_internal" "$ROOT_DIR/Sources/Firelink/_internal"
|
||||
touch "$ROOT_DIR/Sources/Firelink/_internal/.gitkeep"
|
||||
fi
|
||||
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
ensure_ytdlp
|
||||
|
||||
swift build -c "$CONFIGURATION"
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
@@ -24,6 +67,20 @@ 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"
|
||||
@@ -52,12 +109,6 @@ fi
|
||||
|
||||
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
if [ -d ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" ]; then
|
||||
cp -R ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
|
||||
fi
|
||||
if [ -d ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" ]; then
|
||||
cp -R ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" "$FRAMEWORKS_DIR/SparkleCore.framework"
|
||||
fi
|
||||
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
|
||||
|
||||
@@ -93,12 +144,6 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -115,7 +160,46 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
PLIST
|
||||
|
||||
if command -v codesign &> /dev/null; then
|
||||
codesign --force --deep --sign - "$APP_DIR"
|
||||
if [[ -n "$SIGNING_IDENTITY" ]]; then
|
||||
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$SIGNING_IDENTITY")
|
||||
echo "Signing app bundle with identity: $SIGNING_IDENTITY"
|
||||
else
|
||||
CODESIGN_ARGS=(--force --sign -)
|
||||
echo "Ad-hoc signing app bundle for local use."
|
||||
fi
|
||||
|
||||
sign_path() {
|
||||
local path="$1"
|
||||
codesign "${CODESIGN_ARGS[@]}" "$path"
|
||||
}
|
||||
|
||||
sign_mach_o_file() {
|
||||
local path="$1"
|
||||
|
||||
if file "$path" | grep -q 'Mach-O'; then
|
||||
if ! sign_path "$path"; then
|
||||
echo "WARNING: Could not sign Mach-O executable for local build: $path" >&2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -d "$FRAMEWORKS_DIR" ]]; then
|
||||
while IFS= read -r -d '' executable_path; do
|
||||
sign_mach_o_file "$executable_path"
|
||||
done < <(find "$FRAMEWORKS_DIR" -type f -print0)
|
||||
|
||||
while IFS= read -r -d '' bundle_path; do
|
||||
sign_path "$bundle_path"
|
||||
done < <(find "$FRAMEWORKS_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
|
||||
fi
|
||||
|
||||
while IFS= read -r -d '' executable_path; do
|
||||
sign_mach_o_file "$executable_path"
|
||||
done < <(find "$RESOURCES_DIR" -type f -print0)
|
||||
|
||||
sign_path "$MACOS_DIR/$APP_NAME"
|
||||
sign_path "$APP_DIR"
|
||||
codesign --verify --deep --verbose=2 "$APP_DIR" || true
|
||||
fi
|
||||
|
||||
echo "Created $APP_DIR"
|
||||
|
||||
+12
-2
@@ -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"
|
||||
|
||||
@@ -32,31 +32,45 @@ struct AddDownloadsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
linkSection
|
||||
|
||||
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
|
||||
summarySection
|
||||
previewSection
|
||||
HStack(spacing: 0) {
|
||||
// Left Column
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
linkSection
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right Column
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.frame(width: 320)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 470, idealHeight: 500)
|
||||
.frame(minWidth: 720, idealWidth: 800, minHeight: 620, idealHeight: 680)
|
||||
.sheet(isPresented: $showingDuplicates) {
|
||||
DuplicateResolutionView(
|
||||
conflicts: $conflictingDownloads,
|
||||
onConfirm: {
|
||||
showingDuplicates = false
|
||||
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
executeAddDownloads(start: pendingStartFlag, conflicts: conflictingDownloads)
|
||||
}
|
||||
},
|
||||
onCancel: {
|
||||
showingDuplicates = false
|
||||
@@ -82,6 +96,16 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.onDisappear {
|
||||
metadataTask?.cancel()
|
||||
linkText = ""
|
||||
pendingDownloads = []
|
||||
headerText = ""
|
||||
cookieText = ""
|
||||
mirrorText = ""
|
||||
useAuthorization = false
|
||||
authUsername = ""
|
||||
authPassword = ""
|
||||
checksumEnabled = false
|
||||
checksumValue = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +119,7 @@ struct AddDownloadsView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(height: 72)
|
||||
.frame(minHeight: 72, idealHeight: 100, maxHeight: 160)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
@@ -113,95 +137,68 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
private var optionsSection: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Label("Save Location", systemImage: "folder")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Toggle("Use one folder for all files", isOn: $overrideDestination)
|
||||
Group {
|
||||
if let firstMedia = pendingDownloads.first(where: { $0.isMedia }) {
|
||||
mediaFormatSection(for: firstMedia)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("")
|
||||
HStack(spacing: 8) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(!overrideDestination)
|
||||
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
Section("Save Location") {
|
||||
Toggle("Use one folder for all files", isOn: $overrideDestination)
|
||||
if overrideDestination {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
.disabled(!overrideDestination)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Queue", systemImage: "tray.full")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Queue", selection: $targetQueueID) {
|
||||
Section("Queue") {
|
||||
Picker("Target Queue", selection: $targetQueueID) {
|
||||
ForEach(controller.queues) { queue in
|
||||
Text(queue.name).tag(queue.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 220, alignment: .leading)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Section("Transfer Settings") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connections per File")
|
||||
HStack {
|
||||
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
|
||||
.frame(width: 145)
|
||||
Text("\(Int(connectionsPerServer)) segments")
|
||||
Text("\(Int(connectionsPerServer))")
|
||||
.monospacedDigit()
|
||||
.frame(width: 98, alignment: .leading)
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Limit speed per file", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Speed Limit per File", systemImage: "speedometer")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Limit each file", isOn: $speedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(!speedLimitEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Authorization", systemImage: "lock.shield")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.top, 4)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Use authorization", isOn: $useAuthorization)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if useAuthorization {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Username", text: $authUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 145)
|
||||
SecureField("Password", text: $authPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 145)
|
||||
}
|
||||
Toggle("Save login for this website", isOn: $saveLogin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Authorization") {
|
||||
Toggle("Use authorization", isOn: $useAuthorization)
|
||||
if useAuthorization {
|
||||
TextField("Username", text: $authUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Password", text: $authPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Save login for this website", isOn: $saveLogin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,36 +246,11 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
TableColumn("Status") { $item in
|
||||
if item.isMedia {
|
||||
if !item.mediaOptions.isEmpty {
|
||||
Menu {
|
||||
ForEach(item.mediaOptions) { option in
|
||||
Button {
|
||||
item.selectedMediaOption = option
|
||||
if let metadata = item.mediaMetadata {
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
item.fileName = "\(cleanTitle).\(option.outputExtension)"
|
||||
item.category = FileClassifier.category(forFileName: item.fileName)
|
||||
}
|
||||
} label: {
|
||||
Text(option.name)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(item.selectedMediaOption?.name ?? "Select Format")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
} else if case .loading = item.state {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking")
|
||||
}.foregroundStyle(.secondary)
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
if item.isMedia, case .loading = item.state {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking")
|
||||
}.foregroundStyle(.secondary)
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
@@ -312,7 +284,7 @@ struct AddDownloadsView: View {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.keyboardShortcut(showingDuplicates ? nil : .cancelAction)
|
||||
|
||||
Button("Add to Queue") {
|
||||
addDownloads(start: false)
|
||||
@@ -324,66 +296,159 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canAddDownloads)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.keyboardShortcut(showingDuplicates ? nil : .defaultAction)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMediaOption(for firstMedia: PendingDownload, newId: String) {
|
||||
for index in pendingDownloads.indices where pendingDownloads[index].isMedia {
|
||||
if let option = pendingDownloads[index].mediaOptions.first(where: { $0.id == newId }) {
|
||||
pendingDownloads[index].selectedMediaOption = option
|
||||
if let metadata = pendingDownloads[index].mediaMetadata {
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
pendingDownloads[index].fileName = "\(cleanTitle).\(option.outputExtension)"
|
||||
pendingDownloads[index].category = FileClassifier.category(forFileName: pendingDownloads[index].fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mediaFormatSection(for firstMedia: PendingDownload) -> some View {
|
||||
Section {
|
||||
if firstMedia.mediaOptions.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
if case .loading = firstMedia.state {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Fetching media options...")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case .failed(let error) = firstMedia.state {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
|
||||
Text("Failed to load options.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for metadata...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
let currentOption = firstMedia.selectedMediaOption ?? firstMedia.mediaOptions.first!
|
||||
let availableTypes = Array(Set(firstMedia.mediaOptions.map(\.mediaType))).sorted(by: { $0.rawValue > $1.rawValue })
|
||||
let optionsForType = firstMedia.mediaOptions.filter { $0.mediaType == currentOption.mediaType }
|
||||
let availableFormats = Array(Set(optionsForType.map(\.containerName))).sorted()
|
||||
let optionsForFormat = optionsForType.filter { $0.containerName == currentOption.containerName }
|
||||
|
||||
let availableQualities = Array(Set(optionsForFormat.map(\.qualityName))).sorted(by: {
|
||||
if $0 == "Best" { return true }
|
||||
if $1 == "Best" { return false }
|
||||
return $0 > $1
|
||||
})
|
||||
|
||||
Picker("Type", selection: Binding(
|
||||
get: { currentOption.mediaType },
|
||||
set: { newType in
|
||||
if let firstOfNewType = firstMedia.mediaOptions.first(where: { $0.mediaType == newType }) {
|
||||
updateMediaOption(for: firstMedia, newId: firstOfNewType.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableTypes, id: \.self) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Picker("Format", selection: Binding(
|
||||
get: { currentOption.containerName },
|
||||
set: { newFormat in
|
||||
let matching = optionsForType.first(where: { $0.containerName == newFormat && $0.qualityName == currentOption.qualityName })
|
||||
let fallback = optionsForType.first(where: { $0.containerName == newFormat })
|
||||
if let newOption = matching ?? fallback {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableFormats, id: \.self) { format in
|
||||
Text(format).tag(format)
|
||||
}
|
||||
}
|
||||
|
||||
if currentOption.mediaType == .video {
|
||||
Picker("Quality", selection: Binding(
|
||||
get: { currentOption.qualityName },
|
||||
set: { newQuality in
|
||||
if let newOption = optionsForFormat.first(where: { $0.qualityName == newQuality }) {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableQualities, id: \.self) { quality in
|
||||
Text(quality).tag(quality)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Quality", selection: .constant("Best")) {
|
||||
Text("Best").tag("Best")
|
||||
}
|
||||
.disabled(true)
|
||||
}
|
||||
} // End of else block
|
||||
} header: {
|
||||
Text("Media Format").foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Verify Checksum", isOn: $checksumEnabled)
|
||||
if checksumEnabled {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 130)
|
||||
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
.disabled(!checksumEnabled)
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Headers", systemImage: "text.quote")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Cookies", systemImage: "circle.hexagongrid.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cookies")
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,20 @@ final class AppSettings: ObservableObject {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var extensionPairingToken: String {
|
||||
didSet {
|
||||
if isKeychainAccessGranted {
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var isKeychainAccessGranted: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var showKeychainPrimer = false
|
||||
|
||||
@Published var message = ""
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -192,6 +206,7 @@ final class AppSettings: ObservableObject {
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
|
||||
let granted: Bool
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
||||
appTheme = stored.appTheme ?? .system
|
||||
@@ -205,6 +220,8 @@ final class AppSettings: ObservableObject {
|
||||
siteLogins = stored.siteLogins
|
||||
mediaCookieSource = stored.mediaCookieSource ?? .none
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
granted = stored.isKeychainAccessGranted ?? false
|
||||
isKeychainAccessGranted = granted
|
||||
} else {
|
||||
appTheme = .system
|
||||
appFontSize = .standard
|
||||
@@ -217,6 +234,37 @@ final class AppSettings: ObservableObject {
|
||||
siteLogins = []
|
||||
mediaCookieSource = .none
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
granted = false
|
||||
isKeychainAccessGranted = granted
|
||||
}
|
||||
|
||||
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
let fullVersion = "\(currentVersion).\(currentBuild)"
|
||||
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
|
||||
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
|
||||
|
||||
var needsPrimer = false
|
||||
if granted {
|
||||
if let lastVersion, lastVersion != fullVersion {
|
||||
needsPrimer = true
|
||||
}
|
||||
}
|
||||
|
||||
if granted {
|
||||
if needsPrimer {
|
||||
showKeychainPrimer = true
|
||||
extensionPairingToken = ""
|
||||
} else {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
// The didSet of extensionPairingToken will handle setting it in the keychain since isKeychainAccessGranted is true.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
extensionPairingToken = ""
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
||||
@@ -320,6 +368,38 @@ final class AppSettings: ObservableObject {
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
func grantKeychainAccess() {
|
||||
isKeychainAccessGranted = true
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
}
|
||||
|
||||
func revokeKeychainAccess() {
|
||||
KeychainCredentialStore.deleteExtensionToken()
|
||||
for login in siteLogins {
|
||||
KeychainCredentialStore.deletePassword(for: login.id)
|
||||
}
|
||||
siteLogins.removeAll()
|
||||
extensionPairingToken = ""
|
||||
isKeychainAccessGranted = false
|
||||
}
|
||||
|
||||
func resolveKeychainPrimer(grantAccess: Bool) {
|
||||
showKeychainPrimer = false
|
||||
if grantAccess {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
} else {
|
||||
isKeychainAccessGranted = false
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredSettings(
|
||||
appTheme: appTheme,
|
||||
@@ -332,7 +412,8 @@ final class AppSettings: ObservableObject {
|
||||
proxySettings: proxySettings.normalized,
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins,
|
||||
mediaCookieSource: mediaCookieSource
|
||||
mediaCookieSource: mediaCookieSource,
|
||||
isKeychainAccessGranted: isKeychainAccessGranted
|
||||
)
|
||||
let defaults = self.defaults
|
||||
let storageKey = self.storageKey
|
||||
@@ -374,6 +455,15 @@ final class AppSettings: ObservableObject {
|
||||
return host == normalizedPattern
|
||||
}
|
||||
|
||||
private static func generateSecureToken() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
return UUID().uuidString
|
||||
}
|
||||
return Data(bytes).base64EncodedString()
|
||||
}
|
||||
|
||||
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
|
||||
}
|
||||
@@ -404,4 +494,5 @@ private struct StoredSettings: Codable {
|
||||
var downloadDirectories: [String: String]
|
||||
var siteLogins: [SiteLogin]
|
||||
var mediaCookieSource: BrowserCookieSource?
|
||||
var isKeychainAccessGranted: Bool?
|
||||
}
|
||||
|
||||
@@ -56,21 +56,14 @@ final class Aria2DownloadEngine {
|
||||
let candidates = [
|
||||
"/opt/homebrew/bin/aria2c",
|
||||
"/usr/local/bin/aria2c",
|
||||
"/usr/bin/aria2c"
|
||||
"/usr/bin/aria2c",
|
||||
"/opt/local/bin/aria2c"
|
||||
]
|
||||
|
||||
if let found = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
|
||||
return URL(fileURLWithPath: found)
|
||||
}
|
||||
|
||||
let path = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
for folder in path.split(separator: ":") {
|
||||
let candidate = URL(fileURLWithPath: String(folder)).appendingPathComponent("aria2c")
|
||||
if FileManager.default.isExecutableFile(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
enum BinaryDownloaderError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpError(statusCode: Int)
|
||||
case downloadFailed(Error?)
|
||||
case moveFailed(Error)
|
||||
case permissionFailed(Error)
|
||||
case unzipFailed
|
||||
case unsupportedDownloadURL
|
||||
case missingChecksum
|
||||
case checksumMismatch
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
"The add-on server returned an invalid response."
|
||||
case .httpError(let statusCode):
|
||||
"The add-on download failed with HTTP \(statusCode)."
|
||||
case .downloadFailed(let error):
|
||||
error?.localizedDescription ?? "The add-on download failed."
|
||||
case .moveFailed(let error):
|
||||
error.localizedDescription
|
||||
case .permissionFailed(let error):
|
||||
"Could not mark the add-on executable: \(error.localizedDescription)"
|
||||
case .unzipFailed:
|
||||
"Could not extract the downloaded add-on archive."
|
||||
case .unsupportedDownloadURL:
|
||||
"The add-on URL must be HTTP or HTTPS."
|
||||
case .missingChecksum:
|
||||
"The add-on configuration is missing a SHA-256 checksum."
|
||||
case .checksumMismatch:
|
||||
"The downloaded add-on did not match the expected SHA-256 checksum."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class BinaryDownloader: NSObject, URLSessionDownloadDelegate, Sendable {
|
||||
private let url: URL
|
||||
private let destination: URL
|
||||
private let expectedSHA256: String?
|
||||
private let onProgress: @Sendable (Double) -> Void
|
||||
private let session: URLSession
|
||||
|
||||
private let continuation: CheckedContinuation<Void, Error>
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
destination: URL,
|
||||
expectedSHA256: String?,
|
||||
onProgress: @escaping @Sendable (Double) -> Void,
|
||||
continuation: CheckedContinuation<Void, Error>
|
||||
) {
|
||||
self.url = url
|
||||
self.destination = destination
|
||||
self.expectedSHA256 = expectedSHA256
|
||||
self.onProgress = onProgress
|
||||
self.continuation = continuation
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
self.session = URLSession(configuration: config, delegate: nil, delegateQueue: nil) // Delegate set below
|
||||
super.init()
|
||||
}
|
||||
|
||||
static func download(
|
||||
from url: URL,
|
||||
to destination: URL,
|
||||
expectedSHA256: String? = nil,
|
||||
onProgress: @escaping @Sendable (Double) -> Void
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
let downloader = BinaryDownloader(
|
||||
url: url,
|
||||
destination: destination,
|
||||
expectedSHA256: expectedSHA256,
|
||||
onProgress: onProgress,
|
||||
continuation: continuation
|
||||
)
|
||||
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
|
||||
let task = session.downloadTask(with: url)
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
guard let response = downloadTask.response as? HTTPURLResponse else {
|
||||
continuation.resume(throwing: BinaryDownloaderError.invalidResponse)
|
||||
return
|
||||
}
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
continuation.resume(throwing: BinaryDownloaderError.httpError(statusCode: response.statusCode))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
|
||||
throw BinaryDownloaderError.unsupportedDownloadURL
|
||||
}
|
||||
|
||||
let isZip = url.pathExtension.lowercased() == "zip"
|
||||
let stagingURL = destination
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(".\(destination.lastPathComponent).\(UUID().uuidString).staged")
|
||||
var cleanupURLs: [URL] = [stagingURL]
|
||||
defer {
|
||||
for cleanupURL in cleanupURLs {
|
||||
try? FileManager.default.removeItem(at: cleanupURL)
|
||||
}
|
||||
}
|
||||
|
||||
if isZip {
|
||||
let tempZip = location.appendingPathExtension("zip")
|
||||
try FileManager.default.moveItem(at: location, to: tempZip)
|
||||
cleanupURLs.append(tempZip)
|
||||
|
||||
let extractDir = tempZip.deletingLastPathComponent().appendingPathComponent("extracted_\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true)
|
||||
cleanupURLs.append(extractDir)
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||
process.arguments = ["-q", tempZip.path, "-d", extractDir.path]
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw BinaryDownloaderError.unzipFailed
|
||||
}
|
||||
|
||||
let expectedName = destination.lastPathComponent
|
||||
var foundBinary: URL?
|
||||
if let enumerator = FileManager.default.enumerator(at: extractDir, includingPropertiesForKeys: nil) {
|
||||
for case let fileURL as URL in enumerator {
|
||||
if fileURL.lastPathComponent == expectedName || fileURL.lastPathComponent == expectedName + "c" {
|
||||
foundBinary = fileURL
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let foundBinary = foundBinary else {
|
||||
throw BinaryDownloaderError.unzipFailed
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: foundBinary, to: stagingURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: location, to: stagingURL)
|
||||
}
|
||||
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stagingURL.path)
|
||||
if let expectedSHA256 {
|
||||
let actualSHA256 = try Self.sha256Hex(for: stagingURL)
|
||||
guard actualSHA256.caseInsensitiveCompare(expectedSHA256.trimmingCharacters(in: .whitespacesAndNewlines)) == .orderedSame else {
|
||||
throw BinaryDownloaderError.checksumMismatch
|
||||
}
|
||||
}
|
||||
try installStagedBinary(stagingURL, at: destination)
|
||||
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: BinaryDownloaderError.moveFailed(error))
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||||
guard totalBytesExpectedToWrite > 0 else { return }
|
||||
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||
onProgress(progress)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
session.finishTasksAndInvalidate()
|
||||
continuation.resume(throwing: BinaryDownloaderError.downloadFailed(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func installStagedBinary(_ stagedURL: URL, at destination: URL) throws {
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
_ = try FileManager.default.replaceItemAt(destination, withItemAt: stagedURL)
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: stagedURL, to: destination)
|
||||
}
|
||||
}
|
||||
|
||||
private static func sha256Hex(for url: URL) throws -> String {
|
||||
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,12 @@ struct ContentView: View {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
if let url = url {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
let newText = url.absoluteString
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + newText
|
||||
} else {
|
||||
controller.pendingPasteboardText = newText
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
@@ -37,7 +42,11 @@ struct ContentView: View {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = text
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + text
|
||||
} else {
|
||||
controller.pendingPasteboardText = text
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
@@ -47,6 +56,10 @@ struct ContentView: View {
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sheet(isPresented: $settings.showKeychainPrimer) {
|
||||
KeychainPrimerView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -142,18 +155,24 @@ struct ContentView: View {
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [])
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
handlePaste(queueID: queueID)
|
||||
}
|
||||
.keyboardShortcut("v", modifiers: .command)
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
selectAll(items: items)
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: .command)
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
if let item = selectedItems.first {
|
||||
@@ -162,6 +181,8 @@ struct ContentView: View {
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
|
||||
@@ -243,3 +264,55 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainPrimerView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "lock.shield")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Security Update")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: true)
|
||||
} label: {
|
||||
Text("Grant Secure Access")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: false)
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ final class DownloadController: ObservableObject {
|
||||
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
|
||||
}()
|
||||
private var saveTask: Task<Void, Never>?
|
||||
private var pendingNotifications: [(title: String, body: String)] = []
|
||||
private var notificationDebounceTask: Task<Void, Never>?
|
||||
private var lastProgressUpdateTimes: [UUID: Date] = [:]
|
||||
|
||||
init(settings: AppSettings) {
|
||||
self.settings = settings
|
||||
@@ -208,26 +211,6 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func addMediaDownload(_ item: DownloadItem, startImmediately: Bool) {
|
||||
var item = item
|
||||
item.fileName = FileClassifier.sanitizedFileName(item.fileName)
|
||||
item.category = FileClassifier.category(forFileName: item.fileName)
|
||||
item.speedLimitKiBPerSecond = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
|
||||
item.queueID = normalizedQueueID(item.queueID ?? DownloadQueue.mainQueueID)
|
||||
|
||||
if let password = item.credentials?.password, !password.isEmpty {
|
||||
KeychainCredentialStore.setPassword(password, for: item.id)
|
||||
}
|
||||
|
||||
downloads.append(item)
|
||||
engineMessage = "Added \(item.fileName) to \(item.category.rawValue)."
|
||||
saveDownloads()
|
||||
|
||||
if startImmediately {
|
||||
startQueue(queueID: item.queueID ?? DownloadQueue.mainQueueID)
|
||||
}
|
||||
}
|
||||
|
||||
func startQueue(queueID: UUID? = nil) {
|
||||
engineMessage = ""
|
||||
restrictQueueToAutoResume = false
|
||||
@@ -250,6 +233,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
func pause(_ item: DownloadItem) {
|
||||
lastProgressUpdateTimes[item.id] = nil
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
activeMediaHandles[item.id]?.cancel()
|
||||
@@ -534,7 +518,7 @@ final class DownloadController: ObservableObject {
|
||||
do {
|
||||
update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.message = "Checking media add-ons..."
|
||||
$0.message = "Checking bundled media engines..."
|
||||
}
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
|
||||
guard let liveItem = activeDownloadItem(id: item.id) else { return }
|
||||
@@ -550,6 +534,11 @@ final class DownloadController: ObservableObject {
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
return
|
||||
}
|
||||
self?.lastProgressUpdateTimes[item.id] = now
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
@@ -601,6 +590,11 @@ final class DownloadController: ObservableObject {
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
return
|
||||
}
|
||||
self?.lastProgressUpdateTimes[item.id] = now
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
@@ -622,7 +616,7 @@ final class DownloadController: ObservableObject {
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Process \(handle.processIdentifier)"
|
||||
$0.message = "Starting..."
|
||||
}
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
@@ -1108,14 +1102,35 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
private func showNotification(title: String, body: String) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||
guard granted else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
pendingNotifications.append((title: title, body: body))
|
||||
notificationDebounceTask?.cancel()
|
||||
|
||||
notificationDebounceTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
|
||||
guard granted, let self else { return }
|
||||
Task { @MainActor in
|
||||
let items = self.pendingNotifications
|
||||
self.pendingNotifications.removeAll()
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
if items.count == 1 {
|
||||
content.title = items[0].title
|
||||
content.body = items[0].body
|
||||
} else {
|
||||
content.title = "\(items.count) Downloads Completed"
|
||||
content.body = "Multiple items have finished downloading."
|
||||
}
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,23 +134,80 @@ enum DownloadMetadataFetcher {
|
||||
if h == "localhost" || h.hasSuffix(".local") { return true }
|
||||
if !h.contains(".") && !h.contains(":") { return true }
|
||||
|
||||
let parts = h.split(separator: ".")
|
||||
if parts.count == 4, let first = Int(parts[0]), let second = Int(parts[1]) {
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
var hints = addrinfo(
|
||||
ai_flags: 0,
|
||||
ai_family: AF_UNSPEC,
|
||||
ai_socktype: SOCK_STREAM,
|
||||
ai_protocol: 0,
|
||||
ai_addrlen: 0,
|
||||
ai_canonname: nil,
|
||||
ai_addr: nil,
|
||||
ai_next: nil
|
||||
)
|
||||
|
||||
if h.contains(":") {
|
||||
if h == "[::1]" || h.hasPrefix("[fc") || h.hasPrefix("[fd") || h.hasPrefix("[fe8") || h.hasPrefix("[fe9") || h.hasPrefix("[fea") || h.hasPrefix("[feb") {
|
||||
return true
|
||||
var res: UnsafeMutablePointer<addrinfo>?
|
||||
if getaddrinfo(host, nil, &hints, &res) == 0 {
|
||||
var current = res
|
||||
while let info = current {
|
||||
let family = info.pointee.ai_family
|
||||
if family == AF_INET {
|
||||
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee }
|
||||
let ip = UInt32(bigEndian: addr.sin_addr.s_addr)
|
||||
let first = (ip >> 24) & 0xFF
|
||||
let second = (ip >> 16) & 0xFF
|
||||
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
} else if family == AF_INET6 {
|
||||
let addr = info.pointee.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee }
|
||||
let bytes = addr.sin6_addr.__u6_addr.__u6_addr8
|
||||
|
||||
let isLoopback = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
||||
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
||||
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0 && bytes.11 == 0 &&
|
||||
bytes.12 == 0 && bytes.13 == 0 && bytes.14 == 0 && bytes.15 == 1
|
||||
|
||||
let isULA = (bytes.0 & 0xFE) == 0xFC
|
||||
let isLinkLocal = bytes.0 == 0xFE && (bytes.1 & 0xC0) == 0x80
|
||||
|
||||
let isIPv4Mapped = bytes.0 == 0 && bytes.1 == 0 && bytes.2 == 0 && bytes.3 == 0 &&
|
||||
bytes.4 == 0 && bytes.5 == 0 && bytes.6 == 0 && bytes.7 == 0 &&
|
||||
bytes.8 == 0 && bytes.9 == 0 && bytes.10 == 0xFF && bytes.11 == 0xFF
|
||||
|
||||
if isLoopback || isULA || isLinkLocal {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if isIPv4Mapped {
|
||||
let first = bytes.12
|
||||
let second = bytes.13
|
||||
if first == 127 || first == 10 || (first == 192 && second == 168) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 172 && (16...31).contains(second) {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
if first == 169 && second == 254 {
|
||||
freeaddrinfo(res)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
current = info.pointee.ai_next
|
||||
}
|
||||
freeaddrinfo(res)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ struct DownloadTable: View {
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||
.animation(.default, value: sortedItems)
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
} primaryAction: { itemIDs in
|
||||
@@ -202,33 +203,37 @@ struct DownloadTable: View {
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
Menu("Controls") {
|
||||
if targetItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .paused || target.status == .failed || target.status == .canceled {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,22 +311,15 @@ struct DownloadTable: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor(for: item.status))
|
||||
.frame(width: max(0, proxy.size.width * item.progress))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
ProgressView(value: item.progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(statusColor(for: item.status))
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.frame(width: 35, alignment: .trailing)
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +1,8 @@
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
|
||||
private var _updater: SPUUpdater?
|
||||
var updater: SPUUpdater { _updater! }
|
||||
|
||||
@Published var isChecking = false
|
||||
@Published var isDownloading = false
|
||||
@Published var isExtracting = false
|
||||
@Published var isReadyToInstall = false
|
||||
@Published var downloadProgress: Double = 0.0
|
||||
@Published var extractionProgress: Double = 0.0
|
||||
|
||||
@Published var updateStatus: String?
|
||||
@Published var foundUpdateItem: SUAppcastItem?
|
||||
@Published var releaseNotes: String?
|
||||
|
||||
var expectedContentLength: UInt64 = 0
|
||||
var receivedContentLength: UInt64 = 0
|
||||
var cancellation: (() -> Void)?
|
||||
var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
let driver = InlineUpdateUserDriver(updater: self)
|
||||
let hostBundle = Bundle.main
|
||||
self._updater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: driver, delegate: self)
|
||||
do {
|
||||
try self._updater?.start()
|
||||
} catch {
|
||||
print("Failed to start Sparkle updater: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func checkForUpdates() {
|
||||
guard updater.canCheckForUpdates else {
|
||||
isChecking = false
|
||||
updateStatus = "Update check is already in progress."
|
||||
return
|
||||
}
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
|
||||
func resetState() {
|
||||
isChecking = false
|
||||
isDownloading = false
|
||||
isExtracting = false
|
||||
isReadyToInstall = false
|
||||
downloadProgress = 0.0
|
||||
extractionProgress = 0.0
|
||||
updateStatus = nil
|
||||
foundUpdateItem = nil
|
||||
releaseNotes = nil
|
||||
expectedContentLength = 0
|
||||
receivedContentLength = 0
|
||||
cancellation = nil
|
||||
updateChoiceReply = nil
|
||||
}
|
||||
|
||||
// Delegate methods can be left mostly empty or minimal since the UserDriver handles the UI state now.
|
||||
func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
self.isChecking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct FirelinkApp: App {
|
||||
@StateObject private var sparkleUpdater: SparkleUpdater
|
||||
|
||||
@StateObject private var updateChecker: ReleaseUpdateChecker
|
||||
@StateObject private var settings: AppSettings
|
||||
@StateObject private var controller: DownloadController
|
||||
@StateObject private var schedulerController: SchedulerController
|
||||
@@ -79,7 +12,7 @@ struct FirelinkApp: App {
|
||||
private let extensionServer: LocalExtensionServer?
|
||||
|
||||
init() {
|
||||
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater())
|
||||
self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
|
||||
|
||||
let settings = AppSettings()
|
||||
let controller = DownloadController(settings: settings)
|
||||
@@ -87,7 +20,7 @@ struct FirelinkApp: App {
|
||||
_controller = StateObject(wrappedValue: controller)
|
||||
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
|
||||
|
||||
extensionServer = LocalExtensionServer(downloadController: controller)
|
||||
extensionServer = LocalExtensionServer(downloadController: controller, settings: settings)
|
||||
extensionServer?.start()
|
||||
controller.extensionServerPort = extensionServer?.port
|
||||
}
|
||||
@@ -98,9 +31,12 @@ struct FirelinkApp: App {
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(schedulerController)
|
||||
.environmentObject(sparkleUpdater)
|
||||
.environmentObject(updateChecker)
|
||||
.modifier(AppThemeModifier(theme: settings.appTheme))
|
||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||
.task {
|
||||
updateChecker.checkAutomaticallyIfNeeded()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if url.scheme == "firelink" {
|
||||
if url.host == "add",
|
||||
@@ -126,7 +62,7 @@ struct FirelinkApp: App {
|
||||
}
|
||||
.windowStyle(.titleBar)
|
||||
|
||||
WindowGroup("Add Downloads", id: "add-downloads") {
|
||||
Window("Add Downloads", id: "add-downloads") {
|
||||
AddDownloadsView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(settings)
|
||||
@@ -174,7 +110,6 @@ struct FirelinkApp: App {
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(sparkleUpdater)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = menuBarIconURL(),
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct AddonConfig: Codable, Equatable, Sendable {
|
||||
let version: String
|
||||
let macArm64: URL?
|
||||
let macX64: URL?
|
||||
let macArm64SHA256: String?
|
||||
let macX64SHA256: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case macArm64 = "mac-arm64"
|
||||
case macX64 = "mac-x64"
|
||||
case macArm64SHA256 = "mac-arm64-sha256"
|
||||
case macX64SHA256 = "mac-x64-sha256"
|
||||
}
|
||||
|
||||
/// Returns the appropriate download URL for the current system architecture
|
||||
var currentArchURL: URL? {
|
||||
#if arch(arm64)
|
||||
return macArm64
|
||||
#elseif arch(x86_64)
|
||||
return macX64
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
var currentArchSHA256: String? {
|
||||
#if arch(arm64)
|
||||
return macArm64SHA256
|
||||
#elseif arch(x86_64)
|
||||
return macX64SHA256
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct GatekeeperConfig: Codable, Equatable, Sendable {
|
||||
let ytDlp: AddonConfig?
|
||||
let ffmpeg: AddonConfig?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ytDlp = "yt-dlp"
|
||||
case ffmpeg
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -8,25 +8,18 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
static let maxRequestBytes = 128 * 1024
|
||||
static let maxURLCount = 200
|
||||
static let extensionRequestHeader = "x-firelink-extension"
|
||||
|
||||
// IMPORTANT(Backward Compatibility):
|
||||
// Extension updates on Mozilla/Chrome stores can take several days to be approved.
|
||||
// Therefore, we MUST NOT introduce breaking changes to the LocalExtensionServer API
|
||||
// without maintaining backward compatibility for older extensions.
|
||||
// If you need to update the API (e.g., changing the payload structure), add the new
|
||||
// token here and handle both versions in `processRequest`.
|
||||
static let supportedExtensionTokens = Set(["firelink-extension-v1"])
|
||||
|
||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||
}
|
||||
|
||||
private let listener: NWListener
|
||||
private let downloadController: DownloadController
|
||||
private let settings: AppSettings
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
init?(downloadController: DownloadController) {
|
||||
init?(downloadController: DownloadController, settings: AppSettings) {
|
||||
self.downloadController = downloadController
|
||||
self.settings = settings
|
||||
let parameters = NWParameters.tcp
|
||||
|
||||
var createdListener: NWListener?
|
||||
@@ -63,12 +56,17 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receiveRequest(from: connection, accumulatedData: Data())
|
||||
let timeoutItem = DispatchWorkItem { [weak connection] in
|
||||
connection?.cancel()
|
||||
}
|
||||
queue.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem)
|
||||
receiveRequest(from: connection, accumulatedData: Data(), timeoutItem: timeoutItem)
|
||||
}
|
||||
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data, timeoutItem: DispatchWorkItem) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else {
|
||||
timeoutItem.cancel()
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
@@ -79,22 +77,25 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
|
||||
timeoutItem.cancel()
|
||||
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let request = HTTPRequest(data: requestData) {
|
||||
timeoutItem.cancel()
|
||||
let status = self.processRequest(request)
|
||||
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
|
||||
return
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
timeoutItem.cancel()
|
||||
self.sendResponse(.badRequest, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData)
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData, timeoutItem: timeoutItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
if let origin, isAllowedExtensionOrigin(origin) {
|
||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||
headers.append("Vary: Origin")
|
||||
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Methods: GET, POST, OPTIONS")
|
||||
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||
}
|
||||
|
||||
@@ -128,10 +129,6 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func processRequest(_ request: HTTPRequest) -> HTTPStatus {
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
let host = request.header(named: "host") ?? ""
|
||||
let isLocalhost = host == "127.0.0.1:\(self.port)" || host == "localhost:\(self.port)" || host == "127.0.0.1" || host == "localhost"
|
||||
guard isLocalhost else {
|
||||
@@ -142,13 +139,22 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
|
||||
guard let token = request.header(named: Constants.extensionRequestHeader),
|
||||
token == expectedToken else {
|
||||
return .forbidden
|
||||
}
|
||||
|
||||
guard let token = request.header(named: Constants.extensionRequestHeader),
|
||||
Constants.supportedExtensionTokens.contains(token) else {
|
||||
return .forbidden
|
||||
if request.path == "/ping" {
|
||||
return request.method == "GET" ? .ok : .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.path == "/download" else {
|
||||
return .notFound
|
||||
}
|
||||
|
||||
guard request.method == "POST" else {
|
||||
return .methodNotAllowed
|
||||
}
|
||||
|
||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||
|
||||
@@ -29,11 +29,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
|
||||
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
|
||||
|
||||
guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
|
||||
guard let ytDlpURL, FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled yt-dlp executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
|
||||
guard let ffmpegURL, FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
|
||||
throw EngineError.missingEngine("The bundled FFmpeg executable is missing. Reinstall Firelink or rebuild the app bundle.")
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
|
||||
@@ -44,7 +44,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
var arguments = [
|
||||
"--newline",
|
||||
"--ffmpeg-location", ffmpegURL.path,
|
||||
"--extractor-args", "youtube:player_client=ios,tv",
|
||||
"--force-ipv4",
|
||||
"-o", item.destinationPath
|
||||
]
|
||||
|
||||
@@ -97,30 +97,44 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
messageUpdate: messageUpdate
|
||||
)
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
||||
} else {
|
||||
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,21 +161,23 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let baseName = expectedURL.deletingPathExtension().lastPathComponent
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: item.destinationDirectory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return expectedURL
|
||||
let commonExtensions = ["mp4", "mkv", "webm", "mp3", "m4a", "opus", "m4v", "aac", "wav", "flac"]
|
||||
|
||||
var mostRecent: URL?
|
||||
var mostRecentDate: Date = .distantPast
|
||||
|
||||
for ext in commonExtensions {
|
||||
let candidate = item.destinationDirectory.appendingPathComponent("\(baseName).\(ext)")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let date = (try? candidate.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
if date >= mostRecentDate {
|
||||
mostRecentDate = date
|
||||
mostRecent = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contents
|
||||
.filter { $0.deletingPathExtension().lastPathComponent == baseName }
|
||||
.max { lhs, rhs in
|
||||
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
return lhsDate < rhsDate
|
||||
} ?? expectedURL
|
||||
return mostRecent ?? expectedURL
|
||||
}
|
||||
|
||||
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
|
||||
|
||||
@@ -3,7 +3,6 @@ import Combine
|
||||
|
||||
enum AddonState: Equatable, Sendable {
|
||||
case notInstalled
|
||||
case downloading(progress: Double)
|
||||
case installed(version: String)
|
||||
case failed(error: String)
|
||||
}
|
||||
@@ -12,10 +11,6 @@ enum AddonType: String, CaseIterable, Sendable {
|
||||
case ytDlp = "yt-dlp"
|
||||
case ffmpeg
|
||||
|
||||
var defaultsKey: String {
|
||||
return "Firelink.AddonVersion.\(self.rawValue)"
|
||||
}
|
||||
|
||||
var binaryName: String {
|
||||
switch self {
|
||||
case .ytDlp: return "yt-dlp"
|
||||
@@ -31,116 +26,56 @@ final class MediaEngineManager: ObservableObject {
|
||||
@Published var ytDlpState: AddonState = .notInstalled
|
||||
@Published var ffmpegState: AddonState = .notInstalled
|
||||
|
||||
private let configURL = URL(string: "https://nimbold.github.io/Firelink/firelink-addons.json")!
|
||||
|
||||
private var addonsDirectory: URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
|
||||
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
|
||||
}
|
||||
private var installTasks: [AddonType: Task<Void, Error>] = [:]
|
||||
|
||||
private init() {
|
||||
checkLocalInstallation()
|
||||
}
|
||||
|
||||
func binaryPath(for addon: AddonType) -> URL {
|
||||
return addonsDirectory.appendingPathComponent(addon.binaryName)
|
||||
func binaryPath(for addon: AddonType) -> URL? {
|
||||
if let bundled = Bundle.main.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
|
||||
// Prevent fatalError crash: avoid accessing Bundle.module if running in a packaged app.
|
||||
if Bundle.main.bundleURL.pathExtension.lowercased() != "app" {
|
||||
#if SWIFT_PACKAGE
|
||||
if let bundled = Bundle.module.url(forResource: addon.binaryName, withExtension: nil),
|
||||
FileManager.default.isExecutableFile(atPath: bundled.path) {
|
||||
return bundled
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkLocalInstallation() {
|
||||
for addon in AddonType.allCases {
|
||||
guard installTasks[addon] == nil else { continue }
|
||||
let path = binaryPath(for: addon)
|
||||
if FileManager.default.isExecutableFile(atPath: path.path) {
|
||||
if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
|
||||
setState(for: addon, to: .installed(version: version))
|
||||
} else {
|
||||
setState(for: addon, to: .installed(version: "Unknown"))
|
||||
}
|
||||
if binaryPath(for: addon) != nil {
|
||||
setState(for: addon, to: .installed(version: "Bundled"))
|
||||
} else {
|
||||
setState(for: addon, to: .notInstalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLatestConfig() async throws -> GatekeeperConfig {
|
||||
var request = URLRequest(url: configURL)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.timeoutInterval = 30
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(GatekeeperConfig.self, from: data)
|
||||
}
|
||||
|
||||
func ensureInstalled(addons requiredAddons: Set<AddonType> = Set(AddonType.allCases)) async throws {
|
||||
let config = try await fetchLatestConfig()
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
|
||||
let task = installationTask(for: addon, from: config)
|
||||
group.addTask {
|
||||
try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
try await group.waitForAll()
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAvailable(addons requiredAddons: Set<AddonType>) async throws {
|
||||
checkLocalInstallation()
|
||||
let missingAddons = requiredAddons.filter { addon in
|
||||
switch state(for: addon) {
|
||||
case .installed:
|
||||
return false
|
||||
case .downloading, .notInstalled, .failed:
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard !missingAddons.isEmpty else { return }
|
||||
try await ensureInstalled(addons: missingAddons)
|
||||
}
|
||||
|
||||
private func shouldInstall(addon: AddonType, config: GatekeeperConfig) -> Bool {
|
||||
let state: AddonState
|
||||
let configVersion: String?
|
||||
switch addon {
|
||||
case .ytDlp:
|
||||
state = ytDlpState
|
||||
configVersion = config.ytDlp?.version
|
||||
case .ffmpeg:
|
||||
state = ffmpegState
|
||||
configVersion = config.ffmpeg?.version
|
||||
for missing in missingAddons {
|
||||
setState(for: missing, to: .failed(error: "Bundled executable missing"))
|
||||
}
|
||||
|
||||
switch state {
|
||||
case .notInstalled, .failed:
|
||||
return true
|
||||
case .downloading:
|
||||
return true
|
||||
case .installed(let version):
|
||||
guard let configVersion else { return false }
|
||||
return version != configVersion
|
||||
}
|
||||
}
|
||||
|
||||
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
|
||||
if let task = installTasks[addon] {
|
||||
return task
|
||||
}
|
||||
|
||||
let task = Task { @MainActor in
|
||||
defer { self.installTasks[addon] = nil }
|
||||
try await self.install(addon: addon, from: config)
|
||||
}
|
||||
installTasks[addon] = task
|
||||
return task
|
||||
throw NSError(domain: "MediaEngineErrorDomain", code: 1, userInfo: [NSLocalizedDescriptionKey: "One or more required media engines are missing from the app bundle. Reinstall Firelink or rebuild the app bundle."])
|
||||
}
|
||||
|
||||
private func state(for addon: AddonType) -> AddonState {
|
||||
@@ -150,60 +85,6 @@ final class MediaEngineManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func install(addon: AddonType, from config: GatekeeperConfig) async throws {
|
||||
setState(for: addon, to: .downloading(progress: 0))
|
||||
|
||||
let addonConfig: AddonConfig? = {
|
||||
switch addon {
|
||||
case .ytDlp: return config.ytDlp
|
||||
case .ffmpeg: return config.ffmpeg
|
||||
}
|
||||
}()
|
||||
|
||||
guard let addonConfig = addonConfig else {
|
||||
setState(for: addon, to: .failed(error: "Missing configuration for \(addon.rawValue)"))
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
guard let downloadURL = addonConfig.currentArchURL else {
|
||||
setState(for: addon, to: .failed(error: "No download URL for current architecture"))
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
guard downloadURL.scheme?.lowercased() == "https" else {
|
||||
setState(for: addon, to: .failed(error: "Add-on URL must use HTTPS"))
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expectedSHA256.isEmpty else {
|
||||
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
|
||||
throw BinaryDownloaderError.missingChecksum
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
let destination = binaryPath(for: addon)
|
||||
|
||||
try await BinaryDownloader.download(
|
||||
from: downloadURL,
|
||||
to: destination,
|
||||
expectedSHA256: expectedSHA256
|
||||
) { progress in
|
||||
Task { @MainActor in
|
||||
self.setState(for: addon, to: .downloading(progress: progress))
|
||||
}
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(addonConfig.version, forKey: addon.defaultsKey)
|
||||
setState(for: addon, to: .installed(version: addonConfig.version))
|
||||
|
||||
} catch {
|
||||
setState(for: addon, to: .failed(error: error.localizedDescription))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(for addon: AddonType, to state: AddonState) {
|
||||
switch addon {
|
||||
case .ytDlp: ytDlpState = state
|
||||
|
||||
@@ -26,6 +26,11 @@ struct MediaMetadata: Decodable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaType: String, Sendable, Equatable {
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
}
|
||||
|
||||
struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
@@ -35,6 +40,10 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
let outputExtension: String
|
||||
let detail: String
|
||||
let estimatedBytes: Int64?
|
||||
|
||||
let mediaType: MediaType
|
||||
let qualityName: String
|
||||
let containerName: String
|
||||
}
|
||||
|
||||
enum MediaExtractionEngine {
|
||||
@@ -51,7 +60,7 @@ enum MediaExtractionEngine {
|
||||
case .processFailed(let msg): return "Extraction failed: \(msg)"
|
||||
case .invalidOutput: return "Invalid output from media engine."
|
||||
case .parsingFailed(let err): return "Failed to parse metadata: \(err.localizedDescription)"
|
||||
case .timedOut: return "Fetching metadata timed out. Try again, update yt-dlp, or change the selected browser cookie source."
|
||||
case .timedOut: return "Fetching metadata timed out. Try again or change the selected browser cookie source."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,12 +71,13 @@ enum MediaExtractionEngine {
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
let ytDlpPath = await MediaEngineManager.shared.binaryPath(for: .ytDlp).path
|
||||
guard FileManager.default.isExecutableFile(atPath: ytDlpPath) else {
|
||||
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
|
||||
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
throw ExtractionError.processFailed("yt-dlp binary not found.")
|
||||
}
|
||||
let ytDlpPath = ytDlpURL.path
|
||||
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--extractor-args", "youtube:player_client=ios,tv"]
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--force-ipv4"]
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
args.append(url.absoluteString)
|
||||
|
||||
@@ -116,11 +126,12 @@ enum MediaExtractionEngine {
|
||||
}
|
||||
|
||||
private static func appendJavaScriptRuntimeArguments(to args: inout [String]) {
|
||||
var runtimes: [String] = []
|
||||
if let denoPath = executablePath(named: "deno", candidates: [
|
||||
"/opt/homebrew/bin/deno",
|
||||
"/usr/local/bin/deno"
|
||||
]) {
|
||||
args.append(contentsOf: ["--js-runtimes", "deno:\(denoPath)"])
|
||||
runtimes.append("deno:\(denoPath)")
|
||||
}
|
||||
|
||||
if let nodePath = executablePath(named: "node", candidates: [
|
||||
@@ -128,23 +139,28 @@ enum MediaExtractionEngine {
|
||||
"/usr/local/bin/node",
|
||||
"/usr/bin/node"
|
||||
]) {
|
||||
args.append(contentsOf: ["--js-runtimes", "node:\(nodePath)"])
|
||||
runtimes.append("node:\(nodePath)")
|
||||
}
|
||||
|
||||
if !runtimes.isEmpty {
|
||||
args.append(contentsOf: ["--js-runtimes", runtimes.joined(separator: ",")])
|
||||
}
|
||||
}
|
||||
|
||||
private static func executablePath(named name: String, candidates: [String]) -> String? {
|
||||
if let path = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) {
|
||||
return path
|
||||
}
|
||||
var safeCandidates = candidates
|
||||
safeCandidates.append(contentsOf: [
|
||||
"/opt/homebrew/bin/\(name)",
|
||||
"/usr/local/bin/\(name)",
|
||||
"/usr/bin/\(name)",
|
||||
"/opt/local/bin/\(name)"
|
||||
])
|
||||
|
||||
let pathEnvironment = ProcessInfo.processInfo.environment["PATH"] ?? ""
|
||||
for directory in pathEnvironment.split(separator: ":") {
|
||||
let candidate = URL(fileURLWithPath: String(directory)).appendingPathComponent(name).path
|
||||
for candidate in safeCandidates {
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -187,7 +203,10 @@ enum MediaExtractionEngine {
|
||||
base: height == nil ? "Best available video" : "Up to \(qualityName)",
|
||||
estimatedBytes: estimatedBytes
|
||||
),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .video,
|
||||
qualityName: qualityName,
|
||||
containerName: containerName
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -201,7 +220,10 @@ enum MediaExtractionEngine {
|
||||
symbol: "music.note",
|
||||
outputExtension: "mp3",
|
||||
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "MP3"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -214,7 +236,10 @@ enum MediaExtractionEngine {
|
||||
symbol: "waveform",
|
||||
outputExtension: "m4a",
|
||||
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "M4A"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -227,7 +252,10 @@ enum MediaExtractionEngine {
|
||||
symbol: "waveform",
|
||||
outputExtension: "opus",
|
||||
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "Opus"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -374,43 +402,57 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
process.standardError = errorPipe
|
||||
process.standardInput = nil
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
outputBuffer.append(data)
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
outputBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
errorPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
errorBuffer.append(data)
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
self.process = process
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
continuation.resume(returning: outputBuffer.data)
|
||||
return
|
||||
}
|
||||
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
|
||||
} else {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stdout = String(data: outputBuffer.data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let message = [stderr, stdout]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(process.terminationStatus)" : message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -420,6 +462,7 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
} catch {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
// We do not care about the DispatchGroup if we throw immediately here
|
||||
continuation.resume(throwing: MediaExtractionEngine.ExtractionError.processFailed(error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MediaInspectorInlineView: View {
|
||||
let url: URL
|
||||
let cookieSource: BrowserCookieSource
|
||||
let credentials: DownloadCredentials?
|
||||
let transferOptions: DownloadTransferOptions
|
||||
let onCancel: () -> Void
|
||||
let onDownload: (CleanFormatOption, MediaMetadata) -> Void
|
||||
|
||||
@ObservedObject private var engineManager = MediaEngineManager.shared
|
||||
|
||||
@State private var isLoading = true
|
||||
@State private var statusText = "Checking Media Engine..."
|
||||
@State private var metadata: MediaMetadata?
|
||||
@State private var options: [CleanFormatOption] = []
|
||||
@State private var errorMessage: String?
|
||||
@State private var loadTask: Task<Void, Never>?
|
||||
|
||||
enum MediaType: String, CaseIterable, Identifiable {
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@State private var selectedType: MediaType = .video
|
||||
@State private var selectedVideoQuality: String = "Best"
|
||||
@State private var selectedVideoFormat: String = "MP4"
|
||||
@State private var selectedAudioFormat: String = "MP3"
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
|
||||
let ytState = engineManager.ytDlpState
|
||||
let ffState = engineManager.ffmpegState
|
||||
|
||||
if case let .downloading(p) = ytState, p > 0 {
|
||||
Text("Downloading yt-dlp: \(Int(p * 100))%")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case let .downloading(p) = ffState, p > 0 {
|
||||
Text("Downloading ffmpeg: \(Int(p * 100))%")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(statusText)
|
||||
.foregroundStyle(.secondary)
|
||||
cookieStatusLabel
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
} else if let errorMessage {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
Button("Retry") {
|
||||
loadMetadata()
|
||||
}
|
||||
} else if let metadata {
|
||||
if let thumbnail = metadata.thumbnail {
|
||||
AsyncImage(url: thumbnail) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
|
||||
} placeholder: {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.quaternary)
|
||||
.frame(width: 80, height: 50)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(metadata.title ?? "Unknown Title")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Picker("Type", selection: $selectedType) {
|
||||
ForEach(availableTypes) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 80)
|
||||
|
||||
if selectedType == .video {
|
||||
Picker("Quality", selection: $selectedVideoQuality) {
|
||||
ForEach(availableVideoQualities, id: \.self) { q in
|
||||
Text(q).tag(q)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
|
||||
Picker("Format", selection: $selectedVideoFormat) {
|
||||
ForEach(availableVideoFormats, id: \.self) { f in
|
||||
Text(f).tag(f)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 80)
|
||||
} else {
|
||||
Picker("Format", selection: $selectedAudioFormat) {
|
||||
ForEach(availableAudioFormats, id: \.self) { f in
|
||||
Text(f).tag(f)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
}
|
||||
}
|
||||
|
||||
if let selected = resolveSelectedOption() {
|
||||
Text(selected.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Button("Extract") {
|
||||
if let selected = resolveSelectedOption() {
|
||||
onDownload(selected, metadata)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(resolveSelectedOption() == nil)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(.quaternary.opacity(0.35))
|
||||
)
|
||||
.onAppear {
|
||||
loadMetadata()
|
||||
}
|
||||
.onDisappear {
|
||||
loadTask?.cancel()
|
||||
loadTask = nil
|
||||
}
|
||||
.onChange(of: url) { _, _ in loadMetadata() }
|
||||
.onChange(of: cookieSource) { _, _ in loadMetadata() }
|
||||
.onChange(of: credentials) { _, _ in loadMetadata() }
|
||||
.onChange(of: transferOptions) { _, _ in loadMetadata() }
|
||||
.onChange(of: selectedType) { _, _ in ensureValidSelection() }
|
||||
.onChange(of: options) { _, _ in ensureValidSelection() }
|
||||
}
|
||||
|
||||
private var availableTypes: [MediaType] {
|
||||
var types: [MediaType] = []
|
||||
if options.contains(where: { !$0.isAudioOnly }) { types.append(.video) }
|
||||
if options.contains(where: { $0.isAudioOnly }) { types.append(.audio) }
|
||||
return types
|
||||
}
|
||||
|
||||
private var availableVideoQualities: [String] {
|
||||
let qualities = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").first ?? "" }
|
||||
return NSOrderedSet(array: qualities).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private var availableVideoFormats: [String] {
|
||||
let formats = options.filter { !$0.isAudioOnly }.map { $0.name.components(separatedBy: " ").last ?? "" }
|
||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private var availableAudioFormats: [String] {
|
||||
let formats = options.filter { $0.isAudioOnly }.map { $0.name.replacingOccurrences(of: "Audio ", with: "") }
|
||||
return NSOrderedSet(array: formats).array as? [String] ?? []
|
||||
}
|
||||
|
||||
private func ensureValidSelection() {
|
||||
if !availableTypes.contains(selectedType), let first = availableTypes.first {
|
||||
selectedType = first
|
||||
}
|
||||
if selectedType == .video {
|
||||
if !availableVideoQualities.contains(selectedVideoQuality), let first = availableVideoQualities.first {
|
||||
selectedVideoQuality = first
|
||||
}
|
||||
if !availableVideoFormats.contains(selectedVideoFormat), let first = availableVideoFormats.first {
|
||||
selectedVideoFormat = first
|
||||
}
|
||||
} else {
|
||||
if !availableAudioFormats.contains(selectedAudioFormat), let first = availableAudioFormats.first {
|
||||
selectedAudioFormat = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveSelectedOption() -> CleanFormatOption? {
|
||||
if selectedType == .video {
|
||||
return options.first { !$0.isAudioOnly && $0.name == "\(selectedVideoQuality) \(selectedVideoFormat)" }
|
||||
} else {
|
||||
return options.first { $0.isAudioOnly && $0.name == "Audio \(selectedAudioFormat)" }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cookieStatusLabel: some View {
|
||||
if let browserName = cookieSource.ytDlpBrowserName {
|
||||
Label("Using \(browserName.capitalized) cookies", systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Browser cookies off", systemImage: "circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMetadata() {
|
||||
loadTask?.cancel()
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
metadata = nil
|
||||
options = []
|
||||
|
||||
loadTask = Task {
|
||||
do {
|
||||
await MainActor.run { statusText = "Checking yt-dlp..." }
|
||||
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp])
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run { statusText = "Fetching Metadata..." }
|
||||
let (fetchedMetadata, fetchedOptions) = try await MediaExtractionEngine.fetchMetadata(
|
||||
for: url,
|
||||
cookieSource: cookieSource,
|
||||
credentials: credentials,
|
||||
transferOptions: transferOptions
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
if fetchedOptions.isEmpty {
|
||||
self.errorMessage = "No downloadable media formats were found."
|
||||
} else {
|
||||
self.metadata = fetchedMetadata
|
||||
self.options = fetchedOptions
|
||||
self.ensureValidSelection()
|
||||
}
|
||||
self.loadTask = nil
|
||||
withAnimation { self.isLoading = false }
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.loadTask = nil
|
||||
withAnimation { self.isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct AboutSettingsPane: View {
|
||||
@EnvironmentObject var sparkleUpdater: SparkleUpdater
|
||||
@EnvironmentObject private var updateChecker: ReleaseUpdateChecker
|
||||
|
||||
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
|
||||
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
|
||||
private let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
|
||||
private let aria2URL = URL(string: "https://aria2.github.io/")!
|
||||
private let ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
|
||||
private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
|
||||
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
|
||||
|
||||
private var appVersion: String {
|
||||
@@ -29,7 +31,7 @@ struct AboutSettingsPane: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Version \(appVersion)")
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
@@ -41,176 +43,15 @@ struct AboutSettingsPane: View {
|
||||
|
||||
Section("Updates") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if sparkleUpdater.isChecking {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking for updates...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if sparkleUpdater.isDownloading || sparkleUpdater.isExtracting {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(sparkleUpdater.isDownloading ? "Downloading update..." : "Extracting update...")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
if sparkleUpdater.isDownloading && sparkleUpdater.downloadProgress > 0 {
|
||||
Text("\(Int(sparkleUpdater.downloadProgress * 100))%")
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
ProgressView(value: sparkleUpdater.isDownloading ? sparkleUpdater.downloadProgress : sparkleUpdater.extractionProgress)
|
||||
.tint(.accentColor)
|
||||
|
||||
Button("Cancel") {
|
||||
sparkleUpdater.cancellation?()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if sparkleUpdater.isReadyToInstall {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.app.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Update Ready")
|
||||
.font(.headline)
|
||||
Text("The new version is ready to be installed.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
sparkleUpdater.updateChoiceReply?(.install)
|
||||
} label: {
|
||||
Label("Install and Relaunch", systemImage: "sparkles")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
} else if let item = sparkleUpdater.foundUpdateItem {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.arrow.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.title)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Update Available")
|
||||
.font(.headline)
|
||||
Text("Version \(item.displayVersionString) is available.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let notes = sparkleUpdater.releaseNotes, !notes.isEmpty {
|
||||
DisclosureGroup("What's New") {
|
||||
ScrollView {
|
||||
Text(notes)
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxHeight: 150)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
sparkleUpdater.updateChoiceReply?(.install)
|
||||
} label: {
|
||||
Text("Download & Install")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Skip This Version") {
|
||||
sparkleUpdater.updateChoiceReply?(.skip)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Up to date or initial state
|
||||
if let status = sparkleUpdater.updateStatus, status == "You're up to date!" {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.title)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("You're up to date!")
|
||||
.font(.headline)
|
||||
Text("Firelink \(appVersion) is the newest version available.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
sparkleUpdater.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
|
||||
} label: {
|
||||
Label("Release Notes", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
if let status = sparkleUpdater.updateStatus {
|
||||
if status.lowercased().contains("failed") || status.lowercased().contains("error") {
|
||||
Image(systemName: "xmark.octagon.fill")
|
||||
.foregroundStyle(.red)
|
||||
} else {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
Text(status)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Keeping your app up to date ensures you have the latest features and security improvements.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
updateStatusView
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
sparkleUpdater.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
|
||||
} label: {
|
||||
Label("Release Notes", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
.padding(.vertical, 2)
|
||||
|
||||
Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.animation(.easeInOut, value: sparkleUpdater.isChecking)
|
||||
.animation(.easeInOut, value: sparkleUpdater.isDownloading)
|
||||
.animation(.easeInOut, value: sparkleUpdater.isExtracting)
|
||||
.animation(.easeInOut, value: sparkleUpdater.isReadyToInstall)
|
||||
.animation(.easeInOut, value: sparkleUpdater.foundUpdateItem != nil)
|
||||
.animation(.easeInOut, value: updateChecker.state)
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -236,7 +77,13 @@ struct AboutSettingsPane: View {
|
||||
|
||||
HStack {
|
||||
Text("Powered by")
|
||||
Link("aria2", destination: aria2URL)
|
||||
HStack(spacing: 4) {
|
||||
Link("aria2", destination: aria2URL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("yt-dlp", destination: ytDlpURL)
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
Link("ffmpeg", destination: ffmpegURL)
|
||||
}
|
||||
Spacer()
|
||||
Link("MIT License", destination: licenseURL)
|
||||
}
|
||||
@@ -251,4 +98,84 @@ struct AboutSettingsPane: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var updateStatusView: some View {
|
||||
HStack(alignment: .center) {
|
||||
switch updateChecker.state {
|
||||
case .idle:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Check for Updates")
|
||||
.font(.headline)
|
||||
Text("Firelink checks GitHub Releases for new versions.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Check Now") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
|
||||
case .checking:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Checking for updates...")
|
||||
.font(.headline)
|
||||
Text("Connecting to GitHub...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.trailing, 16)
|
||||
|
||||
case .updateAvailable(let update):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Firelink \(update.version) is available!")
|
||||
.font(.headline)
|
||||
Text("You currently have version \(appVersion).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Link("View Release Notes", destination: update.releaseURL)
|
||||
.font(.caption)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer()
|
||||
Button("Download Update") {
|
||||
NSWorkspace.shared.open(update.releaseURL)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
case .upToDate(let latestVersion, _):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Firelink is up to date")
|
||||
.font(.headline)
|
||||
Text("Version \(latestVersion) is the newest stable release.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Check Again") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
|
||||
case .failed(let message, let recovery):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(message)
|
||||
.font(.headline)
|
||||
Text(recovery)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Button("Try Again") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ struct EngineSettingsPane: View {
|
||||
@StateObject private var engineManager = MediaEngineManager.shared
|
||||
@State private var version = "Checking..."
|
||||
|
||||
@State private var isCheckingForUpdates = false
|
||||
@State private var updateCheckResult: String?
|
||||
|
||||
private var executableURL: URL? {
|
||||
Aria2DownloadEngine.findExecutable()
|
||||
}
|
||||
@@ -16,23 +13,13 @@ struct EngineSettingsPane: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Status") {
|
||||
if executableURL != nil {
|
||||
Label("Ready", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Missing", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
LabeledContent("Version") {
|
||||
Text(version)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
|
||||
LabeledContent("Binary Path") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
@@ -54,40 +41,10 @@ struct EngineSettingsPane: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Updates") {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
checkMediaEngineUpdates()
|
||||
} label: {
|
||||
Text("Check for Updates")
|
||||
}
|
||||
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState, path: engineManager.binaryPath(for: .ytDlp))
|
||||
|
||||
if isCheckingForUpdates {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking...")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else if let result = updateCheckResult {
|
||||
if result == "Up to date" || result == "Updated successfully" {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(result)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(result)
|
||||
.foregroundStyle(.red)
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||
|
||||
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
|
||||
|
||||
LabeledContent("Browser Cookies") {
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
@@ -97,14 +54,12 @@ struct EngineSettingsPane: View {
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState)
|
||||
} header: {
|
||||
Text("Media Extractors")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Powers video and audio extraction from supported sites.")
|
||||
|
||||
|
||||
if settings.mediaCookieSource != .none {
|
||||
Text(settings.mediaCookieSource.statusDetail)
|
||||
}
|
||||
@@ -117,63 +72,30 @@ struct EngineSettingsPane: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func checkMediaEngineUpdates() {
|
||||
Task {
|
||||
isCheckingForUpdates = true
|
||||
updateCheckResult = nil
|
||||
// Brief visual feedback delay
|
||||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||||
|
||||
do {
|
||||
let wasDownloading = isDownloadingMediaEngines
|
||||
try await engineManager.ensureInstalled()
|
||||
if wasDownloading || isDownloadingMediaEngines {
|
||||
updateCheckResult = "Updated successfully"
|
||||
} else {
|
||||
updateCheckResult = "Up to date"
|
||||
}
|
||||
} catch {
|
||||
updateCheckResult = "Update failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isCheckingForUpdates = false
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if !isCheckingForUpdates {
|
||||
withAnimation {
|
||||
updateCheckResult = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isDownloadingMediaEngines: Bool {
|
||||
if case .downloading = engineManager.ytDlpState { return true }
|
||||
if case .downloading = engineManager.ffmpegState { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func addonStatusRow(title: String, state: AddonState) -> some View {
|
||||
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
|
||||
LabeledContent(title) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Label("Missing", systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
case .downloading(let progress):
|
||||
HStack(spacing: 6) {
|
||||
ProgressView(value: progress)
|
||||
.frame(width: 60)
|
||||
Text("\(Int(progress * 100))%")
|
||||
.monospacedDigit()
|
||||
VStack(alignment: .trailing) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
.foregroundStyle(.red)
|
||||
case .installed(let version):
|
||||
Text(version)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Text("Error")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
case .installed(let version):
|
||||
Label("v\(version)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
case .failed(let error):
|
||||
Label("Failed", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
|
||||
Text(path?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Sparkle
|
||||
|
||||
class InlineUpdateUserDriver: NSObject, SPUUserDriver {
|
||||
weak var updater: SparkleUpdater?
|
||||
|
||||
init(updater: SparkleUpdater) {
|
||||
self.updater = updater
|
||||
}
|
||||
|
||||
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
|
||||
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
|
||||
}
|
||||
|
||||
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.resetState()
|
||||
self.updater?.isChecking = true
|
||||
self.updater?.updateStatus = "Checking for updates..."
|
||||
self.updater?.cancellation = cancellation
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isChecking = false
|
||||
self.updater?.foundUpdateItem = appcastItem
|
||||
self.updater?.updateStatus = "Update available: Version \(appcastItem.displayVersionString)"
|
||||
self.updater?.updateChoiceReply = reply
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
|
||||
DispatchQueue.main.async {
|
||||
if let htmlString = String(data: downloadData.data, encoding: .utf8) {
|
||||
self.updater?.releaseNotes = self.stripHTML(htmlString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stripHTML(_ string: String) -> String {
|
||||
guard let data = string.data(using: .utf8) else { return string }
|
||||
if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) {
|
||||
return attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {
|
||||
}
|
||||
|
||||
func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isChecking = false
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == SUSparkleErrorDomain && nsError.code == 1001 {
|
||||
self.updater?.updateStatus = "You're up to date!"
|
||||
} else {
|
||||
self.updater?.updateStatus = "Update check failed: \(error.localizedDescription)"
|
||||
}
|
||||
acknowledgement()
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isChecking = false
|
||||
self.updater?.updateStatus = "Updater error: \(error.localizedDescription)"
|
||||
acknowledgement()
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadInitiated(cancellation: @escaping () -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isDownloading = true
|
||||
self.updater?.downloadProgress = 0.0
|
||||
self.updater?.cancellation = cancellation
|
||||
self.updater?.updateStatus = "Downloading update..."
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.expectedContentLength = expectedContentLength
|
||||
self.updater?.receivedContentLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadDidReceiveData(ofLength length: UInt64) {
|
||||
DispatchQueue.main.async {
|
||||
if let updater = self.updater {
|
||||
updater.receivedContentLength += length
|
||||
if updater.expectedContentLength > 0 {
|
||||
updater.downloadProgress = Double(updater.receivedContentLength) / Double(updater.expectedContentLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadDidStartExtractingUpdate() {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isDownloading = false
|
||||
self.updater?.isExtracting = true
|
||||
self.updater?.updateStatus = "Extracting update..."
|
||||
self.updater?.downloadProgress = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
func showExtractionReceivedProgress(_ progress: Double) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.extractionProgress = progress
|
||||
}
|
||||
}
|
||||
|
||||
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isExtracting = false
|
||||
self.updater?.isReadyToInstall = true
|
||||
self.updater?.updateStatus = "Ready to install"
|
||||
self.updater?.updateChoiceReply = reply
|
||||
}
|
||||
}
|
||||
|
||||
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
|
||||
}
|
||||
|
||||
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
|
||||
acknowledgement()
|
||||
}
|
||||
|
||||
func dismissUpdateInstallation() {
|
||||
DispatchQueue.main.async {
|
||||
self.updater?.isChecking = false
|
||||
self.updater?.isDownloading = false
|
||||
self.updater?.isExtracting = false
|
||||
self.updater?.isReadyToInstall = false
|
||||
self.updater?.downloadProgress = 0.0
|
||||
self.updater?.extractionProgress = 0.0
|
||||
self.updater?.foundUpdateItem = nil
|
||||
self.updater?.releaseNotes = nil
|
||||
// Do not clear updateStatus here so success/error messages remain visible.
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateInFocus() {
|
||||
}
|
||||
}
|
||||
@@ -3,51 +3,106 @@ import SwiftUI
|
||||
|
||||
struct IntegrationSettingsPane: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var showToast = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
Image(systemName: "puzzlepiece.extension")
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundStyle(.orange)
|
||||
.accessibilityHidden(true)
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Image(systemName: "puzzlepiece.extension.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 40, height: 40)
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firefox Extension")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Capture downloads directly from your browser.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Installation") {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.")
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connect Browser Extension")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Capture downloads directly from your browser in three easy steps.")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Install on Firefox", systemImage: "arrow.down.app")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0))) // Firefox Orange
|
||||
.controlSize(.large)
|
||||
.font(.subheadline)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Section("Diagnostics") {
|
||||
LabeledContent("Local receiver") {
|
||||
KeychainAccessCard()
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
HStack(spacing: 12) {
|
||||
// Step 1
|
||||
CompactStepCardView(
|
||||
stepNumber: 1,
|
||||
title: "Copy Token",
|
||||
description: "This secure token authorizes your browser extension.",
|
||||
icon: "doc.on.clipboard.fill",
|
||||
iconColor: .blue,
|
||||
actionText: "Copy Token",
|
||||
action: {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Regenerate",
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 2
|
||||
CompactStepCardView(
|
||||
stepNumber: 2,
|
||||
title: "Get Extension",
|
||||
description: "Install the Firelink Companion extension on your browser.",
|
||||
icon: "globe",
|
||||
iconColor: .orange,
|
||||
actionText: "Firefox Add-ons",
|
||||
action: {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Releases",
|
||||
secondaryAction: {
|
||||
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 3
|
||||
CompactStepCardView(
|
||||
stepNumber: 3,
|
||||
title: "Paste & Connect",
|
||||
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
|
||||
icon: "arrow.down.doc.fill",
|
||||
iconColor: .green,
|
||||
actionText: nil,
|
||||
action: nil
|
||||
)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Divider()
|
||||
|
||||
// Diagnostics
|
||||
HStack {
|
||||
Text("Diagnostics:")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
@@ -56,14 +111,111 @@ struct IntegrationSettingsPane: View {
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
}
|
||||
|
||||
Section("Permissions & Privacy") {
|
||||
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(24)
|
||||
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
struct CompactStepCardView: View {
|
||||
let stepNumber: Int
|
||||
let title: String
|
||||
let description: String
|
||||
let icon: String
|
||||
let iconColor: Color
|
||||
let actionText: String?
|
||||
let action: (() -> Void)?
|
||||
var secondaryActionText: String? = nil
|
||||
var secondaryAction: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(alignment: .top) {
|
||||
// Step Number Badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.frame(width: 24, height: 24)
|
||||
.shadow(color: .black.opacity(0.1), radius: 1, y: 1)
|
||||
|
||||
Text("\(stepNumber)")
|
||||
.font(.system(.caption, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Spacer()
|
||||
// Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(iconColor.opacity(0.15))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Text Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
// Action Button
|
||||
VStack(spacing: 8) {
|
||||
if let actionText = actionText, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionText)
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
|
||||
Button(action: secondaryAction) {
|
||||
Text(secondaryActionText)
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundColor(.primary)
|
||||
.cornerRadius(6)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
|
||||
struct KeychainAccessCard: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Keychain Access")
|
||||
.font(.headline)
|
||||
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Label("Granted", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.subheadline.weight(.medium))
|
||||
|
||||
Button(role: .destructive) {
|
||||
settings.revokeKeychainAccess()
|
||||
} label: {
|
||||
Text("Revoke")
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
settings.grantKeychainAccess()
|
||||
} label: {
|
||||
Text("Grant Access")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,14 @@ struct SiteLoginsSettingsPane: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||
Section {
|
||||
KeychainAccessCard()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
@@ -71,6 +78,7 @@ struct SiteLoginsSettingsPane: View {
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ToastNotification: ViewModifier {
|
||||
var message: String
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
content
|
||||
|
||||
if isShowing {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.8))
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
|
||||
)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.zIndex(1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
|
||||
.task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
withAnimation {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func toast(isShowing: Binding<Bool>, message: String) -> some View {
|
||||
self.modifier(ToastNotification(message: message, isShowing: isShowing))
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Firelink Updates</title>
|
||||
<link>https://github.com/nimbold/Firelink</link>
|
||||
<description>Most recent updates for Firelink</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Version 0.5.7</title>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
|
||||
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.5.7</sparkle:releaseNotesLink>
|
||||
<pubDate>Sat, 06 Jun 2026 11:36:06 +0000</pubDate>
|
||||
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.5.7/Firelink-0.5.7-mac-arm64.dmg"
|
||||
sparkle:version="22"
|
||||
sparkle:shortVersionString="0.5.7"
|
||||
length="8871979"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="Y4QEJqGw6IeXL5JmaRqt5U22Bmc8JD02+oLRIFoKgOJO4v6p6+AmBP9VoM3yp/MAzj+gJMWiI/Gf4aPx4x3dAg==" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
Reference in New Issue
Block a user