Compare commits

..

380 Commits

Author SHA1 Message Date
NimBold 3c8800805f chore(release): prepare Firelink 1.4.2 2026-09-08 22:30:27 +03:30
NimBold 6b42d878c2 fix(torrent): clarify peer threshold guidance
- explain that the threshold triggers aggregate-speed peer discovery
- separate threshold and maximum-peer help in Properties
- document Aria2 defaults and speed units across all supported locales
2026-09-08 21:49:24 +03:30
NimBold 8fd73a145e fix(extension): support browser-local torrent attachments
- Cache signed browser-local torrent bytes in the desktop handoff server.\n- Preserve managed torrent identity through Add-window inspection and rekey.\n- Advance the tested browser companion submodule to the published capture fix.
2026-09-08 21:15:31 +03:30
NimBold 5f76277aaf fix(extension): capture automatic torrent downloads
- advance Companion to the pre-download torrent handoff fix

- keep the Firelink protocol and native desktop integration contract unchanged
2026-09-08 19:22:21 +03:30
NimBold 1cfda45334 fix(download): recover duplicate ownership and fail fast on aria2 startup
- Repair ownership-backed and malformed download rows from migration backups.
- Preserve nonterminal removal intent and completed tombstones during recovery.
- Reject unrelated backup names and keep incomplete repairs retryable.
- Surface known Aria2 startup failures before range probes and retry loopback RPC port conflicts.
- Add regression coverage for duplicate recovery and startup failure paths.
2026-09-08 10:40:57 +03:30
NimBold 8cbc1c8768 fix(download): prevent persistence wipeout on startup and recover schema v3 downloads
- Prevent premature persistence activation by installing the subscription only after download state initialization finishes
- Guard persistence subscription and flush handlers against emitting snapshots before database hydration completes
- Preserve array and set reference equality in applyRemovalJob when target job ID is absent, avoiding spurious persistence triggers
- Merge removal jobs monotonically during initDB and suppress redundant identical removal events
- Recover missing downloads, ownership records, and custom queues from schema v3 migration backup when SQLite downloads table is empty
- Add resilience against corrupt backup candidates and maintain referential integrity by excluding tombstoned download paths
- Add unit and regression tests covering persistence hydration barriers, removal job deduplication, and backup recovery
2026-09-07 21:15:16 +03:30
NimBold eca4efb1ad fix(ci): optimize engine build times and add granular Aria2 caching
- replace hardcoded make -j2 with dynamic CPU core detection in aria2 build.sh

- isolate Aria2 source build cache from prebuilt engine downloads

- add atomic cache validation, restoration, and promotion in scripts/engine-aria2-cache.js

- add Rust dependency and target caching with Swatinem/rust-cache@v2

- raise Desktop checks timeout to 45 minutes to prevent job aborts

- harden temporary directory cleanup on Windows process abort
2026-09-07 19:58:49 +03:30
NimBold 33613e8679 fix(deps): refresh runtime dependencies and engine locks
- update Lucide, rustls, and compatible Cargo lock dependencies

- refresh target-specific Windows and Linux FFmpeg archives and checksums

- prevent UTF-8 media stderr truncation from panicking download workers

- correct bundled payload provenance and Aria2 build notices
2026-09-07 18:09:44 +03:30
NimBold d8a6440998 ci: cache verified engine payloads
- cache target-scoped engine payloads with exact lock and toolchain keys\n- validate manifest provenance, checksums, and paths before reuse\n- restrict cache writes to trusted main pushes while allowing release restores\n- fall back to source provisioning when a cache is absent or invalid\n- harden release checksum generation against pipeline races
2026-09-06 20:55:06 +03:30
NimBold 164f9f32a1 fix(engines): promote bundled Aria2 runtimes
- copy the Windows runtime directory into the provisioned payload

- keep the dependency manifest aligned with staged files
2026-09-06 16:56:22 +03:30
NimBold 74eb2f84cf fix(aria2): bundle MinGW runtime dependencies
- collect the Windows DLL dependency closure into the staged Aria2 payload

- expose the trusted runtime directory to native and CI Aria2 processes

- keep OpenSSL provider modules available for Windows smoke and packaged runs
2026-09-06 16:54:52 +03:30
NimBold 9ac3dc27a3 fix(ci): provide MinGW autotools pkg-config macros
- Install the MSYS pkgconf package for Aria2 source provisioning.\n- Expose MSYS and MinGW aclocal directories before autoreconf.\n- Keep release provisioning aligned with the CI dependency contract.
2026-09-06 16:24:26 +03:30
NimBold 752c86c89b fix(ci): keep hashed Aria2 patches canonical
- Force the Aria2 patch checkout to LF on every platform.\n- Keep Windows patch verification aligned with the checked-in digest.\n- Preserve the existing source-build and provenance checks.
2026-09-06 16:07:56 +03:30
NimBold 2858b4c757 fix(aria2): include callback definition for gcc builds
- Include DHTMessageCallback.h before namespace aria2 so GCC can instantiate unique_ptr defaults.\n- Refresh the checked-in Aria2 patch and all platform patch digests.\n- Validate the clean-source patch application and local engine checks.
2026-09-06 15:55:48 +03:30
NimBold d3345d34e1 fix(ci): repair cross-platform cleanup checks
- Remove the duplicate Windows filesystem flag import and add the missing file identity import.\n- Make the Linux replacement-file regression deterministic while retaining permission-repair coverage.\n- Re-run the native test suite before publishing the corrective commit.
2026-09-06 15:36:09 +03:30
NimBold 3624db280c fix(downloads): report torrent allocation and remove files asynchronously
- Add lifecycle-fenced Aria2 allocation telemetry and shared animated status presentation.
- Persist background removal jobs, retain Removing rows, and provide explicit retry after failures.
- Fence cleanup against stale dispatch, native controls, completion races, and replaced assets across restart.
- Preserve completed and seeding payloads through Trash and permit safe permission-repair retries.
- Require patched engine capabilities and reproducible Windows/Linux provisioning; isolate packaged smoke storage.
- Verify 561 frontend tests, 672 native tests, 72 script tests, i18n/build checks, and macOS package/engine smoke checks.
2026-09-06 15:26:59 +03:30
NimBold d4dcaf38b7 fix(window): restore clear Windows frame contours
- Apply stronger active and inactive renderer contours only on Windows.

- Keep native shadows disabled for main and Properties windows to prevent rectangular corner bleed.

- Lock platform configuration and maximized-frame invariants with regression tests.
2026-09-06 07:54:22 +03:30
NimBold c3afc414a3 fix(window): harden frame geometry across window states
- Remove rounded renderer corners from opaque Linux window surfaces.
- Track maximized state with race-safe native reads for main and Properties windows.
- Flatten maximized Windows frames without changing macOS AppKit zoom contours.
- Make early platform detection explicit, testable, and fail closed on unsupported targets.
2026-09-05 20:54:46 +03:30
NimBold 1ddfaec338 fix(window): refine cross-platform window frames and eliminate Windows shadow bleed
- Set shadow: false on Windows main and Properties window builders to prevent rectangular DWM shadow bleed behind rounded corners.
- Refine active and inactive window frame tokens across themes for subtle, non-glaring boundaries.
- Apply a 0.5px hairline window border rule specifically on macOS to match native Retina AppKit window strokes.
- Synchronize document platform dataset attribute on app boot across main and Properties windows.
- Update and extend window configuration and platform synchronization test suites.
2026-09-05 20:02:31 +03:30
NimBold bbe5445933 fix(window): harden focus lifecycle and cross-platform controls
- Prevent listener leaks on synchronous registration errors and handle non-function native disposers safely.
- Add immediate DOM focus listener fallback and deduplicate consecutive identical window focus updates.
- Synchronize React focus state immediately when the underlying focus source changes.
- Align Linux Properties window builder with platform policy by disabling unsupported transparency and shadows.
- Restore full caption button opacity on hover and implement native monochrome styling for inactive macOS traffic lights.
2026-09-05 16:11:07 +03:30
NimBold f5e228a589 fix(window): clarify cross-platform window boundaries
- Enable native elevation on macOS and Windows with theme-aware active and inactive contours.

- Track native window focus with race-safe registration and cleanup across main and Properties shells.

- Disable unsupported Linux shadows explicitly and cover focus and platform policies with regression tests.
2026-09-05 15:46:58 +03:30
NimBold f65d98bbd0 fix(ci): resolve Windows cross-drive engine workspace and strip verbatim paths
- allocate engine workspace on the repository drive when os.tmpdir() is cross-drive on Windows
- resolve RUNNER_TEMP on GitHub Actions to align workspace drive with checkout
- strip Win32 \\?\ verbatim prefixes to ensure Tauri resource normalizer compatibility
2026-09-05 10:28:31 +03:30
NimBold 006f941bae ci(release): invoke tauri-command directly to prevent argument stripping on Windows
- execute node scripts/tauri-command.js build directly instead of npm run tauri build --
- prevent PowerShell from consuming -- on Windows runners and triggering npm version print
- maintain cross-platform build parity across macOS, Linux, and Windows
2026-09-05 09:56:30 +03:30
NimBold 2606502293 fix(settings): resolve network section overlap and harden panel layouts
- Restore 24px bottom margin on .settings-network-panel .mac-settings-group and add defensive top margin on subsequent section titles to prevent card collision with section headings
- Unify Identity section within the network-settings-panel-general tabpanel to fix ARIA containment and layout hierarchy
- Guard system proxy status note to avoid empty border rows during idle and display contextual status icons with settings-network-note styling
- Align Site Logins validation error messages to the input column in the two-column grid
- Make Integrations step guide cards flexible with min-h-[190px] to prevent vertical text clipping across translations and large font sizes
2026-09-05 09:24:31 +03:30
NimBold bbfc6e9eb1 fix(network): harden native proxy discovery and FFmpeg provider matching
- discover active macOS dynamic store proxies via scutil before networksetup

- treat missing registry keys and unavailable probe tools as clean absence instead of hard error

- support two-part versions and tag-exact asset patterns in BtbN FFmpeg updates

- normalize BtbN n-prefixed FFmpeg versions in sidecar status checks
2026-09-05 08:41:10 +03:30
NimBold d329a5ad91 fix(deps): harden proxy and Cargo update audits
- bound complete native proxy discovery and reject oversized service sets

- reject malformed proxy endpoints while preserving valid IPv6 hosts

- report Cargo graph additions and removals as compatible resolution drift
2026-09-05 08:17:05 +03:30
NimBold 0b620b46f3 chore(engines): standardize FFmpeg 9.0.1 2026-09-05 08:03:13 +03:30
NimBold 86c9c49be2 test(deps): migrate to Vitest 5
- require Node 22.12 across local and CI execution
- make lifecycle and persistence suites repeat-safe
- add a five-repeat race gate with filesystem module caching
2026-09-05 07:47:53 +03:30
NimBold efe9ffa3c2 chore(deps): refresh compatible application dependencies
- update supported npm packages and compatible Cargo locks
- move direct base64 encoding to scalar-only 0.23.1
- preserve Aria2 torrent payload encoding with an exact regression test
2026-09-05 07:45:02 +03:30
NimBold 6440d8ad40 fix(deps): make update and advisory checks complete
- resolve FFmpeg against complete cross-platform stable provider tuples
- report Cargo lock drift through isolated resolver metadata
- gate vulnerabilities and document upstream informational advisories
2026-09-05 07:42:31 +03:30
NimBold ddde763133 fix(network): replace legacy system proxy fallback
- replace sysproxy with bounded asynchronous native platform probes
- preserve deterministic proxy precedence and environment fallback
- cover native parsing, output bounds, and macOS service selection
2026-09-05 07:36:16 +03:30
NimBold 69a818d621 fix(torrent): resolve TUN proxy stalls with DHT bootstrap and UDP translation matching
- Bundle updated aria2c binary with TUN/Fake-IP address translation matching and default DHT bootstrap nodes
- Fallback to 32-bit transaction ID correlation for UDP tracker replies and DHT messages when upstream rewrites return IP
- Cache UDP tracker connection IDs for both rewritten and original addresses to unblock pending announces
- Default to public DHT bootstrap routers on startup to eliminate zero-node routing table starvation
- Update engines.lock.json checksum for the staged aarch64-apple-darwin aria2c sidecar
2026-09-04 09:37:31 +03:30
NimBold ec3bd05547 fix(app): enforce media and scheduler lifecycle boundaries
- keep magnet probe cleanup and resolver fallback inside absolute deadlines
- route provider media and playlists only through HTTP(S)
- enforce scheduler ID, time-format, and running-state invariants across persistence
- isolate Companion release tag checks from ambient Git configuration
2026-09-03 20:54:32 +03:30
NimBold 248b4ac460 fix(app): harden release contracts across shell, queue, and engine boundaries
- Shell & UI: restore focus on sidebar reveal, guard window drag regions, contain context menu Escape keys, and fallback gracefully when a filtered queue is deleted.
- Download table: fix sort order for estimated sizes, descending null values, 3-state sort cycle, and desktop keyboard row navigation.
- Add window: harden duplicate resolution against unmanaged disk targets, draft id keying, and media credential isolation.
- Properties window: add individual Torrent file path copy, horizontal scroll protection for narrow and RTL layouts, and strict numeric bounds on property edits.
- Settings & scheduler: add draft buffering for integer inputs, sanitize persisted scheduler options, synchronize post-queue action countdown cancellation, and localize token regeneration.
- Engine & backend: isolate loopback JSON-RPC from environment proxies with no_proxy, normalize media cookie sources, dispatch container formats case-insensitively, and preserve snake_case arguments in torrent Tauri commands.
- Verification & packaging: isolate Companion git tag resolution from sandboxed git configs, and add regression tests for workflow normalization and portable packaging.
2026-09-03 19:28:27 +03:30
NimBold 41b525ea18 chore(release): prepare Firelink 1.4.1 2026-09-03 09:52:45 +03:30
NimBold 3f4f344620 fix(network): restore native async resolver for route-aware transfers (#38)
- Use NativeAsyncResolver as primary for queue transfers and magnet probes when route contract is available
- Fall back to standard system resolver options on stock Aria2 builds without custom flags
- Guarantee minimum 5s cleanup reserve budget to prevent zero-duration probe cleanup timeouts
- Prevent Aria2 single-threaded main loop freezing under TUN proxies (ref #38)
2026-09-03 08:55:36 +03:30
NimBold 4814c8e92b chore(deps): update cargo crates and ffmpeg engine source locks
- update BtbN FFmpeg autobuild URLs and SHA-256 digests in engine-sources.lock.json to 2026-09-02 release
- upgrade sha1 to 0.11 to deduplicate with tungstenite and eliminate duplicate sha1 0.10.7 and digest 0.10.7 crates
- bump rustls (0.23.43), sysinfo (0.39.6), thiserror (2.0.20), keepawake (0.6.1), rusqlite (0.40.2), and log (0.4.34) in Cargo.toml
- update platform-specific native keyring store dependencies for macOS (1.0.2) and Linux (1.0.1)
- update Cargo.lock transitive dependencies including trash (5.2.7), mio (1.2.3), open (5.4.3), sigchld (0.2.5), and toml (1.1.5)
- keep base64 pinned to 0.22 to preserve alignment with the Tauri and Hyper ecosystem
2026-09-03 07:58:43 +03:30
NimBold c87d4a5ec5 fix(deps): refresh packages and engine payloads
- Update compatible Tauri, i18next, React i18next, and Lucide dependencies.
- Refresh Rust plugin locks and target-specific FFmpeg source metadata.
- Replace the macOS arm64 FFmpeg payload with a verified snapshot.
2026-09-02 02:53:32 +03:30
NimBold 050932dc27 fix(ci): support stock Aria2 route fallback (#38)
- Keep normal and Torrent transfers on the system resolver while preserving proxy routing.\n- Gate the alternate magnet resolver on the optional Firelink capability and disable its default target policy for system-route transfers.\n- Allow stock Windows/Linux engine payloads and make the metadata timeout regression deterministic.
2026-08-31 08:50:13 +03:30
NimBold 4777f1e3c3 fix(network): harden TUN-aware DNS routing (#38)
- Keep hostname resolution on the active OS, TUN, proxy, or Aria2 route while blocking literal local targets.\n- Propagate and attest the Firelink Aria2 resolver and network-policy contract across normal, Torrent, and magnet paths.\n- Fence magnet probe cleanup, redirect/range policy, lifecycle races, and engine staging with regression and smoke coverage.
2026-08-31 07:36:24 +03:30
NimBold 725f5e40ec fix(build): isolate engine staging per invocation
- remove shared staging locks and the repository engine tree
- stage verified target payloads in private atomic workspaces
- fence Tauri bundles and process-tree cleanup across platforms
- align CI, AppImage packaging, smoke checks, and release docs
2026-08-31 01:11:56 +03:30
NimBold b564e92532 fix(network): harden route-aware torrent resolution
- keep reqwest metadata on its supported proxy route and require Aria2 asynchronous DNS
- fence magnet probe ownership and delayed RPC cleanup without cross-transfer removal
- validate Torrent DHT nodes, web seeds, proxy inputs, and literal IPv4/IPv6 targets
- extend regression and smoke coverage for issue #38

Refs: #38
2026-08-30 14:58:01 +03:30
NimBold 1d629873b5 fix(deps): refresh packages and harden engine staging
- update npm, Cargo, Deno, and FFmpeg lockfiles
- refresh verified cross-platform engine payload metadata
- serialize staging, verification, and Tauri packaging across concurrent runs
- recover interrupted lock records and cover crash and ownership races
2026-08-30 10:22:38 +03:30
NimBold 09ad412047 fix(ci): harden cross-platform replacement fingerprints
- Bind new replacement fingerprints to file contents and open-handle identity.\n- Preserve legacy recovery state and keep hashing off async and IPC threads.\n- Refs: #39
2026-08-28 21:13:01 +03:30
NimBold f3cf70ab20 test(ci): stabilize Linux torrent probe harness
- Bound scripted RPC waits and prevent notification races.\n- Use runner-safe budgets for HTTP probe tests after deadline semantics changed.\n- Refs: #39
2026-08-28 20:41:44 +03:30
NimBold a4a9949513 chore(extension): advance Companion for Edge Add-ons (#39)
- Point the Browser submodule at the pushed Companion Edge-preparation commit 20e677d.

- Keep the Firelink 1.4.0 desktop compatibility contract aligned with the extension.

- Keep issue #39 open pending Edge Add-ons upload and Microsoft certification.
2026-08-28 19:02:15 +03:30
NimBold 9bf02f2ee2 fix(network): make DNS routing TUN-aware (issue #38)
- Share literal-target policy and route selection across metadata, Aria2, Torrent, and yt-dlp paths.
- Start direct Aria2 resolution with system DNS and fence a bounded alternate resolver fallback.
- Validate Torrent trackers/web seeds and harden magnet probe cleanup and lifecycle handling.
- Add route, resolver, fallback, and smoke coverage without changing browser IPC payloads.

Refs: #38
2026-08-28 17:25:52 +03:30
NimBold 300e225a3a fix(downloads): align credential recovery after properties edits
- Fixes #37.
- Mark username-only and username-without-password edits for credential recovery immediately.
- Keep in-memory and persisted retry behavior aligned without reusing stale request credentials.
2026-08-28 05:55:57 +03:30
NimBold 9d1e8d994a fix(windows): repair properties chrome and credential recovery
Refs #37.

- Keep the transparent Properties window renderer-owned at its rounded corners without native shadow bleed.
- Keep the complete custom caption-control rail outside Tauri drag hit-testing so every button area receives clicks.
- Retry credential-marked downloads through a fresh lifecycle with restored keychain credentials or a safe credentialless request.
- Preserve keychain consent boundaries, sanitize unavailable request credentials, and retain retryable errors when recovery fails.
- Remove the obsolete manual credentialless retry confirmation and keep all start/resume entry points consistent.
2026-08-28 05:07:54 +03:30
NimBold 7477a26378 fix(properties): avoid Windows WebView deadlock (#37)
- Create Properties WebView windows from an async Tauri command
- Serialize open, close, and removal lifecycle operations
- Refs #37
2026-08-28 02:45:15 +03:30
NimBold e4a698eb7f fix(startup): further split Windows invoke handlers (#37) 2026-08-28 01:00:47 +03:30
NimBold 6b9919c5a1 fix(startup): split Windows invoke handlers (#37) 2026-08-28 00:22:19 +03:30
NimBold 639f5bf091 fix(startup): defer renderer IPC past load callback (#37)
- Issue #37: Windows still overflowed when startup IPC ran from the WebView2 load callback.
- Release the renderer startup gate on the next event-loop task after load returns.
- Keep logger and console forwarding behind the same post-load boundary.

Fixes #37.
2026-08-27 22:45:55 +03:30
NimBold 97f15dee37 fix(startup): gate renderer IPC on document load (#37)
- Issue #37: Windows 1.4.0 exited with STATUS_STACK_OVERFLOW before showing the main window.
- Gate lazy entrypoint imports, Zustand persistence, logger initialization, and console forwarding until WebView2 document load.
- Preserve the native Ready reveal and pending-window restore lifecycle across platforms.
- Show a closeable startup error if the main entrypoint cannot load.

Fixes #37.
2026-08-27 22:26:53 +03:30
NimBold 8d1cde8d2c fix(startup): restore Windows renderer startup (#37)
- Issue #37: Windows 1.4.0 exited before showing its main window.\n- Keep the logger state read independent of WebViewWindow extraction during WebView2 bootstrap.\n- Preserve main-window authorization for logging mutations and keep the process-wide pause state consistent across renderers.\n\nFixes #37.
2026-08-27 21:23:00 +03:30
NimBold a5cecb3777 fix(startup): block Windows activation during WebView2 init (#37)
- Issue #37: keep the Windows host hidden, unfocused, and non-activatable while WebView2 is constructed.\n- Restore focusability and reveal the main window only after Tauri reports Ready.\n- Preserve queued startup restores on non-Windows instead of clearing them before servicing.\n\nFixes #37.
2026-08-27 17:34:04 +03:30
NimBold 9c0ba106d6 fix(startup): defer Windows window activation (#37)
- Keep the Windows WebView host hidden and unfocused during native construction.\n- Queue deep-link, torrent, single-instance, and extension restore requests until Tauri is ready.\n- Reveal and focus the main window through one startup-safe path.\n\nFixes #37.
2026-08-27 17:00:57 +03:30
NimBold 8163e2241a fix(startup): avoid reentrant Windows focus (#37)
- Keep the main WebView2 window unfocused during native construction.
- Remove the synchronous post-build focus request that caused the Windows stack overflow.
- Refs #37.
2026-08-27 15:58:47 +03:30
NimBold 0c65837360 fix(startup): prevent Windows WebView2 focus crash (#37)
- Create the main Windows WebView without requesting focus until the native window exists.\n- Keep hidden Properties WebViews unfocused until their reveal path.\n- Make packaged smoke checks observe startup stability and clean up lingering Unix helpers.\n- Refs #37
2026-08-27 15:27:04 +03:30
NimBold 201bb1e07c fix(startup): support redirected Windows storage paths (#37)
- Issue #37: allow Windows AppData junctions to resolve before the main window starts.
- Keep reparse-point rejection for user-selected download and recovery paths.
- Add Windows junction coverage and v1.3.1 schema migration data-preservation coverage.
2026-08-27 13:21:46 +03:30
NimBold e79c0c1446 chore(release): prepare Firelink 1.4.0
Document the Torrent and Properties release, align desktop version metadata, and pin the published Companion 2.2.0.
2026-08-27 08:26:57 +03:30
NimBold cfe929680c fix(torrents): harden unfinished asset cleanup
- recursively prune empty Torrent output directories without deleting unrelated content
- handle metadata-named directories and fail closed on links or substitutions
- flush current persisted status after in-flight dispatch before Delete File removal
- add recursive cleanup and persistence race regressions
2026-08-27 07:48:38 +03:30
NimBold 3a1703889a fix(extension): sync Firefox capture fix
- advance Companion to the Firefox paused-download handoff fix\n- keep the desktop checkout paired with Companion main\n- preserve the existing Add modal metadata feedback changes
2026-08-27 05:43:42 +03:30
NimBold 7fb05e87da fix(integration): repair capture and magnet loading states
- advance the Browser Companion to the published handoff fix
- show Fetching state while optional magnet metadata is loading
- surface failed magnet metadata without blocking transfer-ready magnets
- preserve regression coverage for cross-layer state transitions
2026-08-27 01:54:48 +03:30
NimBold 62e60938e8 fix(extension): update Browser companion handoff
- Advance the Companion submodule to the hardened browser handoff implementation.
- Keep the parent pinned to the tested extension commit.
2026-08-27 01:02:10 +03:30
NimBold c2a6e3d113 chore(deps): refresh packages and bundled engines
- update Lucide and compatible transitive Rust locks

- refresh Windows/Linux FFmpeg payloads with verified source hashes

- verify macOS release checks against the mounted DMG artifact
2026-08-26 21:39:29 +03:30
NimBold 4d33f9790f fix(torrent): remove unstarted magnets safely
- allow pre-admission staged and paused magnets to remove without ownership
- inspect exact cached Torrent outputs and sidecars before bypassing the guard
- fail closed for malformed metadata and admitted lifecycles
- cover missing, unrelated, partial, and malformed asset cases
2026-08-26 11:25:58 +03:30
NimBold ba491ccd7d fix(torrent): harden magnet metadata enrichment
- Preserve magnet display names before metadata resolution
- Bound optional magnet probes separately from required metadata work
- Fence stale Add-window probes and temporary Torrent metadata across modal races
- Compact responsive Torrent peer metrics across locales
2026-08-25 03:27:49 +03:30
NimBold 51db4fc6b7 fix(torrent): repair magnet handoff and TUN discovery
- recognize direct magnet schemes and keep metadata parents out of terminal completion
- adopt validated followed payload GIDs with bounded, lifecycle-fenced reconciliation
- route direct Torrent DNS through the system resolver for TUN clients
- make magnet preview optional and repair stale fallback drafts
- release paused handoff permits and show peer-wait state consistently
- cover the lifecycle and failure paths with Torrent smokes and regression tests
2026-08-25 02:29:18 +03:30
NimBold b5ee53140d fix(torrent): harden lifecycle and properties state
- admit valid magnets immediately while keeping metadata refresh optional
- fence Torrent recovery, diagnostics, and allocation presentation by lifecycle
- avoid false peer-wait claims when telemetry is missing or malformed
- stabilize Properties metric label/value layout for narrow and RTL windows

Tests:
- npm test -- --run
- npm run build
- npm run check:i18n
- node --test scripts/*.node-test.js
- cargo test --all-targets
- npm run smoke:torrent
- npm run smoke:torrent:failure-paths
- git diff --check
2026-08-24 15:51:24 +03:30
NimBold a6b341f9e3 fix(downloads): allow staged replacements to remove their own target
- Treat the persisted staged row as its own legacy ownership claim
- Keep cross-download and ambiguous ownership conflicts fail-closed
- Cover persisted self-claim cleanup and changed-target protection
2026-08-24 13:44:18 +03:30
NimBold 9a7894a3e2 fix(downloads): clean up pre-admission replacements
- Authorize exact fingerprint-matched cleanup for staged replacements
- Revalidate lifecycle, ownership, paths, and identity changes
- Add race and regression coverage
2026-08-24 09:05:26 +03:30
NimBold b56ec0a3a8 fix(downloads): allow removing unadmitted queue items
- treat pre-admission rows without exact assets as metadata-only cleanup
- retain fail-closed ownership checks for admitted and ambiguous lifecycles
- add regression coverage for normal, media, and Torrent artifact cases
2026-08-24 08:31:07 +03:30
NimBold 45c25da98e fix(settings): harden Network Torrent settings layout
- Replace uneven Torrent tabs with concise localized controls
- Align supporting text and value fields with a responsive network grid
- Preserve keyboard tab navigation and style restart notes consistently
- Keep specialized row rules after generic macOS styles to prevent cascade regressions
2026-08-24 07:45:14 +03:30
NimBold 627e24da0f chore(deps): refresh packages and bundled engines
- update React DOM types and compatible Rust lock dependencies
- refresh Windows and Linux FFmpeg source URLs and checksums
- cover aria2 asset hashes and Windows npm command execution
- verify Firelink and Companion across tests, builds, and engine payloads
2026-08-24 06:57:16 +03:30
NimBold c34f2d2ae6 fix(ci): correct Windows handle initialization
- pass the Win32 null template handle with the binding's pointer type
- keep stable file-identity lookup compilable on the MSVC target
2026-08-23 04:02:09 +03:30
NimBold 0ff0b28e9f fix(ci): repair cross-platform native checks
- make cleanup tests configure an explicit approved root instead of relying on platform download-directory discovery
- replace unstable Windows metadata extensions with stable Win32 file identity lookup
- preserve exact replacement fingerprint validation across enqueue, quarantine, recovery, and cleanup paths
2026-08-23 03:53:37 +03:30
NimBold d49c7bd292 fix(downloads): harden recovery, replacement, and cleanup
- make media credential recovery durable and explicit across lifecycle entry points
- protect exact output replacement with platform-aware ownership, fingerprints, locks, and crash-safe quarantine
- permanently remove unfinished assets while preserving safe completed and Torrent cleanup
- add frontend, native, and localization regressions
2026-08-23 03:24:55 +03:30
NimBold 686bba97a9 fix(release): bound engine provisioning and Linux package setup
- abort locked engine downloads and extraction on process interruption
- recover only dead-PID staging trees while preserving active and legacy trees
- normalize Ubuntu mirrors and bound apt in release jobs
- cover cancellation, orphan cleanup, and workflow contracts with focused tests
2026-08-22 05:30:04 +03:30
NimBold e88425833f fix(handoff): close browser credential boundaries
- Filter custom credential headers and cookies at restricted handoff consumers.
- Preserve ordinary single-file capture credentials for Add-window review.
- Extend native and renderer redaction coverage with focused regressions.
2026-08-22 05:02:04 +03:30
NimBold 3bcad639e2 fix(persistence): harden cross-layer ownership
- Validate and sanitize renderer event payloads before UI projection
- Fence stale enqueue cleanup by native lifecycle generation
- Canonicalize empty startup hydration and harden SQLite backup durability
- Add real-postcondition IPC, restart, storage, and queue regressions
2026-08-22 04:11:02 +03:30
NimBold e6d276e28e fix(torrent): harden RPC and seeding lifecycle
- Retry ambiguous seed resumes without stranding queue permits.
- Bound cached Torrent metadata rereads at every native consumer.
- Validate Aria2 global-option results and retain upload telemetry during lost-event reconciliation.
2026-08-22 03:29:37 +03:30
NimBold 101461b97c fix(media): harden bundled engine ownership
- Fail closed on CWD engine fallback in release builds.
- Redact media process diagnostics and remove unused Aria2 resolution.
- Add release-mode resolver regression coverage and rerun media gates.
2026-08-22 02:56:21 +03:30
NimBold a55cdee7e5 fix(downloads): harden live lifecycle projection
- remove non-queued lifecycle rows from the frontend pending projection
- validate live progress telemetry before updating store state
- add lifecycle and malformed-input regression coverage
2026-08-22 02:35:56 +03:30
NimBold d9022add5b fix(tools): harden scheduler and diagnostics
- preserve same-queue scheduler starts across superseded Run Now generations while retaining Stop cancellation
- pause saved and tracked scheduler ownership, fence engine cache writes, and normalize log merge/redaction boundaries
- add postcondition regressions for scheduler handoff, engine request generations, compact JSON redaction, and live log overlap
2026-08-22 02:23:59 +03:30
NimBold 55d5a9358b fix(settings): harden persisted consumer state
- validate complete settings snapshots before replacing durable state
- sanitize malformed proxy, user-agent, sidebar, speed, and path values at native and renderer hydration boundaries
- add hostile persisted-settings regression coverage
2026-08-22 02:00:22 +03:30
NimBold e9de193c9e fix(properties): route live controls through native mutations
- route active normal and Torrent-safe fields to dedicated live consumers

- fence unsupported statuses and mixed persisted patches at the bridge

- add regression coverage for active status and media boundaries
2026-08-22 01:47:13 +03:30
NimBold 3a740db2f2 fix(add-window): harden intake admission and destination safety
- retain valid magnet clipboard handoffs and reject malformed magnet URLs

- normalize destination identity and fail closed on deleted queues

- redact malformed media headers and add focused regression coverage
2026-08-22 01:25:10 +03:30
NimBold 1672dce803 fix(table): harden queue and row interaction lifecycles
- fence column resize and queue lost-capture handlers to the active pointer

- keep staged bulk actions and RTL submenu keyboard navigation truthful

- add deterministic selection, resize, action-count, and keyboard regressions
2026-08-22 01:05:36 +03:30
NimBold 1fe12b1fea fix(shell): harden sidebar accessibility and resize lifecycle
- keep the collapsed sidebar out of the accessibility tree and restore reveal focus

- add keyboard queue context actions and correct the queue accessibility contract

- fence sidebar resize pointers and clean up every interruption path with regressions
2026-08-22 00:48:22 +03:30
NimBold cc74bec9b0 fix(ci): cover Windows headless production contracts
- execute queue, retry, normalization, and credential-boundary contracts without the broken Tauri mock runtime\n- refresh target-specific FFmpeg source locks after provider URL and digest drift\n- document the remaining compile-only Windows GUI test limitation
2026-08-22 00:32:22 +03:30
NimBold f724616cde fix(torrent): distinguish connected and listed peers
- label live connected peer telemetry separately from listed peer details
- report count mismatches and keep connected values accented
- synchronize bindings, locales, accessibility, and regression coverage
2026-08-21 23:04:34 +03:30
NimBold c385c38556 chore(deps): refresh packages and bundled engines
- update compatible npm and Cargo lock dependencies
- refresh yt-dlp and target-specific FFmpeg artifacts with verified hashes
- detect provider SHA-256 drift in the update checker
2026-08-21 09:55:16 +03:30
NimBold 2d265ce7c8 fix(downloads): surface allocation and retain terminal progress
- emit native allocation state around Aria2 preallocation
- preflight batch destinations before backend admission
- preserve exact progress across retries and terminal states
- fence allocation and progress across pauses, retries, and stale GIDs
2026-08-21 01:33:13 +03:30
NimBold c355e99913 fix(ci): normalize Ubuntu package mirrors
- replace unreachable Azure runner mirrors with the HTTPS Ubuntu archive

- retain bounded apt retries and timeout safeguards
2026-08-19 20:39:51 +03:30
NimBold a478701291 fix(aria2): recover monotonic connection pool collapse
- preserve a resumed transfer's full connection baseline before its first speed sample

- recover sequential connection loss through transient throughput rebounds

- retain stable-underfilled safeguards and add log-shaped regression coverage
2026-08-19 20:25:24 +03:30
NimBold 5620110506 fix(ci): allow slow Linux apt index refresh
- give Ubuntu package-index refresh enough time on loaded mirrors

- retain retry, request timeout, and job-level safeguards
2026-08-19 18:38:33 +03:30
NimBold f5bdd88e5c fix(downloads): recover degraded connections and restart state
- recover persistent Aria2 connection-pool collapse without restarting stable or rate-limited pools
- preserve sanitized browser context while failing closed on credential-bearing and malformed restart state
- fence resume paths and harden portable and Torrent persistence with regression coverage
2026-08-19 18:25:54 +03:30
NimBold 6be0f5ca5a fix(ci): isolate Windows Tauri test harness
- keep Windows executable checks on targets that run on the hosted image
- compile Tauri-backed queue and library tests without executing the broken harness
- share root-scoped canonical cache logic with the Windows safety regression
2026-08-19 13:03:07 +03:30
NimBold dd04ed40d6 fix(ci): bound platform test and dependency checks
- run Windows integration targets without the broken Tauri lib harness
- retain Windows cache and web-seed regressions in runnable targets
- bound Linux apt retries, network waits, and job duration
2026-08-19 12:47:16 +03:30
NimBold 9418aa5564 fix(torrents): preserve per-file web-seed ownership
- use Aria2 per-file URI state for safe Torrent seed lifecycle updates
- preserve embedded and legacy web-seed baselines during explicit changes
- harden atomic replacement and retained cache cleanup across platforms
- execute Windows Torrent cache and web-seed regressions in CI
2026-08-19 12:11:09 +03:30
NimBold 78e9c9b80f fix(torrents): harden post-release lifecycle boundaries
- make Torrent journals and metadata caches atomic across platforms
- reject unsafe Torrent cache and web-seed inputs
- validate magnet trackers through the shared policy
- preserve embedded web seeds during explicit installation
- correct 1-based Torrent file indices in renderer state and Properties UI
2026-08-19 11:02:57 +03:30
NimBold 566632b7ad fix(torrent): harden allocation and credential isolation
- Extend allocation-phase eligibility to preallocated Torrent admission while excluding none, verify-only, and media work.

- Strip Torrent metadata credentials at intake, persistence, renderer, native, and Aria2 header boundaries.

- Add restart, batch-admission, persistence, and native regression coverage.
2026-08-19 08:49:02 +03:30
NimBold 2bce25868c fix(build): harden engine downloads and refresh dependencies
- update Lucide, Vitest, compatible Cargo locks, and FFmpeg source pins

- resume interrupted archives with HTTP range and checksum validation

- cover complete, range-ignored, and unsatisfiable archive recovery paths
2026-08-18 20:58:19 +03:30
NimBold bdbc11ad94 fix(ui): fence stale allocation status
- keep paused and completed rows authoritative over transient allocation state

- preserve allocation feedback for failed-download retries

- expose the table allocation phase as an accessible indeterminate progressbar
2026-08-16 00:09:21 +03:30
NimBold 6aa07db5df feat(ui): show file allocation phase
- expose transient allocation state around normal Aria2 enqueue

- render truthful indeterminate allocation status in the table and Properties window

- add localized copy, accessibility semantics, lifecycle cleanup, and regression tests
2026-08-16 00:03:43 +03:30
NimBold d135a17f1b fix(downloads): allow confirmed credentialless resume
- distinguish redacted session credentials from intrinsic URL authentication

- requeue confirmed resumes without saved secrets from the table and Properties window

- scope batch overrides to approved rows and harden malformed bridge payloads

- add regression coverage and localized confirmation copy
2026-08-15 23:32:43 +03:30
NimBold 3f3a3514a3 fix(ui): expose resume failure reason
- show persisted resume failure details instead of a generic backend rejection

- keep the generic message only when no actionable reason is available
2026-08-15 23:12:56 +03:30
NimBold 8e7e81277b fix(downloads): recover stale aria2 gids on resume
- validate retained GIDs before resume shortcuts, including WaitingToSeed

- invalidate stale lifecycle mappings and queue ownership before re-enqueue

- preserve conservative transport handling and cover missing-GID classification
2026-08-15 22:56:36 +03:30
NimBold 6245c62d3d fix(downloads): harden aria2 diagnostics
- add stage-correlated metadata, DNS, admission, retry, and poller logs
- classify resolver, range, and HTTP failures without leaking sensitive data
- fence slow Aria2 admission and terminal polling against stale lifecycle state
- record proxy route and requested versus effective connection telemetry
2026-08-15 21:25:12 +03:30
NimBold f77fd0be3f fix(downloads): harden automatic capture and aria2 transfers
- Pass prepared redirect URIs to Aria2 while preserving stable source identities.

- Fence effective-connection telemetry and retry lifecycle transitions by control epoch.

- Keep credentialed routes conservative across redirects, mirrors, and inline URL credentials.

- Preflight destination access and preserve actionable retryable permission errors in the UI.

- Add adversarial regression coverage for ranges, redirects, retries, telemetry, and enqueue failures.
2026-08-15 14:31:38 +03:30
NimBold 92cfaa26ce chore(deps): refresh package and engine locks
- update Zustand and refreshed npm lockfile metadata

- refresh BtbN FFmpeg Windows/Linux artifacts with verified checksums

- make update checks fail closed on unavailable providers and target-specific engine feeds

- update Rust transitive dependencies and add checker regression coverage
2026-08-15 10:07:39 +03:30
NimBold ca32b772a2 fix(torrent): use live peer telemetry in properties
- source the Properties peer card from live Aria2 status counts
- distinguish connected peers from unavailable peer details
- remove redundant peer-summary IPC and harden count parsing
- add responsive, accessible peer/seeder presentation and regressions
2026-08-15 06:26:25 +03:30
NimBold de41dd55d6 fix(torrent): harden metadata reuse and live peer telemetry
- reuse validated tracker-bearing metainfo without restoring direct web seeds
- preserve Torrent file selection while making long paths scrollable and copyable
- add lifecycle-fenced peer and seeder summaries to Properties telemetry
- validate cache tracker metadata and cover malformed, stale, and path-copy cases
2026-08-14 13:13:06 +03:30
NimBold 314f4e2e00 fix(settings): harden Torrent network validation
- Align the DHT message-timeout range with bundled Aria2 1.37.0.
- Validate and canonicalize Torrent network text settings at the native boundary.
- Fence delayed input responses and reject contradictory IPv6 bind state.
- Add regression coverage for malformed settings and cross-field races.
2026-08-13 16:40:55 +03:30
NimBold 4b43e8ed5c fix(integration): serialize browser add inputs
- serialize extension and deep-link Add-window events
- acknowledge successful extension handling at the frontend boundary
- add regression coverage for ordered input processing
2026-08-12 20:20:55 +03:30
NimBold e9ad226b93 fix(scheduler): harden scheduled actions and speed limits
- Persist scheduler dispatch markers and retry unacknowledged events across renderer and process restarts.
- Fence queue admission, Torrent moves, and permit activation during system actions with an explicit force path.
- Preserve Aria2 zero-limit overrides and normalize global limits across startup, queued, retry, and live paths.
- Guard scheduler lifecycle races, completion post-actions, settings bindings, and regression coverage.
2026-08-12 18:57:31 +03:30
NimBold 885e3d0100 chore(deps): refresh locks and harden engine promotion
- refresh compatible npm and Cargo lockfile dependencies
- update locked Windows and Linux FFmpeg provider artifacts by checksum
- publish verified engine payloads with interrupted-promotion recovery
- retry Windows-safe cleanup and cover worst-case promotion states
2026-08-12 17:54:30 +03:30
NimBold 64a836f09f fix(properties): harden lifecycle and session fencing
- Fence Properties actions and Torrent moves by current caller sessions.
- Preserve move progress and authoritative destinations across stale events and recovery.
- Enforce immutable identity fields and transactional queued-edit rejection.
- Restore subtle theme surfaces and expand regression coverage.
2026-08-12 07:07:52 +03:30
NimBold f7bafdeb0e feat(ui): persist window state and clarify transfer telemetry
- Persist bounded logical main-window geometry with work-area-safe startup restoration.
- Persist the Folders collapse preference in SQLite with guarded legacy localStorage migration.
- Keep media transfer telemetry truthful and compact the transfer controls across locales.
- Add bridge, presentation, persistence, geometry, and configuration regression coverage.
2026-08-11 12:17:11 +03:30
NimBold ae6a00304e fix(ui): harden add downloads modal layout 2026-08-11 10:51:11 +03:30
NimBold 753d590aee chore(deps): refresh packages and engine locks 2026-08-10 23:20:15 +03:30
NimBold 9c6237716e fix(persistence): make download admission durable 2026-08-10 20:35:53 +03:30
NimBold f79f9f1edf fix(torrents): clean multi-file assets on removal 2026-08-10 15:19:38 +03:30
NimBold 472da5a681 feat(downloads): add dedicated Torrent category 2026-08-10 14:20:50 +03:30
NimBold 23991c3dea feat(ui): compact torrent add flow and verify magnet associations 2026-08-10 13:31:40 +03:30
NimBold 6d8eab466c fix(media): restore adaptive YouTube formats
Pass yt-dlp's default and embedded YouTube player clients through both metadata discovery and the final download so adaptive video and audio formats stay available.
2026-08-09 09:27:30 +03:30
NimBold 67828eea92 fix(release): audit untagged companion packages 2026-08-09 05:24:04 +03:30
NimBold 6a9b2bc099 fix(release): allow non-publishing branch audits 2026-08-09 05:10:25 +03:30
NimBold 807e16a1fe fix(downloads): allow aria2 graceful shutdown delay 2026-08-09 04:56:06 +03:30
NimBold 4cd3d50d15 fix(downloads): promote adaptive mirror history safely 2026-08-09 04:40:07 +03:30
NimBold 5797db27c5 fix(windows): persist adaptive mirror history 2026-08-09 04:21:15 +03:30
NimBold 3b7c454ec7 feat(downloads): add adaptive mirror reliability 2026-08-09 04:06:05 +03:30
NimBold c3755ce886 fix(aria2): harden protocol and torrent transfers 2026-08-08 22:33:49 +03:30
NimBold 6c9950a690 fix(macos): declare standard BitTorrent file type 2026-08-08 13:10:58 +03:30
NimBold 27ac8d9083 feat(torrent): add magnet and torrent handoff 2026-08-07 18:39:24 +03:30
NimBold 27da8b1c44 fix(properties): retain window size per app session 2026-08-07 14:14:21 +03:30
NimBold 757b849e4e refactor(properties): fit torrent metrics on one row 2026-08-07 14:00:24 +03:30
NimBold 761bbb61d2 refactor(properties): reduce default window size 2026-08-07 13:55:34 +03:30
NimBold eb256707b0 refactor(properties): compact speed cap control 2026-08-07 13:51:35 +03:30
NimBold 92e369c15b refactor(properties): remove redundant transfer summary 2026-08-07 13:49:45 +03:30
NimBold facb7b3300 fix(properties): harden progress and torrent diagnostics 2026-08-07 13:32:52 +03:30
NimBold e402603edb fix(properties): harden diagnostic visual states 2026-08-07 12:24:05 +03:30
NimBold e2654510af fix(ui): clarify download properties inputs 2026-08-06 23:58:06 +03:30
NimBold c9046f8273 chore(deps): refresh packages and engine locks 2026-08-06 08:09:19 +03:30
NimBold 2b44d1a121 fix(ui): align properties window controls 2026-08-06 07:30:29 +03:30
NimBold 6f9a9e0638 fix(ui): harden download properties window 2026-08-06 07:08:29 +03:30
NimBold ee2448d084 fix(properties): harden child window startup and lifecycle 2026-08-06 00:26:59 +03:30
NimBold 5d90b07fd7 fix(properties): restore standalone window startup 2026-08-05 22:57:58 +03:30
NimBold b36f55e5b5 fix(properties): harden recovery and window chrome 2026-08-05 21:41:59 +03:30
NimBold c1202229ac fix(aria2): harden per-transfer DNS fallback (issue #35) 2026-08-05 19:28:56 +03:30
NimBold 4a83ac97c7 fix(properties): stabilize diagnostics and refresh download properties UI 2026-08-05 10:49:53 +03:30
NimBold 48a727798c fix(properties): harden protocol-aware resume recovery 2026-08-04 23:37:56 +03:30
NimBold 135ba75a69 fix(properties): harden resume lifecycle 2026-08-04 19:37:25 +03:30
NimBold 3905b3ed89 fix(properties): prevent retired session replay 2026-08-04 18:49:07 +03:30
NimBold f6a7cb7b94 fix(properties): fence stale renderer actions 2026-08-04 18:39:44 +03:30
NimBold c86eb1838c fix(properties): harden torrent diagnostics lifecycle 2026-08-04 18:09:07 +03:30
NimBold 2ab292dd5d feat(properties): harden standalone properties lifecycle
- synchronize child appearance and hidden-window readiness
- serialize native Torrent mutations transactionally without double-encoded rows
- preserve native lifecycle markers and fence stale or duplicate actions
- remove the obsolete modal surface and ignore implementation_plan.md
2026-08-04 12:02:30 +03:30
NimBold c342bcd347 feat(torrent): add standalone properties windows
- add caller-bound native properties bridge and lifecycle guards

- split Network settings into accessible secondary tabs

- add tabbed Torrent and generic Properties surfaces

- harden runtime validation and ignore the implementation plan
2026-08-04 09:26:38 +03:30
NimBold 579a8f7f80 feat(torrent): complete lifecycle controls and diagnostics
- add durable Torrent telemetry, availability, sharing, relocation, and web-seed workflows\n- fence queue ownership, lifecycle recovery, persistence, and native control races\n- add localized UI, generated bindings, regression coverage, and smoke validation
2026-08-04 01:18:24 +03:30
NimBold 55a905df14 feat(torrent): complete lifecycle controls 2026-08-03 21:47:02 +03:30
NimBold 819a48bd4b fix(queue): order seed permit release before visibility
- Keep a resumed seeder marked as starting until its download permit is released.
- Prevent Linux scheduling from exposing an inconsistent seed lifecycle to observers.
- Preserve the existing strict regression assertion.
2026-08-03 17:08:27 +03:30
NimBold c4d3a2be51 feat(torrents): harden lifecycle and web-seed management
- Enforce generation-safe seed admission and budget tracking.
- Make web-seed RPC, persistence, rollback, and startup attachment lifecycle-safe.
- Keep Torrent progress, DHT, seed-capacity, and web-seed validation covered.
- Ignore local TORRENT_FEATURES.md roadmap notes.
2026-08-03 16:56:01 +03:30
NimBold 79c0e48c43 fix(torrents): harden removal reservation recovery 2026-08-03 03:56:15 +03:30
NimBold 32034e90b3 feat(torrents): harden info-hash metadata reuse 2026-08-03 03:23:21 +03:30
NimBold cba485ef44 feat(torrents): add aggregate upload limit control 2026-08-02 23:36:48 +03:30
NimBold b2c86a2ec4 docs(torrents): clarify follow child deferral 2026-08-02 23:01:24 +03:30
NimBold d677f98dd1 fix(torrents): prevent unmanaged followed child GIDs 2026-08-02 22:55:13 +03:30
NimBold 7da67c15b5 feat(torrents): harden network identity settings 2026-08-02 22:39:05 +03:30
NimBold d171f736c5 docs(readme): document macOS launch warning workaround (#34) 2026-08-02 22:29:08 +03:30
NimBold 2d427b96d3 feat(torrents): add global open-file limit 2026-08-02 10:50:30 +03:30
NimBold 48b4d984a2 feat(torrents): add tracker timing controls 2026-08-02 10:24:59 +03:30
NimBold 1d27b5b0bf feat(torrents): add encryption policy 2026-08-02 10:05:39 +03:30
NimBold b4da68655a feat(torrents): safely remove unselected files 2026-08-02 09:49:57 +03:30
NimBold 67023d3f0d feat(torrents): support piece priority 2026-08-02 09:22:15 +03:30
NimBold a46a64994d feat(torrents): support tracker exclusion 2026-08-02 01:19:18 +03:30
NimBold ab9f0507d0 feat(torrents): add peer diagnostics 2026-08-02 01:04:04 +03:30
NimBold 2474d2c1cb feat(torrents): add stall timeout control 2026-08-02 00:48:19 +03:30
NimBold d0541308c7 feat(torrents): support remote torrent metadata 2026-08-02 00:29:49 +03:30
NimBold 0f1f4e8003 feat(torrents): add tracker controls 2026-08-01 23:53:07 +03:30
NimBold e31a3fcc90 feat(torrents): add integrity verification policy 2026-08-01 22:53:37 +03:30
NimBold 87d682709e fix(torrents): preserve metadata retention on malformed records 2026-08-01 22:36:37 +03:30
NimBold ab6c3c372e feat(torrents): add live peer controls 2026-08-01 22:27:32 +03:30
NimBold 52082c1e1d feat(torrents): add peer discovery controls 2026-08-01 21:58:09 +03:30
NimBold 9247c5cf9c feat(torrents): add live upload limit control 2026-08-01 21:41:04 +03:30
NimBold dea6ad1974 feat(torrents): add seeding lifecycle and upload controls 2026-08-01 21:27:49 +03:30
NimBold bb64c4cd52 fix(ci): use Windows-compatible Torrent RPC integration test 2026-08-01 20:09:57 +03:30
NimBold 92092e9844 test(torrents): harden native smoke validation 2026-08-01 19:55:42 +03:30
NimBold 1a492fc4c8 fix(torrents): harden RPC probe shutdown and retry classification 2026-08-01 19:12:15 +03:30
NimBold a00d34edc8 feat(torrents): add HTTP-boundary Torrent probe harness and test script 2026-08-01 18:50:25 +03:30
NimBold 18b51ca86e feat(torrents): harden magnet metadata probe lifecycle 2026-07-31 22:07:50 +03:30
NimBold 1bf8b675e4 feat(torrents): add functions to remove orphaned probe directories and cached torrents 2026-07-31 21:34:32 +03:30
NimBold ebd11b26f6 chore: stop tracking local agent guidance 2026-07-31 21:20:28 +03:30
NimBold ae5199132d docs: document macOS dev app validation 2026-07-31 21:15:59 +03:30
NimBold 6336ff6f5d fix(torrents): harden smoke harness teardown 2026-07-31 21:06:18 +03:30
NimBold a8bd80d557 test(torrents): harden runtime smoke harness 2026-07-31 20:30:57 +03:30
NimBold 186e189277 feat(torrents): resolve magnet metadata before enqueue 2026-07-31 00:06:07 +03:30
NimBold 2c1bd9cdf1 fix(torrents): close late Aria2 cleanup races 2026-07-30 23:30:51 +03:30
NimBold 292447d417 feat(torrents): harden Aria2 torrent downloads 2026-07-30 22:51:46 +03:30
NimBold 31fbc5495b fix(ci): exclude engine payload from initial AppImage bundle 2026-07-30 05:53:43 +03:30
NimBold 33dd2cb9c6 chore(release): prepare Firelink 1.3.1 2026-07-30 05:19:42 +03:30
NimBold 924e108f3c fix(release): harden packaging and filename localization 2026-07-30 05:08:26 +03:30
NimBold 1634118f11 fix(ci): gate macOS-only dock badge test import 2026-07-30 03:32:18 +03:30
NimBold 31cf4a1c50 fix(ci): reuse Linux binary for AppImage packaging 2026-07-30 03:16:37 +03:30
NimBold 4e504b8e74 fix(downloads): harden filenames and localized surfaces
Bound generated filenames to cross-platform component limits, preserve extensions, and keep duplicate renames unique. Correct light-theme compositing tokens and enforce Persian date formatting while preserving Hebrew locale formatting.

Fixes #29
Refs #31
2026-07-30 02:48:29 +03:30
NimBold 4df6315be1 chore(release): prepare Firelink 1.3.0 2026-07-29 20:44:58 +03:30
NimBold 29bbb009b5 fix(downloads): harden duplicate resolution and row controls 2026-07-29 18:28:46 +03:30
NimBold 546be23c91 fix(queue): harden resume failure and legacy controls 2026-07-29 16:14:44 +03:30
NimBold ce8a5499c3 fix(queue): resume paused items through lifecycle 2026-07-29 16:06:45 +03:30
NimBold 0e50bdfe9e fix(ui): ignore duplicate stale pause events during resume 2026-07-29 15:49:53 +03:30
NimBold f4ef1a4b28 fix(queue): prevent stale resume waiters from hanging 2026-07-29 15:30:14 +03:30
NimBold 1044b6c619 fix(ui): harden keychain grants and window controls
Keep native credential-store completion pending until the frontend accepts it, prevent stale grant races, and isolate blocking keyring work from the UI. Derive sidebar reveal spacing from each custom control style and restore neutral GNOME/minimal hover feedback.
2026-07-29 15:07:38 +03:30
NimBold 07f45959d0 chore(extension): advance Companion submodule 2026-07-29 12:55:23 +03:30
NimBold 26e0096215 chore(deps): refresh package and engine dependencies 2026-07-29 11:52:18 +03:30
NimBold 197918eab5 fix(queue): keep paused items behind pending work 2026-07-29 00:32:05 +03:30
NimBold 23288ca4e8 fix(ui): harden queue controls and floating menus 2026-07-28 23:49:46 +03:30
NimBold 807663cf03 fix(ui): refine page transitions and bulk actions 2026-07-28 22:39:00 +03:30
NimBold 62f8c83c57 fix(ui): harden navigation and queue interactions 2026-07-28 22:11:38 +03:30
NimBold 7e8f1c7d7b fix(release): enforce tagged companion identity 2026-07-28 19:33:05 +03:30
NimBold 5633896d14 chore(deps): refresh package locks and Rust warnings 2026-07-28 18:29:14 +03:30
NimBold 7795b3bcf8 test(queue): make fake GID allocation atomic 2026-07-28 16:15:10 +03:30
NimBold edc76a7fab fix(ui): avoid default modal action focus 2026-07-27 19:37:47 +03:30
NimBold eb73dde911 fix(ui): harden modal lifecycle and keychain access 2026-07-27 19:23:29 +03:30
NimBold bff7d2782c chore(deps): refresh transitive Rust crates
Update event-listener to 5.4.2 and tray-icon to 0.24.2 for upstream synchronization and cross-platform tray fixes.
2026-07-27 16:39:53 +03:30
NimBold 8cf1bf7897 fix(ui): restore rounded sidebar depth 2026-07-27 16:17:55 +03:30
NimBold 5db9388d07 fix: harden keychain consent startup and modal feedback 2026-07-27 09:58:32 +03:30
NimBold 61dfcf5d8a fix(ui): refine settings and surface styling 2026-07-27 08:37:49 +03:30
NimBold d5c3fc8b27 fix(ui): unify settings top panel surface 2026-07-27 08:15:04 +03:30
NimBold d895fb1b73 docs(readme): restore project icons 2026-07-26 19:58:15 +03:30
NimBold 5372a76eda chore(deps): update Firelink packages and FFmpeg sources 2026-07-26 19:53:14 +03:30
NimBold 54693456b1 docs(readme): update installation badges for Firefox and Chromium 2026-07-26 19:32:56 +03:30
NimBold edc0c1c6ad docs(readme): restore Firefox install badge
Remove maintainer-focused wording, add the Firefox Add-ons pill, and update the paired extension pointer.
2026-07-26 19:29:14 +03:30
NimBold c10b889a41 docs(readme): refresh project documentation
Clarify Firelink status, installation, development, support, credits, and the paired Companion revision.
2026-07-26 19:05:45 +03:30
NimBold 530324ab9c fix(i18n): refine download action translations
Align Persian verb forms and Chinese/Russian action labels with established IDM and FDM terminology references.
2026-07-26 18:51:59 +03:30
NimBold 240055497d fix(queue): preserve lifecycle ownership during recovery
Require queue-permit ownership before resuming a paused GID and rebuild a job when its GID disappears during recovery.

Advance the Browser companion with the automatic-capture handoff guard for #28.
2026-07-26 18:10:04 +03:30
NimBold 7787f4e6c0 test(queue): synchronize dispatcher integration assertion 2026-07-26 03:22:07 +03:30
NimBold cf09bfa2e4 fix(downloads): harden power management and aria2 recovery 2026-07-26 03:04:50 +03:30
NimBold 931e1f2f1e fix(queue): preserve aria2 connection pool 2026-07-25 17:54:28 +03:30
NimBold 956b70dc10 fix(extension): include capture recovery fix (#28)
Advance the Browser companion to bd3c7e0, which keeps disabled or malformed Firefox captures untouched and recovers pending captures safely during startup.
2026-07-25 16:50:04 +03:30
NimBold dd7c2196fe fix(queue): dispatch admitted downloads concurrently 2026-07-25 16:50:00 +03:30
NimBold d62e0429a4 fix(downloads): animate table column reordering 2026-07-25 16:23:07 +03:30
NimBold f3af5b0584 chore(deps): refresh application dependencies and engines 2026-07-25 15:35:54 +03:30
NimBold 5614022dbd fix(downloads): bound connection recovery churn
Require a healthy multi-connection baseline, cap consecutive automatic recovery attempts, and preserve zero-progress recovery. Align Download Properties speed-cap controls with shared app styles.
2026-07-24 06:57:26 +03:30
NimBold b1659fa3d0 fix(ui): harden download table layout (#32)
Refs #32
2026-07-24 04:29:27 +03:30
NimBold 9a40b30a0f fix(add-modal): harden playlist media selection 2026-07-24 03:34:42 +03:30
NimBold 4f89a6c79d fix(i18n): keep font family names consistent
Keep bundled font names untranslated across every supported locale while preserving localized descriptions.

Refs #31
2026-07-23 19:55:12 +03:30
NimBold f5d918ec1e fix(settings): keep bundled font labels compact
Use canonical font names across locales, align system-mode descriptions, and document the bundled OFL license text.

Refs #31
2026-07-23 17:38:33 +03:30
NimBold b579c35e42 feat(settings): add curated multilingual fonts
Bundle Vazirmatn, Noto Sans Hebrew, Noto Sans SC, and Roboto with locale-aware fallbacks while keeping custom font import out of scope.

Refs #31
2026-07-23 17:29:29 +03:30
NimBold 1f1ec828f9 feat(settings): add locale-aware font preferences
Bundle optional Inter and Outfit families, preserve the platform system default, and provide script-aware fallbacks for every supported locale.

Refs #31
2026-07-23 17:13:00 +03:30
NimBold 228db1eaa2 fix(settings): improve look and feel organization
Prevent localized select labels from being clipped and order appearance settings by scope and frequency.

Refs #31
2026-07-23 16:54:53 +03:30
NimBold 9a790073f5 fix(ui): align all window control styles
Lower macOS and minimal controls so every preset shares the same titlebar centerline.

Refs #31
2026-07-23 16:44:01 +03:30
NimBold f3eff02069 fix(ui): align window control vertical spacing
Lower GNOME and minimal controls to the same titlebar baseline as Windows 11.

Refs #31
2026-07-23 16:42:53 +03:30
NimBold 4374219685 fix(ui): refine Windows window controls
Place the close action at the outer sidebar corner, round the caption hit regions, and align the controls with the titlebar content.

Refs #31
2026-07-23 16:40:09 +03:30
NimBold 4e3ceb21a5 feat(ui): add cross-platform window control styles
Persist automatic and explicit macOS, Windows, GNOME, and minimal control presets with localized settings and platform-aware fallback.

Refs #31
2026-07-23 16:25:39 +03:30
NimBold 6adbccdf43 fix(ui): mirror window controls for right sidebar
Match native macOS traffic-light spacing and reference the related UI enhancement.

Refs #31
2026-07-23 16:10:48 +03:30
NimBold 454fad712b fix(modals): import AddDownloadsModal and adjust modal backdrop animation 2026-07-23 15:25:34 +03:30
NimBold 7f450a802f fix(i18n): update queue reorder hint to include hotkey usage 2026-07-23 14:50:06 +03:30
NimBold f2b1fb9d4a fix(downloads): restore smooth queue reorder animations 2026-07-23 14:40:53 +03:30
NimBold 9302911ac7 fix(downloads): harden queue drag and reorder behavior 2026-07-23 07:40:02 +03:30
NimBold 18e9200b74 fix(downloads): restore queue reorder interactions 2026-07-23 06:45:49 +03:30
NimBold 3559c69968 fix(downloads): harden queue reordering and summary stats 2026-07-23 06:23:04 +03:30
NimBold 3587fb0c0d fix(downloads): harden queue admission and live speed controls 2026-07-23 04:13:07 +03:30
NimBold b60818d3af feat(downloads): add live aria2 speed limits 2026-07-23 03:21:34 +03:30
NimBold 5463d27ed3 feat(settings): add opt-in calendar localization 2026-07-23 03:08:52 +03:30
NimBold 3f298715a6 fix(downloads): avoid quadratic queue drag lookups 2026-07-23 03:02:22 +03:30
NimBold ceab8a5fdf feat(downloads): add media quality and queue reordering 2026-07-23 02:55:33 +03:30
NimBold f4e5e211cc feat(downloads): add details controls and aggregate summaries 2026-07-23 02:31:34 +03:30
NimBold 9d737598c6 fix(macos): handle Cmd+M minimization (#30) 2026-07-22 19:02:17 +03:30
NimBold d3e5e8c208 fix(macos): restore native window lifecycle (#30)
Install the native macOS application menu for Command-M, handle Dock reopen events only when no window is visible, and preserve early secondary-launch restores until the main window exists. Refs #30.
2026-07-22 18:34:56 +03:30
NimBold 17eb8e5d39 docs(release): link reported issues 2026-07-22 17:48:27 +03:30
NimBold d6b5d6a944 chore(release): prepare Firelink 1.2.0 2026-07-22 16:49:39 +03:30
NimBold efeacf737a fix(i18n): refine Persian status wording 2026-07-22 16:34:08 +03:30
NimBold 6ae790f2c5 fix(ui): restore download table sorting 2026-07-22 14:50:32 +03:30
NimBold 07efb3667d fix(ui): show media connection limits 2026-07-22 14:45:38 +03:30
NimBold 512a77f6bb fix(downloads): bound backend connection counts 2026-07-22 14:37:03 +03:30
NimBold 030db52016 feat(downloads): honor media connection settings 2026-07-22 14:29:01 +03:30
NimBold d1452ce0c1 feat(downloads): expose active Aria2 connections 2026-07-22 14:04:59 +03:30
NimBold 8e706b598c fix(ui): harden download table actions and layout 2026-07-22 13:27:38 +03:30
NimBold 297f2c08fb fix(ui): harden table drag and keychain startup 2026-07-22 11:54:56 +03:30
NimBold 16377aa0d6 fix(ui): stabilize download table column interactions 2026-07-22 10:33:41 +03:30
NimBold 20a136cd1f chore(deps): refresh packages and bundled engines 2026-07-22 09:48:39 +03:30
NimBold e1f83b4f19 fix(i18n): refine localized copy 2026-07-21 23:21:40 +03:30
NimBold a98cce0980 feat(ui): add customizable download table columns
Add persisted column reordering, positional alignment, animated drag feedback, and resize cleanup for the download table. Keep row actions at the trailing edge, preserve RTL/LTR behavior, and include localized column controls and sidebar alignment fixes.
2026-07-21 22:31:34 +03:30
NimBold 81381a5a93 chore(release): prepare 1.1.2 and guard release tags 2026-07-21 20:13:21 +03:30
NimBold 886388d5f2 fix: harden audited download and release paths 2026-07-21 08:39:54 +03:30
NimBold 69ce2b15ba fix(settings): harden audited state synchronization 2026-07-21 05:05:34 +03:30
NimBold 621652ae13 feat(downloads): add opt-in batch folders (#27) 2026-07-20 20:04:07 +03:30
NimBold e0cb124720 feat(downloads): reuse unfinished filename matches
Fixes #26
2026-07-20 19:02:46 +03:30
NimBold c133556d38 fix(downloads): serialize lifecycle control actions 2026-07-20 07:31:58 +03:30
NimBold f423d9bee3 chore(deps): refresh packages and bundled engines 2026-07-20 06:56:09 +03:30
NimBold 2b14b8f58c fix(ui): harden lazy page preloading 2026-07-19 18:18:49 +03:30
NimBold 6e85c0842f fix(startup): enforce keychain consent before credential access
Keep credential-store operations behind a per-process consent gate, prevent duplicate native grant requests, and defer legacy token migration until explicit consent. Harden the RTL/sidebar, custom window controls, bidi copy, and queue editor fixes.

Refs #17
2026-07-19 07:07:29 +03:30
NimBold f47eb7507f fix(ui): preserve LTR left sidebar in RTL
Keep the left sidebar geometry stable across RTL locales and retain the default settings-tab order.

Refs #17
2026-07-19 04:07:33 +03:30
NimBold c427aa2f7a fix(ui): stabilize sidebar and titlebar placement
Keep left RTL sidebars physically aligned with the default layout, swap titlebar actions with side-aware custom controls, and preserve usable titlebar space when the sidebar is visible.

Refs #17
2026-07-19 03:31:37 +03:30
NimBold 23878c2ef8 fix(ui): harden sidebar and custom window controls
Prevent stale inline queue edits, preserve focused queue deletion semantics, and keep Windows custom chrome decoration-free across sidebar layouts.

Refs #17
2026-07-19 03:10:42 +03:30
NimBold 79cd9f0ed1 fix(ui): stabilize side-aware RTL layout
Keep download columns in their default order, mirror sidebar badges by physical side, and reserve titlebar space for desktop controls.

Refs #17
2026-07-19 02:53:37 +03:30
NimBold 60f13f756c fix(ui): harden RTL sidebar and desktop window controls
Keep custom Windows/Linux controls available during platform detection, preserve RTL table order while aligning filenames left, and persist sidebar placement overrides.

Refs #17
2026-07-19 02:34:32 +03:30
NimBold 2668f0b722 fix(media): handle locked browser cookie databases
Retry yt-dlp metadata and media downloads without browser cookies only when the browser database cannot be read, while preserving explicit credentials, cache identity, and cancellation behavior.

Refs #22

Refs #24
2026-07-18 17:22:10 +03:30
NimBold 93ddf427b4 feat(i18n): add localized desktop UI
Refs #17
2026-07-18 13:44:29 +03:30
NimBold e4d7d5ecf0 feat(locations): make directory memory opt-in
Keep the last Add-window directory disabled until enabled in Settings > Locations.

Refs #23
2026-07-18 12:02:17 +03:30
NimBold 160e6af945 feat(locations): remember last Add-window directory
Remember manually selected Add-window folders during the current session, with a persisted Locations toggle and cross-platform path handling.\n\nRefs #23
2026-07-18 11:52:24 +03:30
NimBold c914eeb7b3 feat(i18n): add localization infrastructure and English catalog
Refs #17
2026-07-18 01:10:27 +03:30
NimBold 62365f514e fix(ui): align size header with values 2026-07-17 23:46:06 +03:30
NimBold 9fdd8ac1b8 fix(ui): align size values within column 2026-07-17 23:37:32 +03:30
NimBold 61eb034db1 chore(release): prepare Firelink 1.1.1 2026-07-17 18:58:45 +03:30
NimBold c949cbb9ee fix(ui): align action controls and protect keychain consent 2026-07-17 18:37:50 +03:30
NimBold 566396e629 fix(media): stabilize YouTube size estimates 2026-07-17 17:49:23 +03:30
NimBold db39cd2153 fix: stabilize media progress and cancel controls 2026-07-17 17:12:56 +03:30
NimBold f4b830414d fix: harden audited persistence and download paths 2026-07-17 16:27:39 +03:30
NimBold 0447d1cab7 fix(build): scope Tailwind scanning and remove dead Rust helper
Limit Tailwind source detection to the frontend and root HTML while preserving the CSS-defined theme. Remove redundant test-only nonce validation scaffolding so the Rust library builds without warnings.
2026-07-17 03:20:31 +03:30
NimBold 513143d745 fix(release): harden packaged smoke cleanup 2026-07-17 02:35:36 +03:30
NimBold a8dc4fb447 fix(security): harden browser handoff boundaries 2026-07-17 02:05:41 +03:30
NimBold 50c3da2f5d fix(downloads): close media lifecycle cleanup gaps
Validate every Aria2 mirror URI, keep metadata cleanup errors truthful, and serialize media registration with lifecycle cancellation so abandoned tombstones cannot accumulate.
2026-07-17 01:03:52 +03:30
NimBold 6ef911919d fix(settings): harden settings and diagnostic controls 2026-07-17 00:39:26 +03:30
NimBold 5144ecd39e chore(deps): refresh packages and bundled engines 2026-07-17 00:03:15 +03:30
NimBold a56b859151 fix(downloads): preserve authenticated capture metadata
Keep Gmail attachment filenames and origin-scoped browser cookies intact through metadata redirects, while rejecting Google sign-in responses.\n\nFixes #21
2026-07-16 22:58:12 +03:30
NimBold 469faed7b9 fix(downloads): harden add flow and URL validation 2026-07-16 22:20:12 +03:30
NimBold 79ce0c18a1 fix(downloads): harden aria2 recovery lifecycle 2026-07-16 17:56:00 +03:30
NimBold d195a132b3 perf(downloads): scale playlist rows efficiently 2026-07-16 17:23:43 +03:30
NimBold feb5d8e87d fix(downloads): harden playlist state and scrolling 2026-07-16 16:57:35 +03:30
NimBold a136fa832c feat(downloads): support YouTube playlists 2026-07-16 16:07:00 +03:30
NimBold f3d0e0be13 fix(downloads): recover stalls and honor connection defaults (#19, #20) 2026-07-16 14:28:59 +03:30
NimBold edeef0ac54 fix(startup): enforce consent before download handoffs
Gate clipboard, extension, and deep-link inputs until startup consent is resolved, and serialize extension readiness transitions so native prompts cannot race the app explanation. Identify consent by the build revision so same-version updates re-enter the consent boundary.

Requested by [this X post](https://x.com/ixabolfazl/status/2077356127763804450?s=20).
2026-07-15 21:48:00 +03:30
NimBold 9f333618fc fix(downloads): show total size after completion 2026-07-15 21:30:01 +03:30
NimBold ed54a048ab fix(keychain): version startup consent policy 2026-07-15 21:21:35 +03:30
NimBold d2479f52be fix(startup): restore keychain consent barrier 2026-07-15 21:09:45 +03:30
NimBold 6e6ae51395 fix(downloads): preserve live progress across lifecycle edges 2026-07-15 20:50:46 +03:30
NimBold 1d197432b2 feat(downloads): add live limits clipboard capture and byte progress 2026-07-15 20:35:05 +03:30
NimBold 9917f29743 fix(build): gate platform-specific proxy helper 2026-07-15 12:49:40 +03:30
NimBold df3bc359c4 chore(release): prepare Firelink 1.1.0 2026-07-15 11:55:54 +03:30
NimBold 57f132f53e fix(metadata): decode URL-derived filenames
Decode URL path segments before sanitizing metadata filenames and preserve malformed escapes safely.

Fixes #18
2026-07-15 11:29:17 +03:30
NimBold 52b00e5cb4 fix(keychain): gate credential-store startup access 2026-07-15 11:07:48 +03:30
NimBold 8e02a61c3f fix(downloads): preserve terminal startup state 2026-07-15 10:23:06 +03:30
NimBold 82f2914077 fix: close post-release lifecycle state gaps 2026-07-15 10:13:53 +03:30
NimBold 9224117d77 chore(deps): scope release-gate update to npm (#16)
Remove the unrelated Rust lockfile refresh from the prior dependency commit. Keep the Autoprefixer release-gate update scoped to package.json and package-lock.json. Refs #16.
2026-07-15 09:43:19 +03:30
NimBold 3c84b5c1a4 chore(deps): refresh compatible npm and Rust packages (#16)
Refresh Autoprefixer and compatible Rust transitive dependencies after the release hardening work. Refs #16.
2026-07-15 09:40:23 +03:30
NimBold c34c489aef fix(settings): preserve speed limiter units
Persist the selected display unit for disabled limits, preserve in-progress fractional input, and keep exact KiB-backed preset values visible.\n\nNo linked issue.
2026-07-15 09:19:19 +03:30
NimBold b81e8391e1 fix(release): harden speed limits and extension replay checks 2026-07-15 09:02:40 +03:30
NimBold 6ff0047d6c fix(release): make verifier RPC smoke resilient
Use OS-selected localhost ports with bounded bind retries and handle aria2c spawn failures without unhandled child-process errors.

Refs #3
2026-07-15 08:50:34 +03:30
NimBold 4a3fece22b fix(release): harden package and engine verifier cleanup 2026-07-15 08:40:12 +03:30
NimBold 1da0fa7223 fix(security): harden credential and path boundaries
Preserve pending pairing tokens until credential-store migration succeeds, defer keychain access until frontend hydration, reject symlink and malformed ownership paths, and restrict metadata credentials to exact origins.

Refs #15

Refs #16
2026-07-15 08:23:43 +03:30
NimBold d6af4ee2b5 fix(extension): harden local handoff boundaries 2026-07-15 06:06:16 +03:30
NimBold 2479ead4ed fix(downloads): harden lifecycle race handling
Audit Assignment 06 lifecycle paths and preserve truthful state across ambiguous Aria2 resume failures. Clean up partial startup listener registration and prevent stale resume workers from leaking or releasing current permits.
2026-07-15 05:34:32 +03:30
NimBold 80a29356e0 fix(tools): harden scheduler limits and logs
Guard scheduler system actions against pending work, serialize diagnostic log transitions, and keep speed-limit saves truthful after backend failures.\n\nNo linked issue was found for this audit.
2026-07-15 03:00:46 +03:30
NimBold 45bbca0515 fix(settings): harden persisted settings and update checks 2026-07-15 02:25:53 +03:30
NimBold e35b1af731 fix(downloads): close audit lifecycle gaps
Prevent duplicate Add Window submissions and preserve discard confirmation.

Reject duplicate backend primary-path ownership and clear stale lifecycle progress.
2026-07-15 01:51:04 +03:30
NimBold 19953a210e fix(downloads): harden Add Window handoffs 2026-07-15 01:38:16 +03:30
NimBold 76850f2433 fix(ui): harden main window interactions 2026-07-15 01:25:59 +03:30
NimBold 0eee47b97f chore(release): refresh cross-platform FFmpeg payloads 2026-07-15 00:11:04 +03:30
NimBold e8487ee71b fix(downloads): harden lifecycle cancellation races 2026-07-15 00:10:56 +03:30
NimBold e07182fbf2 fix(downloads): harden queue and lifecycle synchronization
Serialize queue controls, make multi-item moves atomic, await stale enqueue cleanup, and guard late media and progress events. Add deterministic table sorting and regression coverage for worst-case lifecycle races.
2026-07-14 18:29:14 +03:30
NimBold 2d9eed99d5 chore(deps): refresh packages after fallback hardening (#16)
Refresh the compatible npm and Rust dependency set after the metadata fallback hardening. Refs #16.
2026-07-14 15:34:35 +03:30
NimBold 80cf835060 fix(downloads): defer captured cookies for metadata probes (#16)
Fixes #16
2026-07-14 01:19:00 +03:30
NimBold c78a72a8d7 fix(downloads): harden aria2 resume lifecycle 2026-07-13 04:40:39 +03:30
NimBold df85a77987 fix(downloads): reconcile lost aria2 completion events 2026-07-13 04:21:38 +03:30
NimBold be2a98fbcd fix(downloads): reconcile completed aria2 pause requests 2026-07-13 04:08:18 +03:30
NimBold fcfdffa6e0 fix(downloads): harden aria2 lifecycle synchronization 2026-07-13 03:31:41 +03:30
NimBold 9805c9288a fix(downloads): harden aria2 recovery and resume lifecycle 2026-07-13 00:35:09 +03:30
NimBold dad5b7bc5e fix(portable): harden persistence and release paths (#15)
Preserve legacy source data when portable sanitization cannot replace it, reject malformed settings without panicking, and make portable pairing regeneration durable before UI state changes.\n\nMake the packaged smoke assertion tolerate slow WebView startup and clarify AppImage storage behavior.\n\nRefs #15
2026-07-13 00:09:09 +03:30
NimBold f441c687f0 fix(security): redact secrets from portable errors (#15)
Sanitize lastError at the portable SQLite boundary using the shared redactor.\nCover spaced credentials and non-HTTP URL queries while preserving standard-mode diagnostics.\n\nRefs #15
2026-07-12 23:50:13 +03:30
NimBold a0f44b79ad feat(portable): add secure Windows portable release (#15)
Implement marker-based portable storage, portable WebView and log paths, secure queue and migration sanitization, and Windows portable ZIP validation while preserving the NSIS installer path.

Refs #15
2026-07-12 23:08:56 +03:30
NimBold 56b4c9f511 fix(release): extract RPM packages with bsdtar 2026-07-12 11:31:52 +03:30
NimBold c1fa87b953 fix(release): parse modern Debian package listings 2026-07-12 11:09:15 +03:30
NimBold c7ec8cd666 chore(release): prepare Firelink 1.0.4 2026-07-12 10:48:00 +03:30
NimBold 9133e3b05b fix(downloads): harden release-critical transfer paths 2026-07-12 07:55:47 +03:30
NimBold 5bbee12602 feat(release): add verified Linux packages 2026-07-12 06:03:54 +03:30
NimBold 7894c05bba fix(ui): keep dialogs clear of window controls 2026-07-11 21:52:01 +03:30
NimBold 33375df2ff fix(downloads): harden queue controls and resumable replacement
Prevent queue action clicks from triggering row double-clicks, preserve aria2/yt-dlp resumable sidecars during duplicate replacement, and make aria2 require resume instead of silently restarting.

Add a persisted, accessible Folders collapse control with reduced-motion animation and regression coverage for replacement sidecar handling.

Fixes #11

Fixes #12

Closes #13

Refs #14
2026-07-11 20:07:33 +03:30
NimBold 1922db8ea0 chore(deps): refresh application dependencies 2026-07-11 19:16:51 +03:30
NimBold ba70662165 feat(downloads): prefill Add modal from clipboard (#10) 2026-07-11 09:01:44 +03:30
NimBold 629a34d1e8 chore: clarify browser extension submodule 2026-07-10 19:23:45 +03:30
NimBold 248f3869ad fix: harden media handoff and live logs
Reject stale extension media cookie headers before yt-dlp metadata work, preserve ordinary capture cookies, and advance the companion extension.

Stream redacted diagnostic logs only while the visible Logs view is active, with bounded batched updates and race-safe snapshot handoff.
2026-07-10 19:02:39 +03:30
NimBold 4f4c655de6 fix: address post-audit regressions across queue, db, and ui
- Preserved extension-captured cookies through the Add modal, with a clean fallback when captured cookies break metadata fetching.
- Prevented batched extension captures from losing URLs or reusing stale cookie/header contexts.
- Fixed pause/resume and enqueue generation races, including cancellation during queue reservation and replay after task removal.
- Made startup database initialization safe under React StrictMode.
- Serialized keyring operations and corrected Linux legacy migration/deletion behavior.
- Restored `Downloading` state after yt-dlp retries.
- Replaced hardcoded media heights with dynamically detected formats, including nonstandard qualities such as 576p and 2880p.
2026-07-10 12:07:25 +03:30
NimBold 3fbd0742be fix(media): reject extension cookie headers for media
Keep explicit media requests on yt-dlp's configured browser-cookie path and preserve cookies for normal captures.
2026-07-10 00:46:00 +03:30
NimBold c5025fd5a0 fix(media): quote forwarded cookie headers
Keep yt-dlp config values intact and require explicit media metadata before downloads can start.
2026-07-10 00:27:44 +03:30
NimBold b1c84a0fb9 fix(downloads): harden enqueue lifecycle races
Reject superseded enqueue generations in the queue manager and coordinate frontend dispatch, pause, removal, and property mutations.
2026-07-10 00:04:21 +03:30
NimBold fbb89cde8e fix(downloads): harden capture and media flows
Scope extension request context to each Add modal row, refresh stale metadata handoffs, and align yt-dlp format and retry behavior with Firelink's transfer contract.
2026-07-09 23:21:16 +03:30
NimBold cd8ab5c12b fix(deps): scope linux keyring entries 2026-07-09 18:15:29 +03:30
NimBold ed7c47cb49 chore(deps): migrate keyring stores 2026-07-09 18:11:26 +03:30
NimBold 8c035167c8 chore(deps): refresh package versions 2026-07-09 18:05:36 +03:30
317 changed files with 108793 additions and 7787 deletions
+2
View File
@@ -0,0 +1,2 @@
scripts/aria2/firelink.patch text eol=lf
scripts/aria2/build.sh text eol=lf
+161 -9
View File
@@ -9,6 +9,18 @@ permissions:
contents: read
jobs:
rust-security:
name: Rust advisory audit
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install locked cargo-audit
run: cargo install cargo-audit --version 0.22.2 --locked
- name: Reject vulnerable resolved dependencies
working-directory: src-tauri
run: cargo audit
frontend:
name: Frontend checks
runs-on: ubuntu-22.04
@@ -18,7 +30,7 @@ jobs:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 22.12
cache: npm
- run: npm ci
- run: node --test scripts/*.node-test.js
@@ -27,6 +39,7 @@ jobs:
desktop:
name: Desktop checks (${{ matrix.target }})
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -44,16 +57,40 @@ jobs:
submodules: recursive
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 22.12
cache: npm
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies and build targets
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri -> target
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
# The hosted Ubuntu image can expose an unreachable Azure mirror in
# its apt sources while the public Ubuntu archive is reachable.
# Normalize every apt source, including the image's mirror list,
# before refreshing indexes so a transient runner-local mirror does
# not consume the whole job timeout.
sudo find /etc/apt -type f \
-exec sed -i \
-e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
-e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
{} +
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=60 \
update
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=60 \
install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
@@ -65,16 +102,131 @@ jobs:
if: runner.os != 'Windows'
working-directory: src-tauri
run: cargo test --all-targets --target ${{ matrix.target }}
- name: Test Rust backend
- name: Test Windows Torrent RPC integration
if: runner.os == 'Windows'
working-directory: src-tauri
run: |
cargo test --tests --target ${{ matrix.target }}
cargo test --lib --no-run --target ${{ matrix.target }}
- name: Provision locked engines
run: cargo test --test torrent_rpc --target ${{ matrix.target }} -- --nocapture
- name: Compile Windows queue-manager integration
if: runner.os == 'Windows'
working-directory: src-tauri
run: cargo test --test queue_manager --no-run --target ${{ matrix.target }}
- name: Compile Windows Rust library tests
if: runner.os == 'Windows'
working-directory: src-tauri
run: cargo test --lib --no-run --target ${{ matrix.target }}
- name: Run Windows headless production contracts
if: runner.os == 'Windows'
working-directory: src-tauri
run: cargo test --test production_contract --target ${{ matrix.target }} -- --nocapture
- name: Verify Windows atomic Torrent storage
if: runner.os == 'Windows'
working-directory: src-tauri
run: cargo test --test atomic_file --target ${{ matrix.target }} -- --nocapture
- name: Verify Windows Torrent cache safety
if: runner.os == 'Windows'
working-directory: src-tauri
run: cargo test --test torrent_cache --target ${{ matrix.target }} -- --nocapture
- name: Verify Windows Torrent web-seed normalization
if: runner.os == 'Windows'
working-directory: src-tauri
run: cargo test --test torrent_web_seed --target ${{ matrix.target }} -- --nocapture
- name: Install Aria2 source build dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
- name: Install Aria2 source build dependencies (Windows)
id: aria2-msys
if: runner.os == 'Windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
base-devel autoconf automake libtool gettext-devel pkgconf
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
- name: Fingerprint engine toolchain
id: engine-toolchain
if: runner.os != 'macOS'
env:
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
run: node scripts/engine-toolchain-fingerprint.js
- name: Restore verified engine payload cache
id: engine-cache
if: runner.os != 'macOS'
# v4.2.0 pinned to an immutable commit; this cache is an optimization,
# and a miss always falls back to source provisioning below.
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: src-tauri/provisioned-engines/${{ matrix.target }}
# The target, toolchain fingerprint, lockfiles, provisioning code,
# payload validators, and runner package lists all invalidate the key.
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
- name: Validate restored engine payload
id: engine-cache-validation
if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true'
continue-on-error: true
env:
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }}
run: |
node scripts/stage-engines.js
node scripts/verify-binaries.js --staged
- name: Restore verified Aria2 build cache
id: aria2-cache
if: >-
runner.os != 'macOS' &&
(steps.engine-cache.outputs.cache-hit != 'true' ||
steps.engine-cache-validation.outcome != 'success')
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
- name: Provision locked engines
if: >-
runner.os != 'macOS' &&
(steps.engine-cache.outputs.cache-hit != 'true' ||
steps.engine-cache-validation.outcome != 'success')
env:
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
FIRELINK_TOOLCHAIN_FINGERPRINT: ${{ steps.engine-toolchain.outputs.fingerprint }}
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Stage and verify engines
env:
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
run: |
node scripts/stage-engines.js --target ${{ matrix.target }}
node scripts/verify-binaries.js --staged --target ${{ matrix.target }}
- name: Run Torrent process smoke
env:
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
run: node scripts/smoke-torrent.js --failure-paths
- name: Run Aria2 resolver smoke
env:
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
run: node scripts/smoke-aria2-resolver.js
- name: Run Aria2 normal-transfer smoke
env:
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-workspace/${{ matrix.target }}/engine-dist
run: node scripts/smoke-aria2-transfers.js
- name: Save verified Aria2 build cache
if: >-
runner.os != 'macOS' &&
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
steps.aria2-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
- name: Save verified engine payload cache
if: >-
runner.os != 'macOS' &&
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
steps.engine-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: src-tauri/provisioned-engines/${{ matrix.target }}
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
+224 -14
View File
@@ -40,18 +40,50 @@ jobs:
- uses: actions/checkout@v7
with:
submodules: recursive
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 22.12
cache: npm
- name: Verify tagged release version
if: github.event_name == 'push' || inputs.publish_release
run: node scripts/verify-release-version.js
- name: Verify non-publishing package version
if: github.event_name == 'workflow_dispatch' && !inputs.publish_release
run: node scripts/verify-release-version.js --allow-untagged
- name: Verify tagged Companion release identity
if: github.event_name == 'push' || inputs.publish_release
run: node scripts/verify-companion-release.js
- name: Verify non-publishing Companion package metadata
if: github.event_name == 'workflow_dispatch' && !inputs.publish_release
run: node scripts/verify-companion-release.js --allow-untagged
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
# Hosted Ubuntu images can expose an unreachable Azure mirror in
# their apt sources while the public Ubuntu archive is reachable.
# Keep release jobs bounded and use the same source normalization as
# the native CI path so a runner-local mirror cannot hang packaging.
sudo find /etc/apt -type f \
-exec sed -i \
-e 's#http://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
-e 's#https://azure\.archive\.ubuntu\.com#https://archive.ubuntu.com#g' \
{} +
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=60 \
update
sudo env DEBIAN_FRONTEND=noninteractive timeout --foreground --signal=TERM --kill-after=30s 10m apt-get \
-o Acquire::Retries=3 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
-o DPkg::Lock::Timeout=60 \
install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
@@ -61,16 +93,95 @@ jobs:
libdbus-1-dev \
pkg-config \
xvfb \
libtinfo5
libtinfo5 \
rpm \
cpio \
libarchive-tools \
desktop-file-utils \
xdg-utils
- run: npm ci
- name: Provision locked engines
- name: Install Aria2 source build dependencies (Linux)
if: runner.os == 'Linux'
run: sudo apt-get install -y autoconf automake libtool gettext autopoint libssl-dev libssh2-1-dev libgcrypt20-dev libc-ares-dev libexpat1-dev libsqlite3-dev zlib1g-dev
- name: Install Aria2 source build dependencies (Windows)
id: aria2-msys
if: runner.os == 'Windows'
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: >-
base-devel autoconf automake libtool gettext-devel pkgconf
mingw-w64-x86_64-gcc mingw-w64-x86_64-pkgconf
mingw-w64-x86_64-openssl mingw-w64-x86_64-libssh2
mingw-w64-x86_64-c-ares mingw-w64-x86_64-expat
mingw-w64-x86_64-sqlite3 mingw-w64-x86_64-zlib
- name: Fingerprint engine toolchain
id: engine-toolchain
if: runner.os != 'macOS'
env:
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
run: node scripts/engine-toolchain-fingerprint.js
- name: Restore verified engine payload cache
id: engine-cache
if: runner.os != 'macOS'
# v4.2.0 pinned to an immutable commit; release jobs never write cache
# entries, so only trusted CI pushes can populate the shared payload.
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: src-tauri/provisioned-engines/${{ matrix.target }}
key: firelink-engine-payload-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.fingerprint }}-${{ hashFiles('engine-sources.lock.json', 'engines.lock.json', 'scripts/aria2/**', 'scripts/engine-*.js', 'scripts/provision-engines.js', 'scripts/stage-engines.js', 'scripts/verify-binaries.js', 'scripts/aria2-route-contract.js', '.github/workflows/ci.yml', '.github/workflows/release.yml') }}
- name: Validate restored engine payload
id: engine-cache-validation
if: runner.os != 'macOS' && steps.engine-cache.outputs.cache-hit == 'true'
continue-on-error: true
env:
FIRELINK_TARGET_TRIPLE: ${{ matrix.target }}
FIRELINK_ENGINE_OUTPUT_ROOT: ${{ runner.temp }}/firelink-engine-cache-validation/${{ matrix.target }}
run: |
node scripts/stage-engines.js
node scripts/verify-binaries.js --staged
- name: Restore verified Aria2 build cache
id: aria2-cache
if: >-
runner.os != 'macOS' &&
(steps.engine-cache.outputs.cache-hit != 'true' ||
steps.engine-cache-validation.outcome != 'success')
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57
with:
path: src-tauri/provisioned-engines/.aria2-cache/${{ matrix.target }}
key: firelink-aria2-build-v1-${{ matrix.target }}-${{ steps.engine-toolchain.outputs.aria2-fingerprint }}
- name: Provision locked engines
if: >-
runner.os != 'macOS' &&
(steps.engine-cache.outputs.cache-hit != 'true' ||
steps.engine-cache-validation.outcome != 'success')
env:
FIRELINK_MSYS2_ROOT: ${{ steps.aria2-msys.outputs.msys2-location }}
FIRELINK_TOOLCHAIN_FINGERPRINT: ${{ steps.engine-toolchain.outputs.fingerprint }}
run: node scripts/provision-engines.js --target ${{ matrix.target }}
- name: Build package
run: npm run tauri build -- -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
if: runner.os != 'Linux'
run: node scripts/tauri-command.js build -vv --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
env:
APPIMAGE_EXTRACT_AND_RUN: 1
- name: Build Linux native packages
if: runner.os == 'Linux'
run: node scripts/tauri-command.js build -vv --target ${{ matrix.target }} --bundles deb,rpm
env:
APPIMAGE_EXTRACT_AND_RUN: 1
- name: Verify and preserve Linux native packages
if: runner.os == 'Linux'
run: |
node scripts/verify-linux-packages.js --target ${{ matrix.target }}
mkdir -p "$RUNNER_TEMP/firelink-native-packages/deb" "$RUNNER_TEMP/firelink-native-packages/rpm"
cp src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb "$RUNNER_TEMP/firelink-native-packages/deb/"
cp src-tauri/target/${{ matrix.target }}/release/bundle/rpm/*.rpm "$RUNNER_TEMP/firelink-native-packages/rpm/"
- name: Build Linux AppImage from existing binary
if: runner.os == 'Linux'
run: node scripts/build-linux-appimage.js --target ${{ matrix.target }}
env:
APPIMAGE_EXTRACT_AND_RUN: 1
FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE: ${{ runner.os == 'Linux' && '1' || '' }}
- name: Install pinned appimagetool (Linux only)
if: runner.os == 'Linux'
env:
@@ -86,11 +197,25 @@ jobs:
- name: Verify macOS packaged engines and launch
if: runner.os == 'macOS'
run: |
APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app"
set -euo pipefail
DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)"
test -n "$DMG"
npm run verify:macos-signing -- --app "$APP" --dmg "$DMG"
npm run verify:macos-signing -- --dmg "$DMG"
MOUNT_POINT="$(mktemp -d "$RUNNER_TEMP/firelink-dmg-mount.XXXXXX")"
cleanup() {
hdiutil detach "$MOUNT_POINT" -quiet || hdiutil detach "$MOUNT_POINT" -force -quiet || true
rmdir "$MOUNT_POINT" 2>/dev/null || true
}
trap cleanup EXIT
hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$DMG" >/dev/null
APP_COUNT="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' | wc -l | tr -d ' ')"
test "$APP_COUNT" -eq 1
APP="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' -print -quit)"
FIRELINK_MACOS_APP="$APP" node scripts/app-associations.node-test.js
node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }}
ARIA2="$(find "$APP" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
test -n "$ARIA2"
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
- name: Verify Windows installer payload and launch
if: runner.os == 'Windows'
@@ -100,8 +225,61 @@ jobs:
if (-not $installer) { throw "Windows NSIS installer artifact was not produced." }
$extractRoot = "$env:RUNNER_TEMP/firelink-installer"
Remove-Item -Recurse -Force $extractRoot -ErrorAction SilentlyContinue
7z x $installer.FullName "-o$extractRoot" -y
& 7z x $installer.FullName "-o$extractRoot" -y
if ($LASTEXITCODE -ne 0) { throw "7z failed to extract the Windows installer payload (exit code $LASTEXITCODE)." }
node scripts/verify-binaries.js --search-root "$extractRoot" --target ${{ matrix.target }}
$aria2 = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq "aria2c-${{ matrix.target }}.exe" } |
Sort-Object FullName |
Select-Object -First 1
if (-not $aria2) { throw "Packaged Aria2 executable was not found in the installer payload." }
node scripts/smoke-aria2-transfers.js --binary $aria2.FullName
$portableRoot = "$env:RUNNER_TEMP/firelink-portable-payload"
$portableArtifactDir = "$env:RUNNER_TEMP/firelink-portable"
Remove-Item -Recurse -Force $portableRoot -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $portableArtifactDir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $portableRoot -Force | Out-Null
New-Item -ItemType Directory -Path $portableArtifactDir -Force | Out-Null
$payloadExe = Get-ChildItem $extractRoot -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ieq "firelink.exe" } |
Sort-Object FullName |
Select-Object -First 1
if (-not $payloadExe) { throw "firelink.exe was not found in the extracted installer payload." }
Copy-Item (Join-Path $payloadExe.Directory.FullName '*') $portableRoot -Recurse -Force
Set-Content -Path (Join-Path $portableRoot 'portable.flag') -Value 'portable' -NoNewline
@"
Firelink portable
Extract this folder to a writable location and launch firelink.exe.
Close Firelink before copying or moving this folder.
Settings, queues, logs, and WebView data are stored under data\.
Only one Firelink instance can run at a time; close the installed app before launching this copy.
Credentials, browser cookies, and URL query/fragment data are not persisted in portable queue records.
Saved site passwords remain in the Windows credential store and are not portable.
The portable folder contains the extension pairing credential; treat it as sensitive and do not share it.
Saved absolute download locations may need to be selected again after moving to another drive.
The portable archive does not register the firelink:// protocol; use the installer for browser launch integration.
"@ | Set-Content -Path (Join-Path $portableRoot 'PORTABLE_README.txt')
New-Item -ItemType Directory -Path (Join-Path $portableRoot 'data') -Force | Out-Null
$portableExe = Join-Path $portableRoot 'firelink.exe'
node scripts/verify-binaries.js --search-root "$portableRoot" --target ${{ matrix.target }}
node scripts/smoke-packaged-app.js --executable $portableExe --assert-no-visible-child-windows --assert-portable-data
Get-ChildItem $portableRoot -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^(unins|Uninstall).*\.exe$' } |
Remove-Item -Force -ErrorAction SilentlyContinue
$portableDataDir = Join-Path $portableRoot 'data'
for ($attempt = 1; $attempt -le 10; $attempt++) {
Remove-Item -Recurse -Force $portableDataDir -ErrorAction SilentlyContinue
if (-not (Test-Path $portableDataDir)) { break }
Start-Sleep -Milliseconds (200 * $attempt)
}
if (Test-Path $portableDataDir) {
throw "Portable test data could not be removed after smoke; refusing to package a ZIP containing runtime data."
}
$portableZip = "$portableArtifactDir/Firelink_${{ github.ref_name }}_Windows-x64-portable.zip"
7z a -tzip $portableZip "$portableRoot\*" -y
$installRoot = "$env:RUNNER_TEMP\FirelinkSmoke"
Remove-Item -Recurse -Force $installRoot -ErrorAction SilentlyContinue
$install = Start-Process -FilePath $installer.FullName -ArgumentList @("/S", "/D=$installRoot") -Wait -PassThru
@@ -121,6 +299,9 @@ jobs:
chmod +x "$APPIMAGE"
(cd "$RUNNER_TEMP" && "$GITHUB_WORKSPACE/$APPIMAGE" --appimage-extract >/dev/null)
node scripts/verify-binaries.js --search-root "$RUNNER_TEMP/squashfs-root" --target ${{ matrix.target }}
ARIA2="$(find "$RUNNER_TEMP/squashfs-root" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)"
test -n "$ARIA2"
node scripts/smoke-aria2-transfers.js --binary "$ARIA2"
xvfb-run -a node scripts/smoke-packaged-app.js --executable "$RUNNER_TEMP/squashfs-root/AppRun"
- uses: actions/upload-artifact@v7
with:
@@ -128,6 +309,27 @@ jobs:
path: ${{ matrix.artifact }}
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Windows'
with:
name: Firelink-Windows-x64-portable-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-portable/*.zip
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Linux'
with:
name: Firelink-Linux-x64-Deb-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-native-packages/deb/*.deb
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v7
if: runner.os == 'Linux'
with:
name: Firelink-Linux-x64-RPM-${{ github.ref_name }}
path: ${{ runner.temp }}/firelink-native-packages/rpm/*.rpm
if-no-files-found: error
retention-days: 3
publish:
name: Publish GitHub release
@@ -159,21 +361,29 @@ jobs:
local pattern="$1"
local destination="$2"
local source
source="$(find release-assets -maxdepth 1 -type f -name "$pattern" -print -quit)"
if [[ -z "$source" ]]; then
echo "::error::Missing release asset matching $pattern"
mapfile -d '' -t matches < <(find release-assets -maxdepth 1 -type f -name "$pattern" -print0)
if (( ${#matches[@]} != 1 )); then
echo "::error::Expected exactly one release asset matching $pattern, found ${#matches[@]}"
exit 1
fi
source="${matches[0]}"
mv "$source" "release-assets/$destination"
}
rename_asset '*.dmg' "Firelink_${VERSION}_macOS-ARM64.dmg"
rename_asset '*.AppImage' "Firelink_${VERSION}_Linux-x64.AppImage"
rename_asset '*.deb' "Firelink_${VERSION}_Linux-x64.deb"
rename_asset '*.rpm' "Firelink_${VERSION}_Linux-x64.rpm"
rename_asset '*.exe' "Firelink_${VERSION}_Windows-x64-setup.exe"
rename_asset '*.zip' "Firelink_${VERSION}_Windows-x64-portable.zip"
- name: Generate checksums
run: |
set -euo pipefail
cd release-assets
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
checksum_tmp="$RUNNER_TEMP/Firelink-SHA256SUMS"
trap 'rm -f "$checksum_tmp"' EXIT
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > "$checksum_tmp"
mv "$checksum_tmp" SHA256SUMS
- uses: softprops/action-gh-release@v3
with:
files: release-assets/**
+11 -1
View File
@@ -11,14 +11,24 @@ skills-lock.json
# Local agent and planning notes
AGENT.md
AGENTS.md
TORRENT_FEATURES.md
torrent_features.md
TORRENT_UI_IMPLEMENTATION_PLAN.md
CLAUDE.md
GEMINI.md
implementation_plan.md
CROSS_PLATFORM_CHECKLIST.md
Cross-platform-checklist-gemini.MD
ARIA2_AVERAGE_USER_AUDIT.md
YouTube_media_download_handoff.md
Release_checklist.md
Release Checklist/
RC Read-only/
RC Read_only/
# Frontend output and logs
.agents/
release_notes.md
node_modules/
dist/
dist-ssr/
@@ -35,7 +45,6 @@ lerna-debug.log*
target/
src-tauri/target/
src-tauri/gen/
src-tauri/engine-dist/
src-tauri/provisioned-engines/
# Locally provisioned native engines
@@ -55,6 +64,7 @@ build/
# Local secrets and signing material
.env
.env.*
implementation_plan.md
*.key
*.pem
!src-tauri/binaries/_internal/certifi/cacert.pem
+2 -2
View File
@@ -1,3 +1,3 @@
[submodule "Extensions/Firefox"]
path = Extensions/Firefox
[submodule "Extensions/Browser"]
path = Extensions/Browser
url = https://github.com/nimbold/Firelink-Extension.git
+190
View File
@@ -5,6 +5,196 @@ All notable changes to Firelink 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).
## [1.4.2] - 2026-09-08
This is the stable follow-up to the 1.4.0 pre-release and includes all work since 1.3.1. It is a major release focused on built-in Torrents, clearer transfer controls, safer recovery, and better browser and VPN support.
### New features
- **BitTorrent downloads and browser handoff**
- Add `.torrent` files and magnet links from the Add window, file associations, `magnet:` links, and Firelink Companion.
- Review remote metadata before queueing; choose files and priorities, allocate or preallocate data, verify existing files, remove unselected files safely, and add per-file web seeds.
- Configure trackers and exclusions, tracker timing, DHT/IPv6/PEX/LPD discovery, encryption, peer limits, network identity, and resource limits.
- See per-file and piece progress, availability, peers, seeders, info hash, and upload activity.
- Set upload and seeding limits, seed time or ratio, stop timeout, concurrent seed slots, and move Torrent data.
- Manage Torrents in a dedicated category with pause, resume, retry, redownload, and safe cleanup.
- Send browser magnets, direct `.torrent` links, and browser-local Torrent attachments to the Add window for review.
- **Download and Torrent Properties windows**
- Open a selected download in its own window with overview, transfer, and advanced controls.
- Use Torrent tabs for file selection, trackers, peers, options, and live diagnostics.
- Change supported transfer, Torrent, seeding, verification, allocation, and encryption settings while work is active.
- Inspect exact progress, allocation, destinations, resume failures, and diagnostics; copy long URLs or paths and export magnet links.
- Keep the Properties window size during the session while theme and locale follow the app.
- **Adaptive mirror selection**
- Optionally choose among mirrors using recent transfer performance; history remains private on this device.
- **Transfer and layout visibility**
- See when a normal download is allocating its destination.
- Remember the main-window size and position and the Folders collapse preference between launches.
### Improvements
- Make normal downloads recover more reliably across restarts, redirects, retries, resumed transfers, missing credentials, and connection slowdowns.
- Improve media recovery and resume behavior, preserve exact final progress, and restore adaptive YouTube formats after interruptions, addressing [#36](https://github.com/nimbold/Firelink/issues/36).
- Keep browser and deep-link inputs in order; make magnet clipboard handoffs and Add-window destination and metadata validation clearer.
- Make the download table, sidebar, Add window, Settings, RTL keyboard navigation, and accessibility behavior more usable at narrow window sizes.
- Document the macOS first-launch security warning and safe approval steps after the report in [#34](https://github.com/nimbold/Firelink/issues/34).
- Refresh bundled engines and dependencies, resume interrupted engine downloads safely, and strengthen cross-platform package and release verification.
### Fixes
- Fix the Windows 1.4.0 startup failure, focus recursion, and Properties-window deadlock reported in [#37](https://github.com/nimbold/Firelink/issues/37) and [#41](https://github.com/nimbold/Firelink/issues/41).
- Fix immediate download failures after the Add window showed **Ready** when a VPN or TUN's DNS path could not reach its servers; retry through the system resolver for affected transfers, addressing [#35](https://github.com/nimbold/Firelink/issues/35).
- Fix false **Unsafe URL** failures under V2RayN, Proxifier, and other TUN or proxy setups by letting hostname lookups follow the active network route while continuing to block literal local and private targets, addressing [#38](https://github.com/nimbold/Firelink/issues/38).
- Protect persisted downloads during startup and recover schema-v3 records instead of wiping or losing them.
- Prevent stale or duplicate pause, resume, retry, completion, and removal actions from reviving items, misreporting progress, or leaving queue ownership behind.
- Make replacements and cleanup safe when downloads are queued, retried, canceled, paused, completed, or removed, including multi-file assets.
- Prevent browser capture races from duplicating a download, resuming it too early, or applying cleanup to the wrong item.
- Fix network and Settings panel overlap plus Windows frame, shadow, focus, and cross-platform control issues.
- Restore edited-download credentials without stale keychain conflicts.
### Compatibility
- Use [Firelink Companion `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2), or the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest), with Firelink `1.4.2`. The Companion includes the shared Chromium package and localized Edge Add-ons submission material requested in [#39](https://github.com/nimbold/Firelink/issues/39); the public Edge listing still requires Microsoft's certification.
## [1.3.1] - 2026-07-30
This hotfix improves filename handling, localized display details, and release reliability across platforms.
### Fixes
- Prevent filename errors for long downloads and Windows-reserved names such as `CON` and `COM1`. Names are shortened safely while keeping their file extensions, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
- Correct Persian date formatting in affected settings and timezone cases, while preserving Hebrew locale formatting.
- Fix remaining light-theme overlay and contrast details reported with the broader appearance and localization work in [#31](https://github.com/nimbold/Firelink/issues/31).
### Improvements
- Make Linux AppImage packaging more reliable and avoid compiling the same Rust release binary twice.
- Strengthen cross-platform release checks so Windows and Linux test builds handle macOS-only code correctly.
### Compatibility
- Firelink Companion `2.1.0` remains the current compatible extension. No extension update is required for this hotfix; use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest).
## [1.3.0] - 2026-07-29
This release focuses on smoother queue control, a more comfortable interface, and safer recovery when downloads or browser handoffs are interrupted.
### New features
- **Live speed limits and connection controls** can be adjusted while a download is active.
- **Queue reordering and bulk actions** are easier to use, with paused downloads kept behind work that is ready to run.
- **Appearance preferences** now include a Windows 11-style option, sidebar-aware window controls, curated fonts for supported languages, and optional localized calendar dates. These improvements respond to [#31](https://github.com/nimbold/Firelink/issues/31).
- **Firelink Companion `2.1.0`** is included in the `Extensions/Browser` submodule. Get the latest extension from the [Firelink-Extension releases](https://github.com/nimbold/Firelink-Extension/releases/latest).
### Improvements
- Resume, retry, pause, and redownload actions now recover more reliably when an earlier attempt has disappeared or a background event arrives late, addressing the failed-resume report in [#28](https://github.com/nimbold/Firelink/issues/28).
- Browser captures recover more safely when Firelink is starting, the extension restarts, or capture settings change during handoff. Captures remain in the Add window for review.
- macOS minimize and Dock, Spotlight, and Raycast reopen behavior are more consistent, addressing [#30](https://github.com/nimbold/Firelink/issues/30).
- The downloads table stays readable at narrow widths: row actions remain contained, columns resize consistently, and controls no longer cover Date Added text, addressing [#32](https://github.com/nimbold/Firelink/issues/32).
- Add-window metadata, duplicate resolution, playlist selection, floating menus, page navigation, and table interactions provide clearer feedback and better keyboard behavior.
- Startup consent and keychain access are more predictable, with clearer permission feedback and safer modal focus.
### Fixes
- Prevent stale pause, resume, retry, completion, and remove actions from reviving a download, reporting the wrong result, or leaving a row stuck in a misleading state.
- Keep queue limits, connection recovery, missing completion events, and retry cleanup from leaving downloads stranded or exceeding the users settings.
- Keep paused work behind pending downloads and make queue controls behave correctly when several actions happen close together.
- Automatically shorten media filenames that exceed the cross-platform per-file-name limit, addressing [#29](https://github.com/nimbold/Firelink/issues/29).
- Refresh dependencies and bundled engines, and strengthen release, package, portable-mode, and cross-platform verification.
## [1.2.0] - 2026-07-22
This release makes everyday download management easier to organize, review, and trust across the desktop app and browser extension.
### New features
- **Localized desktop interface** for Persian, Hebrew, Russian, Ukrainian, and Simplified Chinese, responding to the Chinese-language request in [#17](https://github.com/nimbold/Firelink/issues/17). These translations are currently produced with help from LLMs and need user review; please report corrections so they can improve.
- **Customizable download table** with selectable columns, drag-to-reorder controls, sorting, clearer size alignment, and better bulk actions.
- **Optional batch folders** for multi-link downloads, addressing [#27](https://github.com/nimbold/Firelink/issues/27). Firelink can suggest an editable folder name from the page title or common filename while keeping the existing category-based behavior as the default.
- **Remember the last Add-window directory**, with an opt-in setting so users remain in control of where it applies, addressing [#23](https://github.com/nimbold/Firelink/issues/23).
- **Per-download connection controls** for normal and media transfers, with the active aria2 connection count shown in the download table. This also follows the connection-setting report in [#20](https://github.com/nimbold/Firelink/issues/20).
- **Latest Firelink Companion 2.0.6** in the `Extensions/Browser` submodule, including selected-link batch context and safer automatic capture recovery.
### Improvements
- Make pause, resume, retry, remove, redownload, queue, scheduler, and settings actions safer when several operations overlap or background events arrive late.
- Reuse unfinished downloads when their filenames match, addressing [#26](https://github.com/nimbold/Firelink/issues/26), while reducing the chance of stale state creating duplicate or misleading rows.
- Improve first-open navigation, lazy page loading, table layout, drag interactions, sorting, and window-control placement.
- Improve RTL behavior while keeping the download table's physical file columns readable.
- Handle locked browser cookie databases more gracefully and keep browser metadata and authenticated captures on the correct path, addressing the media reports in [#22](https://github.com/nimbold/Firelink/issues/22) and [#24](https://github.com/nimbold/Firelink/issues/24).
- Enforce startup consent before accessing saved credentials and make keychain-related startup behavior more predictable.
- Refresh dependencies and bundled engines, and strengthen checks for macOS, Windows portable, Linux packages, release assets, and release version identity.
- Refine localized wording, including Persian status labels.
### Fixes
- Prevent stale lifecycle work, duplicate terminal events, and overlapping controls from deleting, reviving, or leaving the wrong download in a misleading state.
- Keep missed completion events and connection-limit updates from leaving a finished or active download visually stuck.
- Bound backend connection counts so user-selected limits are respected instead of allowing overlapping settings to exceed them.
- Keep extension handoffs in the Add window and make selected-link batches retain their page context for folder suggestions.
## [1.1.1] - 2026-07-17
This patch release focuses on transfer reliability, browser captures, and easier download control.
### New
- Add YouTube playlist downloads with smoother queueing and scrolling for large playlists.
- Add live per-download connection controls, clipboard capture for the Add window, and byte-level progress updates.
### Improved
- Recover slow or stalled transfers more reliably and apply connection defaults consistently, addressing [#19](https://github.com/nimbold/Firelink/issues/19) and [#20](https://github.com/nimbold/Firelink/issues/20).
- Make pause, resume, retry, cancel, remove, and completion handling more consistent when actions or background events overlap.
- Improve playlist state, media size estimates, and large-list performance.
- Keep clipboard, browser, and deep-link handoffs behind the startup consent explanation, and make that boundary clearer.
- Refresh bundled engines and dependencies while strengthening cross-platform package and diagnostic checks.
### Fixed
- Fix Gmail and other authenticated browser downloads that could lose their filename or save a sign-in page after a redirect, including Chrome Incognito, addressing [#21](https://github.com/nimbold/Firelink/issues/21).
- Keep browser-capture metadata, cookies, and destinations tied to the correct download through redirects.
- Prevent invalid URLs, late download events, stale progress, and abandoned media work from creating misleading rows or leftover temporary files.
- Keep sensitive local paths, credentials, and persisted records protected in errors, diagnostics, and download state.
## [1.1.0] - 2026-07-15
This is a stability-focused release with safer downloads, browser handoffs, settings, and cross-platform packages.
### New
- Add a secure Windows portable ZIP, addressing the portable-version request in [#15](https://github.com/nimbold/Firelink/issues/15). Settings, queues, logs, and WebView data stay with the portable folder, while saved site passwords remain protected in Windows Credential Manager.
### Improved
- Make pause, resume, retry, remove, redownload, and queue actions reliable when several operations happen close together. Downloads now recover their state more consistently instead of restarting unexpectedly or appearing stuck after completion.
- Make browser captures and the Add window safer and more dependable. Captured cookies are no longer sent to metadata checks unless authentication is actually needed, while ordinary downloads still keep the browser session they need. This resolves the fallback error reported in [#16](https://github.com/nimbold/Firelink/issues/16).
- Improve settings and startup behavior: speed-limit units stay consistent, saved settings are handled more safely, and credential-store access waits until it is needed.
- Improve browser handoff safety with stronger local request, replay, path, and credential checks.
- Strengthen macOS, Windows, and Linux release verification, refresh the bundled FFmpeg payload, and make package and diagnostic checks more reliable.
### Fixed
- Prevent late or duplicate background events from bringing back downloads after a newer pause, remove, or edit action has already won.
- Reconcile completed downloads even when a background completion event is missed, so finished files do not remain visually stuck at the end of the transfer.
- Decode URL-derived filenames so links containing encoded characters such as `%20` produce readable names, addressing [#18](https://github.com/nimbold/Firelink/issues/18).
- Keep local paths, usernames, credentials, and other sensitive values out of errors and diagnostic output where they are not needed.
## [1.0.4] - 2026-07-12
### New
- Automatically fill the Add window with valid links from the clipboard when you choose **Add link**, addressing [#10](https://github.com/nimbold/Firelink/issues/10).
- Add a persistent, accessible collapse control for the **Folders** section so the sidebar can stay tidy, addressing [#13](https://github.com/nimbold/Firelink/issues/13).
- Add verified Linux `.deb` and `.rpm` packages alongside the portable AppImage, completing the Linux packaging request in [#3](https://github.com/nimbold/Firelink/issues/3).
### Improved
- Make queue actions safer: rapid clicks no longer open item properties by accident, and replacing an existing download preserves resumable progress instead of starting from zero, addressing [#11](https://github.com/nimbold/Firelink/issues/11) and [#12](https://github.com/nimbold/Firelink/issues/12).
- Improve browser-captured batches so each link keeps its own metadata, headers, cookies, and destination instead of sharing stale request details.
- Make media downloads more reliable with custom or system proxies, clearer metadata errors, more accurate quality choices, and steadier retry, speed, ETA, and progress updates, continuing the work reported in [#5](https://github.com/nimbold/Firelink/issues/5) and [#8](https://github.com/nimbold/Firelink/issues/8).
- Keep the Logs view responsive while it is open and redact local paths and usernames from diagnostic output before it is shown or exported.
- Keep dialogs and controls clear of macOS, Windows, and Linux window controls, and strengthen pause, resume, retry, and removal behavior during rapid actions.
- Clarify incomplete-download handling: aria2 sidecar files show when a download is unfinished, preserve resume information, and are removed after completion, addressing [#14](https://github.com/nimbold/Firelink/issues/14).
### Fixed
- Prevent stale background queue work from resurrecting, duplicating, or restarting downloads after a newer pause, remove, or edit action wins.
- Keep explicit media requests on Firelink's configured browser-cookie source instead of forwarding raw browser cookies, while preserving the browser session for ordinary captured downloads.
- Make final HTTP errors visible during metadata requests and prevent internal retry limits from multiplying unexpectedly.
## [1.0.3] - 2026-07-09
### Improved
Submodule Extensions/Browser added at f20954fe0a
+108 -114
View File
@@ -1,97 +1,114 @@
<div align="center">
<img src="src/assets/app-icon.png" alt="Firelink" width="112" height="112" />
<p align="center">
<img src="src/assets/app-icon.png" alt="Firelink app icon" width="128" height="128" />
</p>
# Firelink
# Firelink
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
> A fast, focused desktop download manager for macOS, Windows, and Linux.
[![Version](https://img.shields.io/badge/version-1.0.3-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#platforms)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/nimbold/Firelink?style=flat-square)](https://github.com/nimbold/Firelink/releases/latest)
[![CI](https://img.shields.io/github/actions/workflow/status/nimbold/Firelink/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/nimbold/Firelink/actions/workflows/ci.yml)
[![License](https://img.shields.io/github/license/nimbold/Firelink?style=flat-square)](LICENSE)
[![macOS](https://img.shields.io/badge/macOS-Apple%20silicon-111111?style=flat-square&logo=apple&logoColor=white)](#installation)
[![Windows](.github/badges/windows.svg)](#installation)
[![Linux](https://img.shields.io/badge/Linux-x64-FCC624?style=flat-square&logo=linux&logoColor=black)](#installation)
[Features](#features) · [Install](#installation) · [Browser Extension](#browser-extension) · [Development](#development) · [Release Notes](CHANGELOG.md)
</div>
<br/>
<div align="center">
<p align="center">
<img src="Screenshots/Dark%20theme%20-%20main.png" width="24%" alt="Firelink dark theme main window" />
<img src="Screenshots/Dark%20theme%20-%20add%20window.png" width="24%" alt="Firelink dark theme add window" />
<img src="Screenshots/Light%20theme%20-%20main.png" width="24%" alt="Firelink light theme main window" />
<img src="Screenshots/Light%20theme%20-%20add%20window.png" width="24%" alt="Firelink light theme add window" />
</p>
<details>
<summary><b>View more screenshots</b></summary>
<br/>
<img src="Screenshots/Dark%20theme%20-%20settings.png" width="32%" alt="Firelink dark theme settings" />
<img src="Screenshots/Light%20theme%20-%20settings.png" width="32%" alt="Firelink light theme settings" />
</details>
</div>
## What is Firelink?
## Why Firelink
Firelink manages direct downloads, browser captures, media, playlists, queues, and scheduling from one desktop app.
Firelink is a desktop download manager for fast transfers, browser capture, media extraction, scheduling, and clear file placement.
It uses a Rust and Tauri backend with a React and TypeScript interface. Required download and media engines are bundled with the app.
It is now a cross-platform Rust/Tauri app with a React and TypeScript interface. A native backend coordinates downloads with aria2, yt-dlp, FFmpeg, Deno, and SQLite.
## Status
Firelink `1.4.2` is the latest desktop release.
Use [the latest Firelink Companion release, `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2) with Firelink. The Companion is maintained in the [Firelink-Extension repository](https://github.com/nimbold/Firelink-Extension).
The project is actively maintained. See the [changelog](CHANGELOG.md) for release history and current work.
Translations are available for English, Simplified Chinese, Hebrew, Persian, Ukrainian, and Russian. Translation corrections are welcome.
## Features
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
- **Add window** for metadata, duplicates, location choices, and captured links.
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
- **File organization** with categories, default folders, per-download overrides, and reveal/trash actions.
- **Browser handoff** through local pairing, signed requests, Add window review, replay protection, and server checks.
- **Desktop integration** with tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- **Diagnostics** with engine health checks, structured logs, and package verification.
- Segmented HTTP and HTTPS downloads with retries, speed limits, and connection controls.
- Media and playlist downloads through yt-dlp, FFmpeg, and Deno.
- BitTorrent downloads from `.torrent` files, magnet links, and browser handoff, with file selection, web seeds, seeding, and live diagnostics.
- Standalone Download and Torrent Properties windows with live controls, transfer details, and safe Torrent relocation.
- Adaptive mirror selection for multi-source downloads.
- An Add window for metadata, duplicate handling, save locations, and download options.
- Persistent queues with pause, resume, retry, redownload, and scheduling.
- Visible allocation and resume status with clearer transfer diagnostics.
- Live speed and connection controls for active downloads.
- A customizable download table with sorting, column selection, reordering, and bulk actions.
- File organization with categories, default folders, and per-download locations.
- Appearance preferences with window styles, multilingual fonts, and localized calendar dates.
- Browser handoff through local pairing and signed requests.
- Tray controls, notifications, sounds, sleep prevention, and secure credential storage.
- RTL support for Hebrew and Persian.
## Installation
Download desktop builds from [GitHub Releases](https://github.com/nimbold/Firelink/releases).
Download the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
| Platform | Package | Notes |
| --- | --- | --- |
| **macOS Apple silicon** | `.dmg` | Not notarized. If macOS blocks the first launch, approve Firelink in **System Settings -> Privacy & Security**. |
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
| macOS Apple silicon | `.dmg` | Ad-hoc signed and not notarized. macOS may require approval in **System Settings -> Privacy & Security**. |
| Windows x64 | NSIS `.exe` | Unsigned. Windows SmartScreen may display a warning. |
| Windows x64 portable | `.zip` | Extract to a writable folder and launch `firelink.exe`. |
| Linux x64 | `.deb`, `.rpm`, or `.AppImage` | Choose the package for your distribution, or use AppImage. |
Bundles include the required engines. Users do not need aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or another package manager.
All packages include aria2, yt-dlp, FFmpeg, Deno, and SQLite support. No separate engine installation is required.
## Browser Extension
<details>
<summary><strong>macOS security warning and first launch</strong></summary>
<p align="center">
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install Firelink Companion from Firefox Add-ons" /></a>
&nbsp;&nbsp;
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Read manual Chromium install instructions" /></a>
Firelink's macOS release is ad-hoc signed but not notarized by Apple. A paid Apple Developer Program account is required for Apple's Developer ID signing and notarization, so macOS may warn that it cannot verify Firelink or check that it is free of malware. This warning does not by itself mean that the app contains malware.
Only use these steps for Firelink downloaded from the [official GitHub release page](https://github.com/nimbold/Firelink/releases/latest). After moving `Firelink.app` to Applications, use one of these methods to approve the first launch:
1. In Finder, Control-click or right-click `Firelink.app`, choose **Open**, then choose **Open** again.
2. Try opening Firelink once, then open **System Settings -> Privacy & Security**, scroll down, and choose **Open Anyway** for Firelink.
3. In Terminal, remove only the downloaded-file quarantine flag:
```bash
xattr -dr com.apple.quarantine /Applications/Firelink.app
```
If Firelink is stored somewhere else, replace the path with the actual path to `Firelink.app`. The approval is normally needed only once.
</details>
<details>
<summary><strong>Windows portable ZIP notes</strong></summary>
- Keep the folder writable. Avoid `Program Files` and read-only media.
- Settings, queues, logs, and WebView data are stored beside the executable in `data/`.
- Close Firelink before moving or copying the folder.
- Treat the folder as sensitive because it contains the extension pairing credential.
- The installer remains the supported path for `firelink://` browser launch registration.
</details>
## Browser integration
[Firelink Companion `2.2.2`](https://github.com/nimbold/Firelink-Extension/releases/tag/v2.2.2) connects browser downloads, links, media pages, magnets, and Torrent metadata to Firelink. Use the [latest Companion release](https://github.com/nimbold/Firelink-Extension/releases/latest) with the [latest Firelink release](https://github.com/nimbold/Firelink/releases/latest).
Captured links open Firelink's Add window for review before they are started or queued.
<p>
<a href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"><img src="https://img.shields.io/badge/Install%20from-Firefox%20Add--ons-FF7139?style=for-the-badge&logo=firefox-browser&logoColor=white" alt="Install from Firefox Add-ons" /></a>&nbsp;&nbsp;
<a href="https://github.com/nimbold/Firelink-Extension#manual-chromium-installation"><img src="https://img.shields.io/badge/Manual%20install-Chromium-4285F4?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Manual install for Chromium browsers" /></a>
</p>
Firelink Companion sends browser links and downloads to the desktop app.
What it adds:
- Automatic capture for regular browser downloads.
- Explicit Fetch media actions from the popup and page context menu.
- Context-menu actions for links and selected text.
- Firefox and Chromium support.
- Signed local requests using the token from **Settings -> Integrations**.
- Fallback to the browser download when Firelink is closed or rejects a handoff.
- Captured links always open Firelink's Add window before anything is added to the download list.
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.2 is the matching extension release for Firelink 1.0.3.
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
## Platforms
| Target | Status |
| --- | --- |
| **macOS arm64** | Supported. Native build, engine checks, launch smoke test, ad-hoc-signed DMG workflow. |
| **Windows x64** | Supported. Native build, engine checks, silent installer smoke test, NSIS installer. |
| **Linux x64** | Supported. Native build, engine checks, xvfb launch smoke test, AppImage. |
See the [Companion README](https://github.com/nimbold/Firelink-Extension#readme) for browser installation, pairing, and privacy details.
## Development
@@ -102,73 +119,50 @@ The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-
- Rust and Cargo
- [Tauri 2 platform prerequisites](https://v2.tauri.app/start/prerequisites/)
Clone the repository with its browser-extension submodule:
### Quick start
```sh
git clone --recurse-submodules https://github.com/nimbold/Firelink.git
cd Firelink
```
Install dependencies and launch the desktop app:
```sh
npm install
npm run tauri dev
```
Run the core checks:
Run the main checks with:
```sh
node --test scripts/*.node-test.js
npm test -- --run
npm run build
cd src-tauri
cargo test --all-targets
cd src-tauri && cargo test --all-targets
```
Create a production bundle:
See [RELEASE.md](RELEASE.md) for engine provisioning, packaging, and release verification.
```sh
npm run tauri build
```
## Contributing and support
macOS uses locked payloads in `src-tauri/binaries`. Provision Windows and Linux payloads from checksum-pinned archives:
```sh
node scripts/provision-engines.js --target x86_64-pc-windows-msvc
node scripts/provision-engines.js --target x86_64-unknown-linux-gnu
```
Build staging includes only the current target. See `engines.lock.json`, `engine-sources.lock.json`, and [RELEASE.md](RELEASE.md).
## Repository Structure
```text
.
├── src/ React and TypeScript interface
├── src-tauri/ Rust backend, Tauri config, and native tests
├── scripts/ Engine provisioning, release, and smoke-test tooling
└── Extensions/Firefox/ Firelink Companion submodule
```
## Help and Project Status
- Report bugs or request improvements in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Report bugs and request features in [GitHub Issues](https://github.com/nimbold/Firelink/issues).
- Open focused pull requests with tests for behavior changes.
- Report translation corrections in an issue or pull request.
- Read [CHANGELOG.md](CHANGELOG.md) for release history.
- Review [RELEASE.md](RELEASE.md) for packaging policy and release verification.
## Technology & Credits
## Credits
Firelink is made possible by these open-source projects:
Core technologies:
- **[Tauri 2](https://tauri.app/)** for the lightweight desktop runtime
- **[Rust](https://www.rust-lang.org/)** and **[Tokio](https://tokio.rs/)** for native application logic
- **[React](https://react.dev/)** and **[TypeScript](https://www.typescriptlang.org/)** for the interface
- **[Zustand](https://zustand-demo.pmnd.rs/)** for frontend state management
- **[SQLite](https://www.sqlite.org/)** for persistent local data
- **[aria2](https://aria2.github.io/)** for segmented downloading
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**, **[FFmpeg](https://ffmpeg.org/)**, and **[Deno](https://deno.com/)** for media extraction and processing
- [Tauri 2](https://v2.tauri.app/), [Rust](https://www.rust-lang.org/), [Tokio](https://tokio.rs/)
- [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/), [Zustand](https://zustand-demo.pmnd.rs/)
- [SQLite](https://www.sqlite.org/)
Bundled engines:
- [aria2](https://aria2.github.io/)
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [FFmpeg](https://ffmpeg.org/)
- [Deno](https://deno.com/)
Font licenses, engine versions, source references, and third-party notices are documented in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
## License
Firelink is available under the [MIT License](LICENSE).
Firelink is available under the [MIT License](LICENSE). Bundled tools and fonts retain their own licenses.
+69 -12
View File
@@ -4,11 +4,14 @@ Targets:
- macOS arm64 DMG
- Windows x64 NSIS installer
- Windows x64 portable ZIP
- Linux x64 AppImage
- Linux x64 Debian package
- Linux x64 RPM package
## Distribution policy
Firelink does not use an Apple Developer account. macOS releases are unsigned and not notarized. Users must explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must never describe these builds as signed, notarized, or Gatekeeper-approved.
Firelink does not use an Apple Developer account. macOS releases are ad-hoc signed but not notarized or Gatekeeper-approved. Users may still need to explicitly approve the downloaded app through Finder or macOS Privacy & Security. Release copy must not describe these builds as Developer ID signed, notarized, or Gatekeeper-approved.
Windows releases are currently unsigned. SmartScreen may warn until code signing is added.
@@ -19,9 +22,22 @@ Firelink never falls back to system-installed media tools.
- `engines.lock.json` pins current committed macOS payload hashes.
- `engine-sources.lock.json` pins Windows/Linux source archives and checksums.
- `scripts/provision-engines.js` downloads and verifies target archives.
- `scripts/stage-engines.js` creates one target-specific bundle payload.
- `scripts/stage-engines.js` creates one target-specific bundle payload in an
invocation-owned temporary workspace.
- `scripts/verify-binaries.js` runs architecture, packaging, version, and RPC checks.
Aria2 allocation telemetry is a required bundle capability. Windows and Linux
provisioning now builds the checksum-pinned upstream source archive with
`scripts/aria2/firelink.patch`; this patch also retains Firelink's native DNS,
network target policy, and Torrent routing changes. CI installs the compiler
and static-library prerequisites. Windows uses the MSYS2 installation returned
by the setup action (`FIRELINK_MSYS2_ROOT`, default `C:/msys64` for local builds).
The patch checksum is recorded in both source and payload provenance. Never
replace these builds with stock Aria2 archives: package verification requires
`firelinkAllocationTelemetry: true` from `aria2.getVersion`.
Linux `.deb` and `.rpm` packages are built with the complete verified engine payload. The AppImage is bundled separately with the engine resource excluded from the initial Linux packaging pass, then repacked from the verified payload because the AppImage tooling can rewrite bundled native binaries.
yt-dlp must remain its official PyInstaller **onedir** distribution: launcher plus adjacent `_internal` runtime. Onefile builds are rejected because repeated extraction caused roughly 17-second startup latency.
## Version update
@@ -36,8 +52,6 @@ Keep versions aligned:
```bash
npm ci
node scripts/stage-engines.js --target aarch64-apple-darwin
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
npm test -- --run
npm run build
cd src-tauri && cargo test --all-targets
@@ -45,18 +59,62 @@ cd ..
npm run tauri build -- --target aarch64-apple-darwin --bundles dmg
```
Verify packaged resources, then launch outside repository working directory:
`npm run tauri` owns engine staging for `dev`, `build`, and `bundle`. The
wrapper creates a private workspace, verifies the payload, and removes the
workspace after Tauri exits. To stage and verify a payload manually, provide a
private output root explicitly:
```bash
APP="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/Firelink.app"
ENGINE_OUTPUT_ROOT="$(mktemp -d -t firelink-engines)/engine-dist"
FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \
node scripts/stage-engines.js --target aarch64-apple-darwin
FIRELINK_ENGINE_OUTPUT_ROOT="$ENGINE_OUTPUT_ROOT" \
node scripts/verify-binaries.js --staged --target aarch64-apple-darwin
```
Do not use `src-tauri/engine-dist` or another repository-shared directory as
the manual output root. On Windows, set `FIRELINK_ENGINE_OUTPUT_ROOT` to a
private directory under `$env:TEMP` and use the PowerShell form:
```powershell
$env:FIRELINK_ENGINE_OUTPUT_ROOT = Join-Path $env:TEMP "firelink-engines-$PID\engine-dist"
node scripts/stage-engines.js --target x86_64-pc-windows-msvc
node scripts/verify-binaries.js --staged --target x86_64-pc-windows-msvc
```
Verify the DMG and the app it contains, then launch outside the repository
working directory. The DMG bundler removes the intermediate app directory, so
the post-build checks must use the mounted release artifact:
```bash
DMG="$(find src-tauri/target/aarch64-apple-darwin/release/bundle/dmg -name '*.dmg' -print -quit)"
test -n "$DMG"
npm run verify:macos-signing -- --dmg "$DMG"
MOUNT_POINT="$(mktemp -d -t firelink-dmg)"
cleanup() {
hdiutil detach "$MOUNT_POINT" -quiet || hdiutil detach "$MOUNT_POINT" -force -quiet || true
rmdir "$MOUNT_POINT" 2>/dev/null || true
}
trap cleanup EXIT
hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT_POINT" "$DMG" >/dev/null
APP_COUNT="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' | wc -l | tr -d ' ')"
test "$APP_COUNT" -eq 1
APP="$(find "$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink.app' -print -quit)"
node scripts/verify-binaries.js --search-root "$APP" --target aarch64-apple-darwin
node scripts/smoke-packaged-app.js --executable "$APP/Contents/MacOS/firelink"
```
GitHub release publication is intentionally manual. Tag pushes build and upload
artifacts, but the `publish` job only runs from a `workflow_dispatch` on a `v*`
tag when both release-certification inputs are checked after Windows, Linux,
and macOS clean-machine QA.
GitHub release publication follows `.github/workflows/release.yml`. A `v*` tag
push builds, verifies, and publishes the GitHub release after the platform jobs
pass. A `workflow_dispatch` on a `v*` tag also publishes when its
`publish_release` input is enabled. The current workflow has no separate
release-certification inputs; clean-machine QA remains a release-owner gate
before pushing the tag.
For paired releases, publish and verify the Companion release first. The
desktop release workflow requires `Extensions/Browser` to be at a clean commit
whose exact tag matches both the Companion `package.json` and `manifest.json`
versions before building desktop packages.
## Automated release builds
@@ -69,7 +127,6 @@ git push origin v<version>
GitHub Actions builds all targets on native runners, verifies engines inside
final package contents, performs packaged launch smoke where supported, and
uploads artifacts. Publish the GitHub Release with a manual `workflow_dispatch`
run on the same tag after clean-machine QA is complete.
publishes the GitHub Release after the build matrix passes.
No target may silently skip missing engines, failed extraction, checksum mismatch, or missing package output.
+30 -4
View File
@@ -2,7 +2,26 @@
Firelink distributes separate executable tools. Firelink's MIT license does not replace their licenses.
Exact versions, target hashes, sources, and build descriptions are pinned in `engines.lock.json`.
Exact versions, target hashes, sources, and build descriptions are pinned in
`engines.lock.json` for the packaged macOS payload and `engine-sources.lock.json`
for the provisioned Windows and Linux payloads.
## Bundled fonts
Firelink bundles variable WOFF2 web fonts through Fontsource. Each font is
licensed under the SIL Open Font License, Version 1.1. The corresponding
license and author information are available from the linked project sources.
- Inter: <https://github.com/rsms/inter>
- Outfit: <https://github.com/Outfitio/Outfit-Fonts>
- Roboto: <https://github.com/googlefonts/roboto-classic>
- Vazirmatn: <https://github.com/rastikerdar/vazirmatn>
- Noto Sans Hebrew: <https://github.com/notofonts/hebrew>
- Noto Sans SC: <https://github.com/notofonts/noto-cjk>
Fontsource distribution: <https://github.com/fontsource/font-files>
License text: <https://openfontlicense.org/open-font-license-official-text/>
## aria2
@@ -10,9 +29,14 @@ Exact versions, target hashes, sources, and build descriptions are pinned in `en
- Source: <https://github.com/aria2/aria2>
- License: GNU General Public License version 2 or later
Corresponding source for the distributed version is available from the source link and release tag listed in `engines.lock.json`. Firelink release notes must retain that source reference.
Corresponding source for the distributed version is available from the source
link and release tag listed in the applicable engine lock file. Firelink release
notes must retain that source reference.
Linux x64 uses a checksum-pinned musl static build produced from upstream aria2 by <https://github.com/abcfy2/aria2-static-build>. Builder source and upstream tag are recorded in `engine-sources.lock.json`.
Windows and Linux use checksum-pinned upstream aria2 source archives built with
Firelink's reviewed native-DNS, network-target, and allocation-telemetry patch.
The archive, patch checksum, and build provenance are recorded in
`engine-sources.lock.json`.
## FFmpeg
@@ -39,4 +63,6 @@ Firelink uses a self-contained PyInstaller onedir distribution. Embedded Python
Engine payloads may contain OpenSSL, SQLite, c-ares, libssh2, gettext/libintl, zstd, and other runtime libraries. Their copyright and license notices remain part of their source distributions and embedded package metadata.
Release engineering must review each newly added target payload before adding its hashes to `engines.lock.json`. Missing provenance or license data blocks release.
Release engineering must review each newly added target payload before adding its
hashes to the applicable engine lock file. Missing provenance or license data
blocks release.
+44 -26
View File
@@ -3,48 +3,66 @@
"targets": {
"x86_64-pc-windows-msvc": {
"yt-dlp": {
"version": "2026.07.04",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_win.zip",
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
"version": "2026.08.19",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_win.zip",
"sha256": "30b4c14aafab6082becff7881e41b76df46dc43ea7633479410a91e29da492bf"
},
"deno": {
"version": "2.9.2",
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
"version": "2.9.6",
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-pc-windows-msvc.zip",
"sha256": "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-win64-gpl-8.1.zip",
"sha256": "a758e2836a8f33c5f21fc44270cb00392acc6d0085dd0ba14fe14ae75935813d"
"version": "9.0.1-26-g5c8e7e2433",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-win64-gpl-9.0.zip",
"sha256": "dd232ccf8661f837a1faa5f534a1a0bdbdb25c42afe79391e8345154df78f791"
},
"aria2c": {
"version": "1.37.0",
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip",
"sha256": "67d015301eef0b612191212d564c5bb0a14b5b9c4796b76454276a4d28d9b288"
"version": "1.37.0-firelink-native-dns-v1",
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
"buildFromSource": true,
"patch": "scripts/aria2/firelink.patch",
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d",
"allocationTelemetry": true,
"firelinkRouteContract": {
"revision": "firelink-native-dns-v1",
"dnsResolver": "native-async",
"networkTargetPolicy": "firelink-v1",
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
}
}
},
"x86_64-unknown-linux-gnu": {
"yt-dlp": {
"version": "2026.07.04",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_linux.zip",
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
"version": "2026.08.19",
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_linux.zip",
"sha256": "32e72032766bef9199d99d15beb69fd52e46df8f8b06f0d8745db59e04d339e9"
},
"deno": {
"version": "2.9.2",
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
"version": "2.9.6",
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-unknown-linux-gnu.zip",
"sha256": "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2"
},
"ffmpeg": {
"version": "8.1.2-22-g94138f6973",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-06-14-19/ffmpeg-n8.1.2-22-g94138f6973-linux64-gpl-8.1.tar.xz",
"sha256": "df99ffb3803ee56dc68954f43f950ea9f33685a3595a5da8a3e73ef4bef37e3c"
"version": "9.0.1-26-g5c8e7e2433",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-06-13-06/ffmpeg-n9.0.1-26-g5c8e7e2433-linux64-gpl-9.0.tar.xz",
"sha256": "e60c4187c792cc35d2558adbae5582c470713f5afed7212999200340f1394f8d"
},
"aria2c": {
"version": "1.37.0",
"url": "https://github.com/abcfy2/aria2-static-build/releases/download/1.37.0/aria2-x86_64-linux-musl_static.zip",
"sha256": "e0a09b12ef67f35f8a8e4fdddbec851d235b7c31da549d0578bff459032b499a",
"upstreamSource": "https://github.com/aria2/aria2/tree/release-1.37.0",
"builderSource": "https://github.com/abcfy2/aria2-static-build/tree/1.37.0"
"version": "1.37.0-firelink-native-dns-v1",
"url": "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.xz",
"sha256": "60a420ad7085eb616cb6e2bdf0a7206d68ff3d37fb5a956dc44242eb2f79b66b",
"buildFromSource": true,
"patch": "scripts/aria2/firelink.patch",
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d",
"allocationTelemetry": true,
"firelinkRouteContract": {
"revision": "firelink-native-dns-v1",
"dnsResolver": "native-async",
"networkTargetPolicy": "firelink-v1",
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
}
}
}
}
+23 -13
View File
@@ -4,34 +4,44 @@
"aarch64-apple-darwin": {
"engines": {
"yt-dlp": {
"version": "2026.07.04",
"version": "2026.08.19",
"source": "https://github.com/yt-dlp/yt-dlp",
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
"sha256": "4f54eb67e4e96c7c3ffa49dd5deb81bc348bbb495080889b47d157d5c6d74443"
},
"aria2c": {
"version": "1.37.0",
"source": "https://github.com/aria2/aria2",
"build": "arm64 executable with adjacent aria2-libs",
"sha256": "111b2f5ed760f1e1a2ec06117c4e8094fcde336ba16122dda1c5e7209bf1862d"
"version": "1.37.0-firelink-native-dns-v1",
"source": "https://github.com/aria2/aria2/tree/release-1.37.0",
"build": "Firelink native-async DNS, network-target-policy and allocation telemetry patch set; arm64 executable with adjacent aria2-libs",
"firelinkRouteContract": {
"revision": "firelink-native-dns-v1",
"dnsResolver": "native-async",
"networkTargetPolicy": "firelink-v1",
"networkTargetPolicyDigest": "sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705"
},
"sha256": "c8fccb159db7cc23ddf9eab0d3eb4fdfb599b462b21b41074e07201afbba1ca7",
"allocationTelemetry": true,
"patchSha256": "1210eeeb0c82a2fee1ef5d28521569259c18a122d3b439135bba57ee39c5f61d"
},
"ffmpeg": {
"version": "N-125450-gfad2e0bc50",
"version": "9.0.1",
"source": "https://ffmpeg.org/",
"build": "GPLv3 build identified by binary as https://www.martin-riedl.de",
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
"build": "Stable GPLv3 build identified by binary as https://www.martin-riedl.de",
"url": "https://ffmpeg.martin-riedl.de/download/macos/arm64/1787073674_9.0.1/ffmpeg.zip",
"sourceSha256": "8287a1b2229e05eb41859f073e18e6c52c60a778f2f5e6881070fe51b79407fe",
"sha256": "393e4c395020a1cb7cbd77fbe00599ce69d1c6466fee0dbd59d13f86a81a1611"
},
"deno": {
"version": "2.9.2",
"version": "2.9.6",
"source": "https://github.com/denoland/deno",
"build": "official aarch64-apple-darwin executable",
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
"sha256": "b3ac3bd206e48c26026cadd80c1367e96c149f9c66130952382a642b09fa8a71"
}
},
"runtimeTrees": {
"_internal": {
"files": 142,
"sha256": "769507d9b8d97164ef81ebb449873072697bf82acac8c7d61c7bfd96c551e210"
"files": 133,
"sha256": "24ac8c7557dbef0ef9e5685365b4620bc441a168daf314b71218276b782dc449"
},
"aria2-libs": {
"files": 7,
-3
View File
@@ -5,9 +5,6 @@
<link rel="icon" type="image/png" href="/src/assets/app-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Firelink Download Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
+840 -509
View File
File diff suppressed because it is too large Load Diff
+39 -24
View File
@@ -1,7 +1,7 @@
{
"name": "firelink",
"private": true,
"version": "1.0.3",
"version": "1.4.2",
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
"license": "MIT",
"homepage": "https://github.com/nimbold/Firelink",
@@ -24,44 +24,59 @@
"desktop"
],
"engines": {
"node": ">=22"
"node": ">=22.12"
},
"type": "module",
"scripts": {
"dev": "vite",
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"check:i18n": "vitest run src/i18n/resources.test.ts",
"check:updates": "node scripts/check-updates.js",
"smoke:torrent": "node scripts/smoke-torrent.js",
"smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths",
"smoke:aria2:resolver": "node scripts/smoke-aria2-resolver.js",
"smoke:aria2:transfers": "node scripts/smoke-aria2-transfers.js",
"test:torrent:rpc": "cd src-tauri && cargo test --test torrent_rpc -- --nocapture",
"verify:macos-signing": "node scripts/verify-macos-signing.js",
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest"
"tauri": "node scripts/tauri-command.js",
"test": "vitest",
"test:race": "vitest run --repeats 5 src/utils/dockBadge.test.ts src/store/downloadStore.test.ts src/store/useDownloadStore.test.ts"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@tailwindcss/vite": "^4.3.1",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/noto-sans-hebrew": "^5.3.0",
"@fontsource-variable/noto-sans-sc": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@fontsource-variable/roboto": "^5.3.0",
"@fontsource-variable/vazirmatn": "^5.3.0",
"@formkit/auto-animate": "^0.10.0",
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^1.23.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.14"
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
"@tauri-apps/plugin-dialog": "^2.7.3",
"@tauri-apps/plugin-fs": "^2.5.2",
"@tauri-apps/plugin-log": "^2.9.1",
"@tauri-apps/plugin-notification": "^2.4.0",
"@tauri-apps/plugin-opener": "^2.5.5",
"i18next": "^26.4.2",
"lucide-react": "^1.42.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.13",
"zustand": "^5.0.15"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^6.0.3",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.5.5",
"postcss": "^8.5.28",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.1.3",
"vitest": "^4.1.10"
"vite": "^8.2.2",
"vitest": "^5.0.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const repositoryRoot = path.resolve(import.meta.dirname, '..');
const tauriConfig = JSON.parse(
fs.readFileSync(path.join(repositoryRoot, 'src-tauri', 'tauri.conf.json'), 'utf8')
);
const torrentAssociation = tauriConfig.bundle.fileAssociations.find(association =>
association.ext?.some(extension => extension.toLowerCase() === 'torrent')
);
test('declares the native macOS BitTorrent content type', () => {
assert.ok(torrentAssociation, 'the bundle must declare a .torrent association');
assert.equal(torrentAssociation.mimeType, 'application/x-bittorrent');
assert.deepEqual(torrentAssociation.exportedType, {
identifier: 'org.bittorrent.torrent',
conformsTo: ['public.data', 'public.item']
});
assert.equal(
torrentAssociation.contentTypes,
undefined,
'the UTI declaration supplies the extension and MIME tags for clean installs'
);
});
test('declares magnet as a desktop deep-link scheme', () => {
assert.deepEqual(tauriConfig.plugins['deep-link'].desktop.schemes, ['firelink', 'magnet']);
});
const packagedAppPath = process.env.FIRELINK_MACOS_APP;
if (packagedAppPath) {
test('packaged macOS app exports the Torrent UTI and magnet URL scheme', () => {
assert.equal(process.platform, 'darwin', 'packaged macOS association checks require macOS');
const infoPlistPath = path.join(packagedAppPath, 'Contents', 'Info.plist');
assert.ok(fs.existsSync(infoPlistPath), `missing packaged Info.plist: ${infoPlistPath}`);
const plist = JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlistPath], {
encoding: 'utf8'
}));
const urlTypes = Array.isArray(plist.CFBundleURLTypes) ? plist.CFBundleURLTypes : [];
const schemes = urlTypes.flatMap(entry => (
entry && typeof entry === 'object' && Array.isArray(entry.CFBundleURLSchemes)
? entry.CFBundleURLSchemes.filter(scheme => typeof scheme === 'string')
: []
));
assert.ok(schemes.includes('firelink'), 'packaged app must retain the Firelink deep-link scheme');
assert.ok(schemes.includes('magnet'), 'packaged app must export the magnet URL scheme');
const documentTypes = Array.isArray(plist.CFBundleDocumentTypes) ? plist.CFBundleDocumentTypes : [];
const torrentDocument = documentTypes.find(entry =>
entry
&& typeof entry === 'object'
&& Array.isArray(entry.CFBundleTypeExtensions)
&& entry.CFBundleTypeExtensions.some(extension =>
typeof extension === 'string' && extension.toLowerCase() === 'torrent'
)
);
assert.ok(torrentDocument, 'packaged app must claim the .torrent extension');
assert.ok(
Array.isArray(torrentDocument.LSItemContentTypes)
&& torrentDocument.LSItemContentTypes.includes('org.bittorrent.torrent'),
'packaged app must claim the standard BitTorrent UTI'
);
});
}
+138
View File
@@ -0,0 +1,138 @@
export const ARIA2_FIRELINK_REVISION = 'firelink-native-dns-v1';
export const ARIA2_DNS_RESOLVER = 'native-async';
export const ARIA2_NETWORK_TARGET_POLICY = 'firelink-v1';
export const ARIA2_NETWORK_TARGET_POLICY_DIGEST =
'sha256:064503d30f1a043e79113f7e44ddfb517fbf2c578a332896355180743eaf1705';
// The standard Aria2 option is the production default. It keeps hostname
// resolution on the OS/TUN route and is accepted by stock and patched builds.
// There is intentionally no daemon-wide flag here: async-dns=false is stamped
// per transfer after the caller has selected the compatible route.
export const ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS = Object.freeze([]);
export const ARIA2_SYSTEM_RESOLVER_OPTIONS = Object.freeze({
'async-dns': 'false',
});
// Firelink-patched Aria2 exposes these options for the bounded alternate
// magnet-probe attempt. They must never be sent to an unverified binary.
export const ARIA2_ROUTE_DAEMON_ARGS = Object.freeze([
'--async-dns=true',
`--dns-resolver=${ARIA2_DNS_RESOLVER}`,
`--network-target-policy=${ARIA2_NETWORK_TARGET_POLICY}`,
]);
export const ARIA2_ROUTE_OPTIONS = Object.freeze({
'async-dns': 'true',
'dns-resolver': ARIA2_DNS_RESOLVER,
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
});
// Local HTTP servers are used only by engine smoke tests. Product transfers
// never apply the fixture exception; Firelink's own admission policy still
// rejects literal local targets before Aria2 is contacted.
export const ARIA2_LOCAL_FIXTURE_OPTIONS = Object.freeze({
...ARIA2_SYSTEM_RESOLVER_OPTIONS,
});
export function assertAria2Baseline(version) {
if (typeof version?.version !== 'string' || version.version.trim() === '') {
throw new Error(`aria2 returned an invalid baseline version response: ${JSON.stringify(version)}`);
}
}
export function assertAria2RouteCapabilities(version) {
if (!Array.isArray(version?.enabledFeatures)
|| !version.enabledFeatures.includes('Async DNS')) {
throw new Error(`aria2 does not advertise asynchronous DNS: ${JSON.stringify(version)}`);
}
const expected = {
firelinkRevision: ARIA2_FIRELINK_REVISION,
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
};
for (const [field, value] of Object.entries(expected)) {
if (version?.[field] !== value) {
throw new Error(`aria2 route capability mismatch for ${field}: ${JSON.stringify(version)}`);
}
}
if (!Array.isArray(version.firelinkDnsResolvers)
|| !version.firelinkDnsResolvers.includes(ARIA2_DNS_RESOLVER)) {
throw new Error(`aria2 does not advertise the Firelink native resolver: ${JSON.stringify(version)}`);
}
if (!Array.isArray(version.firelinkNetworkTargetPolicies)
|| !version.firelinkNetworkTargetPolicies.includes(ARIA2_NETWORK_TARGET_POLICY)) {
throw new Error(`aria2 does not advertise the Firelink target policy: ${JSON.stringify(version)}`);
}
}
export function hasAria2RouteCapabilities(version) {
try {
assertAria2RouteCapabilities(version);
return true;
} catch {
return false;
}
}
export function assertAria2SystemResolverOptions(options, context = 'aria2 transfer') {
if (options?.['async-dns'] !== 'false') {
throw new Error(`${context} did not retain async-dns=false: ${JSON.stringify(options)}`);
}
if (Object.hasOwn(options || {}, 'dns-resolver')
&& options['dns-resolver'] !== ARIA2_DNS_RESOLVER) {
throw new Error(`${context} retained an unknown resolver mode: ${JSON.stringify(options)}`);
}
if (Object.hasOwn(options || {}, 'network-target-policy')
&& options['network-target-policy'] !== 'none') {
throw new Error(`${context} retained an active target policy: ${JSON.stringify(options)}`);
}
}
export function assertAria2RouteContract(version) {
assertAria2RouteCapabilities(version);
const expected = {
firelinkRevision: ARIA2_FIRELINK_REVISION,
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
};
for (const [field, value] of Object.entries(expected)) {
if (version?.[field] !== value) {
throw new Error(`aria2 route contract mismatch for ${field}: ${JSON.stringify(version)}`);
}
}
if (version.firelinkNetworkTargetPolicyEnforced !== true) {
throw new Error(`aria2 is not enforcing the Firelink network target policy: ${JSON.stringify(version)}`);
}
}
export function assertAria2RouteOptions(options, context = 'aria2 transfer') {
for (const [field, value] of Object.entries(ARIA2_ROUTE_OPTIONS)) {
if (options?.[field] !== value) {
throw new Error(`${context} did not retain ${field}=${value}: ${JSON.stringify(options)}`);
}
}
}
export function assertAria2RouteSource(source, target) {
const contract = source?.firelinkRouteContract;
const expected = {
revision: ARIA2_FIRELINK_REVISION,
dnsResolver: ARIA2_DNS_RESOLVER,
networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
};
for (const [field, value] of Object.entries(expected)) {
if (contract?.[field] !== value) {
throw new Error(
`aria2c source for ${target} is not a Firelink route-contract build; `
+ `expected firelinkRouteContract.${field}=${value}`,
);
}
}
}
export function assertAria2AllocationCapabilities(version) {
if (version?.firelinkAllocationTelemetry !== true) {
throw new Error('Bundled Aria2 does not expose file allocation telemetry');
}
}
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertAria2AllocationCapabilities,
ARIA2_DNS_RESOLVER,
ARIA2_FIRELINK_REVISION,
ARIA2_NETWORK_TARGET_POLICY,
ARIA2_NETWORK_TARGET_POLICY_DIGEST,
assertAria2SystemResolverOptions,
assertAria2RouteCapabilities,
assertAria2RouteContract,
assertAria2RouteSource,
} from './aria2-route-contract.js';
const secureVersion = {
enabledFeatures: ['Async DNS'],
firelinkRevision: ARIA2_FIRELINK_REVISION,
firelinkDnsResolver: ARIA2_DNS_RESOLVER,
firelinkDnsResolvers: [ARIA2_DNS_RESOLVER],
firelinkNetworkTargetPolicies: ['none', ARIA2_NETWORK_TARGET_POLICY],
firelinkNetworkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
firelinkNetworkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
firelinkNetworkTargetPolicyEnforced: true,
};
test('Aria2 source metadata must identify the Firelink route contract', () => {
const source = {
firelinkRouteContract: {
revision: ARIA2_FIRELINK_REVISION,
dnsResolver: ARIA2_DNS_RESOLVER,
networkTargetPolicy: ARIA2_NETWORK_TARGET_POLICY,
networkTargetPolicyDigest: ARIA2_NETWORK_TARGET_POLICY_DIGEST,
},
};
assert.doesNotThrow(() => assertAria2RouteSource(source, 'test-target'));
assert.throws(
() => assertAria2RouteSource({}, 'test-target'),
/not a Firelink route-contract build/,
);
assert.throws(
() => assertAria2RouteSource({
firelinkRouteContract: {
...source.firelinkRouteContract,
networkTargetPolicyDigest: 'sha256:wrong',
},
}, 'test-target'),
/networkTargetPolicyDigest/,
);
});
test('route capabilities are distinct from the active local-fixture policy', () => {
assert.doesNotThrow(() => assertAria2RouteCapabilities({
...secureVersion,
firelinkNetworkTargetPolicy: 'none',
firelinkNetworkTargetPolicyEnforced: false,
}));
assert.doesNotThrow(() => assertAria2RouteContract(secureVersion));
assert.throws(
() => assertAria2RouteContract({
...secureVersion,
firelinkNetworkTargetPolicy: 'none',
firelinkNetworkTargetPolicyEnforced: false,
}),
/route contract mismatch for firelinkNetworkTargetPolicy/,
);
});
test('system resolver options cannot retain the custom target policy', () => {
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
'async-dns': 'false',
}));
assert.doesNotThrow(() => assertAria2SystemResolverOptions({
'async-dns': 'false',
'network-target-policy': 'none',
}));
assert.throws(
() => assertAria2SystemResolverOptions({
'async-dns': 'false',
'network-target-policy': ARIA2_NETWORK_TARGET_POLICY,
}),
/active target policy/,
);
});
test('allocation telemetry is mandatory and must be a JSON boolean capability', () => {
assert.throws(() => assertAria2AllocationCapabilities({ version: '1.37.0' }));
assert.throws(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: 'true' }));
assert.doesNotThrow(() => assertAria2AllocationCapabilities({ firelinkAllocationTelemetry: true }));
});
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
# Build from the checksum-pinned upstream archive plus the reviewed patch.
source_root="$1"
patch_file="$2"
if command -v cygpath >/dev/null 2>&1; then
source_root="$(cygpath -u "$source_root")"
patch_file="$(cygpath -u "$patch_file")"
export PATH="/mingw64/bin:/usr/bin:$PATH"
export ACLOCAL_PATH="/mingw64/share/aclocal:/usr/share/aclocal${ACLOCAL_PATH:+:$ACLOCAL_PATH}"
export PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
fi
copy_mingw_runtime_dependencies() {
local binary="$1"
local runtime_dir="$2"
local dependency
local source
local destination
while IFS= read -r dependency; do
[[ -z "$dependency" ]] && continue
source="/mingw64/bin/$dependency"
if [[ ! -f "$source" ]]; then
continue
fi
destination="$runtime_dir/$dependency"
if [[ -e "$destination" ]]; then
continue
fi
cp "$source" "$destination"
copy_mingw_runtime_dependencies "$destination" "$runtime_dir"
done < <(objdump -p "$binary" | awk '/DLL Name:/{print $3}')
}
cd "$source_root"
patch --batch -p1 < "$patch_file"
autoreconf -fi
mkdir firelink-build
cd firelink-build
# Linux and Windows payloads are self-contained; do not inherit host dylibs.
export LDFLAGS="-static ${LDFLAGS:-}"
export PKG_CONFIG="pkg-config --static"
../configure --enable-static --disable-shared --disable-nls \
--without-gnutls --with-openssl --without-libxml2 --with-libexpat \
--without-libgmp --without-libnettle --without-libgcrypt \
--with-libssh2 --with-libcares
if command -v nproc >/dev/null 2>&1; then
JOBS="$(nproc 2>/dev/null || echo 4)"
elif [[ -n "${NUMBER_OF_PROCESSORS:-}" ]]; then
JOBS="$NUMBER_OF_PROCESSORS"
elif command -v sysctl >/dev/null 2>&1; then
JOBS="$(sysctl -n hw.ncpu 2>/dev/null || echo 4)"
else
JOBS=4
fi
JOBS="${JOBS//$'\r'/}"
JOBS="${JOBS// /}"
if ! [[ "$JOBS" =~ ^[1-9][0-9]*$ ]]; then
JOBS=4
fi
make -j"$JOBS"
if command -v cygpath >/dev/null 2>&1; then
command -v objdump >/dev/null 2>&1 || {
echo "MinGW objdump is required to collect Aria2 runtime dependencies." >&2
exit 1
}
runtime_dir="$source_root/aria2-libs"
mkdir -p "$runtime_dir"
copy_mingw_runtime_dependencies "$source_root/firelink-build/src/aria2c.exe" "$runtime_dir"
if [[ -d /mingw64/lib/ossl-modules ]]; then
find /mingw64/lib/ossl-modules -maxdepth 1 -type f -iname '*.dll' -exec cp {} "$runtime_dir/" \;
fi
fi
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import fs from 'node:fs';
const sourceArgument = process.argv.indexOf('--source');
const sourceLocation = sourceArgument >= 0
? process.argv[sourceArgument + 1]
: 'https://raw.githubusercontent.com/aria2/aria2/release-1.37.0/doc/manual-src/en/aria2c.rst';
if (!sourceLocation) throw new Error('--source requires a path or URL');
const source = /^https:\/\//.test(sourceLocation)
? await fetch(sourceLocation, { signal: AbortSignal.timeout(15000) }).then(response => {
if (!response.ok) throw new Error(`Aria2 manual fetch failed with HTTP ${response.status}`);
return response.text();
})
: fs.readFileSync(sourceLocation, 'utf8');
function section(start, end) {
const startIndex = source.indexOf(`${start}\n`);
const endIndex = source.indexOf(`${end}\n`, startIndex + start.length);
if (startIndex < 0 || endIndex < 0) throw new Error(`Could not find manual section ${start} -> ${end}`);
return source.slice(startIndex, endIndex);
}
function options(text) {
const names = [...text.matchAll(/^\.\. option:: .*?(--[a-z0-9-]+)/gm)]
.map(match => match[1].slice(2));
return [...new Set(names)].sort();
}
const normal = options(section('HTTP/FTP/SFTP Options', 'BitTorrent/Metalink Options'));
const torrent = options(
`${section('BitTorrent/Metalink Options', 'BitTorrent Specific Options')}\n${section('BitTorrent Specific Options', 'Metalink Specific Options')}`,
);
if (torrent.length !== 46) {
throw new Error(`Expected 46 unique Aria2 1.37.0 Torrent options, found ${torrent.length}`);
}
console.log(JSON.stringify({
source: sourceLocation,
aria2Version: '1.37.0',
normal: { count: normal.length, options: normal },
torrent: { count: torrent.length, options: torrent },
}, null, 2));
+9 -8
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -41,14 +41,15 @@ function runNpmScript(script) {
run('npm', ['run', script]);
}
// A packaged Tauri artifact must have its own consent identity. A source or
// commit fingerprint cannot distinguish two fresh signed/package builds made
// from the same checkout, which would let an updated binary silently reuse the
// previous binary's credential-store approval. Keep any configured identity
// as useful provenance, but always add the artifact nonce.
const configuredBuildId = process.env.VITE_BUILD_ID?.trim();
process.env.VITE_BUILD_ID = `${configuredBuildId || 'artifact'}-${randomUUID()}`;
run(process.execPath, ['scripts/stage-engines.js']);
run(process.execPath, ['scripts/verify-binaries.js', '--staged']);
if (process.env.FIRELINK_OMIT_ENGINE_DIST_FOR_TAURI_BUNDLE === '1') {
const engineDist = path.join(repoRoot, 'src-tauri', 'engine-dist');
fs.rmSync(engineDist, { recursive: true, force: true });
fs.mkdirSync(engineDist, { recursive: true });
console.log('Omitted engine-dist from the initial Tauri bundle; release packaging will repack verified engines.');
}
runNpmScript('build');
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { resolveOutputRoot, resolveTargetTriple } from './engine-workspace.js';
if (
process.env.FIRELINK_SKIP_ENGINE_RESOURCE === '1'
|| process.env.FIRELINK_ENGINE_BUNDLE_PREPARED === '1'
) {
process.exit(0);
}
// Staging belongs to beforeBuildCommand or the standalone-bundle wrapper.
// This hook is a read-only fence against a missing payload immediately before
// Tauri consumes the resource tree.
const target = resolveTargetTriple();
const outputRoot = resolveOutputRoot();
const suffix = target.includes('windows') ? '.exe' : '';
const destination = path.join(outputRoot, target);
const expectedNames = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno']
.map(engine => `${engine}-${target}${suffix}`);
for (const name of expectedNames) {
const candidate = path.join(destination, name);
if (!fs.existsSync(candidate) || !fs.lstatSync(candidate).isFile()) {
throw new Error(`Prepared engine payload is incomplete: ${candidate}`);
}
}
console.log(`Prepared engine payload is present for ${target} at ${destination}`);
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
let activeChild;
let receivedSignal;
export const APPIMAGE_CONFIG = JSON.stringify({
bundle: {
resources: {
// Tauri merges --config values using JSON Merge Patch. An omitted key
// would leave the base engine resource enabled; null explicitly removes
// it for this first packaging pass. The verified payload is added back
// by repack-linux-appimage-engines.js after linuxdeploy finishes.
'engine-dist/': null,
},
},
});
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function signalExitCode(signal) {
return {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143,
}[signal] ?? 1;
}
function npmInvocation(args) {
if (process.env.npm_execpath) {
return [process.execPath, [process.env.npm_execpath, ...args]];
}
if (process.platform === 'win32') {
return ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]];
}
return ['npm', args];
}
export function appImageBundleArguments(target) {
return [
'run',
'tauri',
'--',
'bundle',
'-vv',
'--target',
target,
'--bundles',
'appimage',
'--config',
APPIMAGE_CONFIG,
];
}
function run(command, args, options = {}) {
const child = spawn(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
windowsHide: true,
detached: process.platform !== 'win32',
});
activeChild = child;
return new Promise((resolve, reject) => {
let settled = false;
child.once('error', error => {
if (activeChild === child) activeChild = undefined;
if (!settled) {
settled = true;
reject(error);
}
});
child.once('close', (code, signal) => {
if (activeChild === child) activeChild = undefined;
if (!settled) {
settled = true;
resolve({ code, signal });
}
});
});
}
async function runChecked(command, args, label = command, options = {}) {
let result;
try {
result = await run(command, args, options);
} catch (error) {
throw new Error(`Failed to run ${label}: ${error.message}`, { cause: error });
}
if (result.signal) {
const error = new Error(`${label} was terminated by ${result.signal}.`);
error.exitCode = signalExitCode(result.signal);
throw error;
}
if (result.code !== 0) {
const error = new Error(`${label} exited with status ${result.code}.`);
error.exitCode = result.code ?? 1;
throw error;
}
return result;
}
function assertSafeTarget(target) {
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) {
throw new Error(`Invalid target triple: ${target}`);
}
}
async function main() {
const target = argValue('--target') || process.env.FIRELINK_TARGET_TRIPLE;
if (!target) {
throw new Error('Pass --target <triple>.');
}
assertSafeTarget(target);
// The native-package build has already staged and verified the engines.
// Verify the immutable provisioned payload once more before creating the
// AppImage so a failed preparation cannot produce an artifact that later
// appears valid only because its payload is absent.
const provisionedRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
await runChecked(
process.execPath,
['scripts/verify-binaries.js', '--root', provisionedRoot, '--target', target],
'provisioned engine verification'
);
if (receivedSignal) {
const error = new Error(`Build interrupted by ${receivedSignal}.`);
error.exitCode = signalExitCode(receivedSignal);
throw error;
}
const [npmCommand, npmArgs] = npmInvocation(appImageBundleArguments(target));
await runChecked(npmCommand, npmArgs, 'Tauri AppImage bundling', {
env: { FIRELINK_SKIP_ENGINE_RESOURCE: '1' },
});
if (receivedSignal) {
const error = new Error(`Build interrupted by ${receivedSignal}.`);
error.exitCode = signalExitCode(receivedSignal);
throw error;
}
console.log(`Built the Linux AppImage from the existing ${target} release binary.`);
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const handleSignal = signal => {
receivedSignal ??= signal;
// npm and Tauri can have Rust/packaging descendants. On POSIX, the child
// is a detached process-group leader, so signal the whole group instead of
// leaving descendants running after the wrapper exits.
if (activeChild && activeChild.exitCode === null && activeChild.signalCode === null) {
try {
if (process.platform === 'win32' || !activeChild.pid) {
activeChild.kill(signal);
} else {
process.kill(-activeChild.pid, signal);
}
} catch (error) {
if (error.code !== 'ESRCH') {
console.error(`[WARN] Could not terminate the AppImage build process group: ${error.message}`);
}
}
} else {
process.exitCode = signalExitCode(signal);
}
};
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
process.once(signal, () => handleSignal(signal));
}
main()
.catch(error => {
console.error(`[FAIL] ${error.message}`);
process.exitCode = error.exitCode ?? 1;
})
.finally(() => {
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
process.removeAllListeners(signal);
}
});
}
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { APPIMAGE_CONFIG, appImageBundleArguments } from './build-linux-appimage.js';
test('AppImage config explicitly removes the staged engine payload', () => {
assert.deepEqual(JSON.parse(APPIMAGE_CONFIG), {
bundle: {
resources: {
'engine-dist/': null,
},
},
});
});
test('AppImage bundling uses the existing binary instead of tauri build', () => {
const args = appImageBundleArguments('x86_64-unknown-linux-gnu');
assert.equal(args[3], 'bundle');
assert.equal(args.includes('build'), false);
assert.deepEqual(args.slice(-2), ['--config', APPIMAGE_CONFIG]);
});
+454 -73
View File
@@ -1,12 +1,22 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const userAgent = 'firelink-update-check';
const fetchRetryDelaysMs = [250, 1_000];
const fetchTimeoutMs = 30_000;
const cargoOutputLimit = 64 * 1024 * 1024;
const retryableHttpStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
function httpResponseError(response, url) {
const status = [response.status, response.statusText].filter(Boolean).join(' ');
return new Error(`${status}: ${url}`);
}
function parseJsonFile(file) {
return JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8'));
@@ -32,71 +42,287 @@ function compareVersions(left, right) {
return 0;
}
function parseSha256Digest(value) {
const match = /^sha256:([0-9a-f]{64})$/i.exec(String(value || ''));
return match?.[1].toLowerCase();
}
function releaseAssetHashes(release) {
return Object.fromEntries(
(release?.assets || [])
.map(asset => {
const digest = parseSha256Digest(asset.digest);
return digest && typeof asset.browser_download_url === 'string'
? [asset.browser_download_url, digest]
: undefined;
})
.filter(Boolean),
);
}
function providerAssetHashes({ ytDlp, deno, aria2 }) {
return {
...releaseAssetHashes(ytDlp),
...releaseAssetHashes(deno),
...releaseAssetHashes(aria2),
};
}
function npmExecutable(platform = process.platform) {
return platform === 'win32' ? 'npm.cmd' : 'npm';
}
function npmOutdated(cwd) {
if (!fs.existsSync(path.join(cwd, 'package.json'))) {
throw new Error(`npm workspace is missing package.json: ${cwd}`);
}
try {
execFileSync('npm', ['outdated', '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
execFileSync(npmExecutable(), ['outdated', '--json'], {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return {};
} catch (error) {
if (error.status !== 1) {
const details = error.stderr?.toString().trim();
throw new Error(details || `npm outdated failed in ${cwd}`);
}
const output = error.stdout?.toString() || '{}';
return JSON.parse(output || '{}');
}
}
async function fetchJson(url) {
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.json();
return fetchWithContext(url, response => response.json());
}
async function fetchText(url) {
const response = await fetch(url, { headers: { 'User-Agent': userAgent } });
if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${url}`);
return response.text();
return fetchWithContext(url, response => response.text());
}
async function fetchWithContext(url, readResponse) {
let lastError;
for (let attempt = 0; attempt <= fetchRetryDelaysMs.length; attempt += 1) {
let response;
try {
response = await fetch(url, {
headers: { 'User-Agent': userAgent },
signal: AbortSignal.timeout(fetchTimeoutMs),
});
} catch (error) {
lastError = error;
if (attempt === fetchRetryDelaysMs.length) break;
await new Promise(resolve => setTimeout(resolve, fetchRetryDelaysMs[attempt]));
continue;
}
if (!response.ok) {
const error = httpResponseError(response, url);
if (!retryableHttpStatuses.has(response.status) || attempt === fetchRetryDelaysMs.length) {
await response.body?.cancel();
throw error;
}
await response.body?.cancel();
lastError = error;
await new Promise(resolve => setTimeout(resolve, fetchRetryDelaysMs[attempt]));
continue;
}
try {
return await readResponse(response);
} catch (error) {
lastError = error;
if (attempt === fetchRetryDelaysMs.length) break;
await new Promise(resolve => setTimeout(resolve, fetchRetryDelaysMs[attempt]));
}
}
const detail = lastError instanceof Error ? lastError.message : String(lastError);
throw new Error(`fetch failed for ${url}: ${detail}`, { cause: lastError });
}
async function githubLatest(repo) {
return fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
const release = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`);
if (!release || Array.isArray(release) || typeof release.tag_name !== 'string' || !release.tag_name.trim()) {
throw new Error(`GitHub latest release response for ${repo} has no usable tag_name`);
}
return release;
}
async function latestFfmpegStable() {
const html = await fetchText('https://ffmpeg.org/releases/');
const versions = [...html.matchAll(/ffmpeg-(\d+\.\d+(?:\.\d+)?)\.tar\.xz/g)].map(match => match[1]);
return [...new Set(versions)].sort(compareVersions).at(-1);
const latest = [...new Set(versions)].sort(compareVersions).at(-1);
if (!latest) throw new Error('FFmpeg release page contained no usable stable release version');
return latest;
}
async function latestMartinRiedlMacArm64Release() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const releaseSection = html.split('Download Release Build')[1] || '';
const match =
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([0-9.]+)/) ||
releaseSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([0-9.]+)/);
return match?.[1];
const card = releaseSection.match(/<h3>macOS \(Apple Silicon\/arm64\)<\/h3>[\s\S]*?<\/div>/)?.[0] || '';
const version =
card.match(/<b>Release:\s*<\/b>\s*([0-9.]+)/)?.[1] ||
card.match(/Release:\s*([0-9.]+)/)?.[1];
const relativeUrl = card.match(/href="([^"]+\/ffmpeg\.zip)"/)?.[1];
if (!version || !relativeUrl) return undefined;
const url = new URL(relativeUrl, 'https://ffmpeg.martin-riedl.de').href;
const checksum = await fetchText(`${url}.sha256`);
const sha256 = checksum.match(/\b([0-9a-f]{64})\b/i)?.[1]?.toLowerCase();
return sha256 ? { version, url, sha256 } : undefined;
}
async function latestMartinRiedlMacArm64Snapshot() {
const html = await fetchText('https://ffmpeg.martin-riedl.de/');
const snapshotSection = html.split('Download Snapshot Build')[1]?.split('Download Release Build')[0] || '';
const match =
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?<b>Release:\s*<\/b>\s*([A-Za-z0-9.-]+)/) ||
snapshotSection.match(/macOS \(Apple Silicon\/arm64\)[\s\S]*?Release:\s*([A-Za-z0-9.-]+)/);
return match?.[1];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function latestBtbnFfmpegN81Build() {
async function latestBtbnFfmpegStableBuild(stableVersion) {
if (!/^\d+\.\d+(?:\.\d+)?$/.test(stableVersion)) {
throw new Error(`unsupported FFmpeg stable version: ${stableVersion}`);
}
const stableSeries = stableVersion.split('.').slice(0, 2).join('.');
const versionPattern = escapeRegExp(stableVersion);
const seriesPattern = escapeRegExp(stableSeries);
const assetPattern = new RegExp(
`^ffmpeg-n(${versionPattern}(?:-\\d+-g[0-9a-f]+)?)-(win64|linux64)-gpl-${seriesPattern}\\.(?:zip|tar\\.xz)$`
);
const releases = await fetchJson('https://api.github.com/repos/BtbN/FFmpeg-Builds/releases?per_page=10');
if (!Array.isArray(releases)) throw new Error('BtbN releases response is not an array');
for (const release of releases) {
if (release.tag_name === 'latest') continue;
const versions = (release.assets || [])
.map(asset => asset.name.match(/^ffmpeg-n(8\.1\.\d+-\d+-g[0-9a-f]+)-(?:win64-gpl-8\.1\.zip|linux64-gpl-8\.1\.tar\.xz)$/)?.[1])
const assets = (release.assets || [])
.map(asset => {
const match = asset.name.match(assetPattern);
if (!match) return undefined;
return {
target: match[2] === 'win64' ? 'windows' : 'linux',
version: match[1],
url: asset.browser_download_url,
sha256: parseSha256Digest(asset.digest),
};
})
.filter(Boolean);
const unique = [...new Set(versions)];
if (unique.length === 1 && versions.length >= 2) {
return unique[0];
const unique = [...new Set(assets.map(asset => asset.version))];
const byTarget = Object.fromEntries(assets.map(asset => [asset.target, asset]));
if (
unique.length === 1 &&
byTarget.windows?.sha256 &&
byTarget.linux?.sha256
) {
return {
version: unique[0],
urls: { windows: byTarget.windows.url, linux: byTarget.linux.url },
hashes: { windows: byTarget.windows.sha256, linux: byTarget.linux.sha256 },
};
}
}
return undefined;
}
function cargoPackages(metadata) {
if (!metadata || !Array.isArray(metadata.packages)) {
throw new Error('Cargo metadata contained no package list');
}
return metadata.packages.map(pkg => ({
name: pkg.name,
version: pkg.version,
source: pkg.source || 'path',
}));
}
function diffCargoMetadata(currentMetadata, updatedMetadata) {
const current = cargoPackages(currentMetadata);
const updated = cargoPackages(updatedMetadata);
const groups = new Map();
for (const [side, packages] of [['current', current], ['updated', updated]]) {
for (const pkg of packages) {
const key = `${pkg.name}\0${pkg.source}`;
const group = groups.get(key) || { name: pkg.name, source: pkg.source, current: [], updated: [] };
group[side].push(pkg.version);
groups.set(key, group);
}
}
const changes = [];
for (const group of groups.values()) {
const oldVersions = [...group.current];
const newVersions = [...group.updated];
for (let index = oldVersions.length - 1; index >= 0; index -= 1) {
const unchangedIndex = newVersions.indexOf(oldVersions[index]);
if (unchangedIndex >= 0) {
oldVersions.splice(index, 1);
newVersions.splice(unchangedIndex, 1);
}
}
oldVersions.sort(compareVersions);
newVersions.sort(compareVersions);
for (let index = 0; index < Math.min(oldVersions.length, newVersions.length); index += 1) {
changes.push({
name: group.name,
version: oldVersions[index],
latest: newVersions[index],
source: group.source,
});
}
for (const version of oldVersions.slice(newVersions.length)) {
changes.push({
name: group.name,
version,
latest: null,
source: group.source,
});
}
for (const latest of newVersions.slice(oldVersions.length)) {
changes.push({
name: group.name,
version: null,
latest,
source: group.source,
});
}
}
return changes.sort((left, right) => left.name.localeCompare(right.name));
}
function cargoMetadata(manifestPath) {
return JSON.parse(execFileSync('cargo', [
'metadata', '--format-version', '1', '--locked', '--manifest-path', manifestPath,
], { encoding: 'utf8', maxBuffer: cargoOutputLimit, stdio: ['ignore', 'pipe', 'pipe'] }));
}
function cargoCompatibleUpdates() {
const sourceDir = path.join(repoRoot, 'src-tauri');
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-cargo-update-'));
try {
fs.copyFileSync(path.join(sourceDir, 'Cargo.toml'), path.join(temporaryRoot, 'Cargo.toml'));
fs.copyFileSync(path.join(sourceDir, 'Cargo.lock'), path.join(temporaryRoot, 'Cargo.lock'));
fs.mkdirSync(path.join(temporaryRoot, 'src'));
fs.writeFileSync(path.join(temporaryRoot, 'src', 'lib.rs'), '');
const manifestPath = path.join(temporaryRoot, 'Cargo.toml');
const current = cargoMetadata(path.join(sourceDir, 'Cargo.toml'));
execFileSync('cargo', ['update', '--manifest-path', manifestPath], {
encoding: 'utf8',
maxBuffer: cargoOutputLimit,
stdio: ['ignore', 'pipe', 'pipe'],
});
return diffCargoMetadata(current, cargoMetadata(manifestPath));
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
function printCargoReport(updates) {
if (!updates.length) {
console.log('Rust Cargo: current');
return 0;
}
console.log(`Rust Cargo: ${updates.length} compatible locked package update(s)`);
for (const update of updates) {
console.log(` ${update.name}: ${update.version ?? '(absent)'} -> ${update.latest ?? '(removed)'}`);
}
return updates.length;
}
function printNpmReport(label, outdated) {
const entries = Object.entries(outdated);
if (!entries.length) {
@@ -114,7 +340,14 @@ function sourceEngineVersions(sourceLock) {
const rows = [];
for (const [target, engines] of Object.entries(sourceLock.targets || {})) {
for (const [engine, meta] of Object.entries(engines)) {
rows.push({ target, engine, version: meta.version, url: meta.url });
rows.push({
target,
engine,
version: meta.version,
url: meta.url,
sha256: meta.sha256,
sourceSha256: meta.sourceSha256,
});
}
}
return rows;
@@ -124,22 +357,66 @@ function packagedEngineVersions(engineLock) {
const rows = [];
for (const [target, targetLock] of Object.entries(engineLock.targets || {})) {
for (const [engine, meta] of Object.entries(targetLock.engines || {})) {
rows.push({ target, engine, version: meta.version });
rows.push({
target,
engine,
version: meta.version,
url: meta.url,
sha256: meta.sha256,
sourceSha256: meta.sourceSha256,
});
}
}
return rows;
}
function checkRows(rows, latestByEngine, latestByTargetEngine = {}) {
function unavailableLatestVersionError(target, engine, targetSpecific) {
const error = new Error(
`${targetSpecific ? 'Latest provider version' : 'Latest version'} is unavailable for ${target} ${engine}`
);
error.code = 'LATEST_VERSION_UNAVAILABLE';
return error;
}
function checkRows(
rows,
latestByEngine,
latestByTargetEngine = {},
latestUrlsByTargetEngine = {},
targetSpecificEngines = new Set(),
latestHashesByTargetEngine = {},
latestHashesByUrl = {},
) {
let outdated = 0;
for (const row of rows) {
const latest = latestByTargetEngine[`${row.target}:${row.engine}`] || latestByEngine[row.engine];
if (!latest) continue;
const targetSpecific = targetSpecificEngines.has(row.engine);
const targetKey = `${row.target}:${row.engine}`;
const latest = targetSpecific
? latestByTargetEngine[targetKey]
: latestByTargetEngine[targetKey] || latestByEngine[row.engine];
if (typeof latest !== 'string' || !latest.trim()) {
throw unavailableLatestVersionError(row.target, row.engine, targetSpecific);
}
const current = normalizeVersion(row.version);
const wanted = normalizeVersion(latest);
const status = compareVersions(current, wanted) < 0 ? 'outdated' : 'current';
if (status === 'outdated') outdated += 1;
const latestUrl = latestUrlsByTargetEngine[targetKey];
const versionOutdated = compareVersions(current, wanted) < 0;
const sourceOutdated = Boolean(latestUrl && row.url && row.url !== latestUrl);
const latestHash = latestHashesByTargetEngine[targetKey] || latestHashesByUrl[row.url];
const checkedHash = row.sourceSha256 || row.sha256;
const currentHash = typeof checkedHash === 'string' ? checkedHash.toLowerCase() : '';
const hashOutdated = Boolean(latestHash && currentHash !== latestHash);
const status = versionOutdated
? 'outdated'
: sourceOutdated
? 'source-outdated'
: hashOutdated
? 'hash-outdated'
: 'current';
if (status !== 'current') outdated += 1;
console.log(` ${row.target} ${row.engine}: ${current} -> ${wanted} ${status}`);
if (sourceOutdated) console.log(` source: ${row.url} -> ${latestUrl}`);
if (hashOutdated) console.log(` source sha256: ${checkedHash || 'missing'} -> ${latestHash}`);
}
return outdated;
}
@@ -149,69 +426,173 @@ async function main() {
outdatedCount += printNpmReport('root npm', npmOutdated(repoRoot));
outdatedCount += printNpmReport(
'Firefox extension npm',
npmOutdated(path.join(repoRoot, 'Extensions', 'Firefox'))
'Browser extension npm',
npmOutdated(path.join(repoRoot, 'Extensions', 'Browser'))
);
outdatedCount += printCargoReport(cargoCompatibleUpdates());
const [
ytDlp,
deno,
aria2,
ffmpeg,
martinRiedlMacArm64Ffmpeg,
martinRiedlMacArm64Snapshot,
btbnFfmpegN81Build,
] = await Promise.all([
githubLatest('yt-dlp/yt-dlp'),
githubLatest('denoland/deno'),
githubLatest('aria2/aria2'),
latestFfmpegStable(),
latestMartinRiedlMacArm64Release(),
latestMartinRiedlMacArm64Snapshot(),
latestBtbnFfmpegN81Build(),
]);
const ffmpegStablePromise = latestFfmpegStable();
const providerChecks = [
['yt-dlp latest release', () => githubLatest('yt-dlp/yt-dlp')],
['Deno latest release', () => githubLatest('denoland/deno')],
['aria2 latest release', () => githubLatest('aria2/aria2')],
[
'FFmpeg stable release',
async () => {
const version = await ffmpegStablePromise;
if (!version) throw new Error('FFmpeg release provider response has no usable version');
return version;
},
],
[
'Martin Riedl macOS release',
async () => {
const build = await latestMartinRiedlMacArm64Release();
const stableVersion = await ffmpegStablePromise;
if (
!build?.version ||
!build.url ||
!build.sha256 ||
compareVersions(build.version, stableVersion) !== 0
) {
throw new Error('Martin Riedl FFmpeg provider response has no complete matching macOS arm64 stable build');
}
return build;
},
],
[
'BtbN FFmpeg Windows/Linux build',
async () => {
const build = await latestBtbnFfmpegStableBuild(await ffmpegStablePromise);
if (
!build?.version ||
!build.urls?.windows ||
!build.urls?.linux ||
!build.hashes?.windows ||
!build.hashes?.linux
) {
throw new Error('BtbN FFmpeg provider response has no complete Windows/Linux build with SHA-256 digests');
}
return build;
},
],
];
const providerResults = await Promise.allSettled(providerChecks.map(([, check]) => check()));
const providerFailures = [];
for (const [index, [label]] of providerChecks.entries()) {
const result = providerResults[index];
if (result.status === 'rejected') {
const detail = result.reason instanceof Error ? result.reason.message : String(result.reason);
providerFailures.push(label);
console.error(`provider unavailable: ${label}: ${detail}`);
}
}
const providerValue = index =>
providerResults[index].status === 'fulfilled' ? providerResults[index].value : undefined;
const ytDlp = providerValue(0);
const deno = providerValue(1);
const aria2 = providerValue(2);
const ffmpeg = providerValue(3);
const martinRiedlMacArm64Release = providerValue(4);
const btbnFfmpegStableBuild = providerValue(5);
const latestByEngine = {
'yt-dlp': ytDlp.tag_name,
deno: deno.tag_name,
aria2c: aria2.tag_name,
'yt-dlp': ytDlp?.tag_name,
deno: deno?.tag_name,
aria2c: aria2?.tag_name,
ffmpeg,
};
const latestByTargetEngine = {
'x86_64-pc-windows-msvc:ffmpeg': btbnFfmpegN81Build || ffmpeg,
'x86_64-unknown-linux-gnu:ffmpeg': btbnFfmpegN81Build || ffmpeg,
'aarch64-apple-darwin:ffmpeg': martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg,
};
const latestByTargetEngine = {};
const latestUrlsByTargetEngine = {};
const latestHashesByTargetEngine = {};
const latestHashesByUrl = providerAssetHashes({ ytDlp, deno, aria2 });
if (btbnFfmpegStableBuild?.version && btbnFfmpegStableBuild.urls?.windows && btbnFfmpegStableBuild.urls?.linux) {
latestByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.version;
latestByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.version;
latestUrlsByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.urls.windows;
latestUrlsByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.urls.linux;
latestHashesByTargetEngine['x86_64-pc-windows-msvc:ffmpeg'] = btbnFfmpegStableBuild.hashes?.windows;
latestHashesByTargetEngine['x86_64-unknown-linux-gnu:ffmpeg'] = btbnFfmpegStableBuild.hashes?.linux;
}
if (martinRiedlMacArm64Release?.version && martinRiedlMacArm64Release.url) {
latestByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.version;
latestUrlsByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.url;
latestHashesByTargetEngine['aarch64-apple-darwin:ffmpeg'] = martinRiedlMacArm64Release.sha256;
}
const displayVersion = value => (value ? normalizeVersion(value) : 'unavailable');
console.log('\nlatest engines:');
for (const [engine, version] of Object.entries(latestByEngine)) {
console.log(` ${engine}: ${normalizeVersion(version)}`);
console.log(` ${engine}: ${displayVersion(version)}`);
}
console.log('\nlatest engine provider builds:');
console.log(` BtbN FFmpeg n8.1 Windows/Linux: ${normalizeVersion(btbnFfmpegN81Build || ffmpeg)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 snapshot: ${normalizeVersion(martinRiedlMacArm64Snapshot || martinRiedlMacArm64Ffmpeg)}`);
console.log(` BtbN FFmpeg stable Windows/Linux: ${displayVersion(btbnFfmpegStableBuild?.version)}`);
console.log(` Martin Riedl FFmpeg macOS arm64 stable: ${displayVersion(martinRiedlMacArm64Release?.version)}`);
const targetSpecificEngines = new Set(['ffmpeg']);
const engineCheckFailures = [];
const runEngineCheck = (label, rows) => {
try {
return checkRows(
rows,
latestByEngine,
latestByTargetEngine,
latestUrlsByTargetEngine,
targetSpecificEngines,
latestHashesByTargetEngine,
latestHashesByUrl,
);
} catch (error) {
if (error?.code !== 'LATEST_VERSION_UNAVAILABLE') throw error;
const detail = error instanceof Error ? error.message : String(error);
engineCheckFailures.push(label);
console.error(`engine provider unavailable: ${label}: ${detail}`);
return 0;
}
};
console.log('\nengine source lock:');
outdatedCount += checkRows(
outdatedCount += runEngineCheck(
'engine source lock',
sourceEngineVersions(parseJsonFile('engine-sources.lock.json')),
latestByEngine,
latestByTargetEngine
);
console.log('\npackaged engine lock:');
outdatedCount += checkRows(
outdatedCount += runEngineCheck(
'packaged engine lock',
packagedEngineVersions(parseJsonFile('engines.lock.json')),
latestByEngine,
latestByTargetEngine
);
if (outdatedCount > 0) {
console.error(`\n${outdatedCount} outdated item(s) found.`);
process.exit(1);
}
if (providerFailures.length > 0) {
console.error(`\n${providerFailures.length} provider check(s) unavailable; refusing to claim that all updates are current.`);
}
if (engineCheckFailures.length > 0) {
console.error(`\n${engineCheckFailures.length} engine lock check(s) unavailable; refusing to claim that all engines are current.`);
}
if (providerFailures.length > 0 || engineCheckFailures.length > 0) {
process.exit(1);
}
console.log('\nAll checked packages and engines are current.');
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
export {
checkRows,
diffCargoMetadata,
fetchJson,
fetchText,
fetchWithContext,
latestBtbnFfmpegStableBuild,
latestMartinRiedlMacArm64Release,
npmExecutable,
providerAssetHashes,
};
+296
View File
@@ -0,0 +1,296 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
checkRows,
diffCargoMetadata,
fetchJson,
fetchText,
latestBtbnFfmpegStableBuild,
latestMartinRiedlMacArm64Release,
npmExecutable,
providerAssetHashes,
} from './check-updates.js';
async function withMockFetch(mockFetch, callback) {
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
return await callback();
} finally {
globalThis.fetch = originalFetch;
}
}
test('fetchJson retries transient HTTP responses before succeeding', async () => {
let attempts = 0;
const result = await withMockFetch(async () => {
attempts += 1;
if (attempts < 3) return new Response('temporarily unavailable', { status: 503 });
return new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}, () => fetchJson('https://example.test/releases'));
assert.deepEqual(result, { status: 'ok' });
assert.equal(attempts, 3);
});
test('fetchText does not retry terminal HTTP responses', async () => {
let attempts = 0;
await assert.rejects(
() =>
withMockFetch(async () => {
attempts += 1;
return new Response('not found', { status: 404 });
}, () => fetchText('https://example.test/missing')),
/404: https:\/\/example\.test\/missing/
);
assert.equal(attempts, 1);
});
test('checkRows fails closed when a latest version is unavailable', () => {
assert.throws(
() =>
checkRows(
[{ target: 'test-target', engine: 'test-engine', version: '1.0.0', url: 'https://example.test/engine' }],
{}
),
/Latest version is unavailable for test-target test-engine/
);
});
test('checkRows does not fall back to the generic release for target-specific engines', () => {
assert.throws(
() =>
checkRows(
[{ target: 'test-target', engine: 'ffmpeg', version: '8.1.2', url: 'https://example.test/engine' }],
{ ffmpeg: '9.0.1' },
{},
{},
new Set(['ffmpeg'])
),
/Latest provider version is unavailable for test-target ffmpeg/
);
});
test('checkRows detects a provider hash change when version and URL are current', () => {
const outdated = checkRows(
[{
target: 'test-target',
engine: 'test-engine',
version: '1.0.0',
url: 'https://example.test/engine',
sha256: 'a'.repeat(64),
}],
{ 'test-engine': '1.0.0' },
{},
{},
new Set(),
{},
{ 'https://example.test/engine': 'b'.repeat(64) },
);
assert.equal(outdated, 1);
});
test('checkRows compares packaged source provenance without confusing the payload digest', () => {
const outdated = checkRows(
[{
target: 'aarch64-apple-darwin',
engine: 'ffmpeg',
version: '9.0.1',
url: 'https://example.test/ffmpeg.zip',
sourceSha256: 'a'.repeat(64),
sha256: 'b'.repeat(64),
}],
{ ffmpeg: '9.0.1' },
{ 'aarch64-apple-darwin:ffmpeg': '9.0.1' },
{ 'aarch64-apple-darwin:ffmpeg': 'https://example.test/ffmpeg.zip' },
new Set(['ffmpeg']),
{ 'aarch64-apple-darwin:ffmpeg': 'a'.repeat(64) },
);
assert.equal(outdated, 0);
});
test('checkRows detects an aria2 asset digest change when the provider supplies it', () => {
const url = 'https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip';
const digest = 'b'.repeat(64);
const hashes = providerAssetHashes({
aria2: { assets: [{ browser_download_url: url, digest: `sha256:${digest}` }] },
});
const outdated = checkRows(
[{
target: 'x86_64-pc-windows-msvc',
engine: 'aria2c',
version: '1.37.0',
url,
sha256: 'a'.repeat(64),
}],
{ aria2c: '1.37.0' },
{},
{},
new Set(),
{},
hashes,
);
assert.equal(outdated, 1);
});
test('npm executable selection uses the Windows command shim when needed', () => {
assert.equal(npmExecutable('win32'), 'npm.cmd');
assert.equal(npmExecutable('darwin'), 'npm');
assert.equal(npmExecutable('linux'), 'npm');
});
test('selects a complete BtbN build for the current stable series', async () => {
const digest = value => `sha256:${value.repeat(64)}`;
const release = {
tag_name: 'autobuild-test',
assets: [
{
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
browser_download_url: 'https://example.test/windows.zip',
digest: digest('a'),
},
{
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-linux64-gpl-9.0.tar.xz',
browser_download_url: 'https://example.test/linux.tar.xz',
digest: digest('b'),
},
{
name: 'ffmpeg-n8.1.2-50-g1a748fe2cd-win64-gpl-8.1.zip',
browser_download_url: 'https://example.test/old.zip',
digest: digest('c'),
},
],
};
const result = await withMockFetch(
async () => new Response(JSON.stringify([release]), { status: 200 }),
() => latestBtbnFfmpegStableBuild('9.0.1'),
);
assert.equal(result.version, '9.0.1-11-ge47273f4d9');
assert.equal(result.urls.windows, 'https://example.test/windows.zip');
assert.equal(result.hashes.linux, 'b'.repeat(64));
});
test('selects a complete BtbN build for a two-part stable version and tag-exact assets', async () => {
const digest = value => `sha256:${value.repeat(64)}`;
const release = {
tag_name: 'autobuild-test',
assets: [
{
name: 'ffmpeg-n9.0-win64-gpl-9.0.zip',
browser_download_url: 'https://example.test/windows-exact.zip',
digest: digest('e'),
},
{
name: 'ffmpeg-n9.0-linux64-gpl-9.0.tar.xz',
browser_download_url: 'https://example.test/linux-exact.tar.xz',
digest: digest('f'),
},
],
};
const result = await withMockFetch(
async () => new Response(JSON.stringify([release]), { status: 200 }),
() => latestBtbnFfmpegStableBuild('9.0'),
);
assert.equal(result.version, '9.0');
assert.equal(result.urls.windows, 'https://example.test/windows-exact.zip');
assert.equal(result.hashes.linux, 'f'.repeat(64));
});
test('rejects an incomplete BtbN stable target tuple', async () => {
const release = {
tag_name: 'autobuild-test',
assets: [{
name: 'ffmpeg-n9.0.1-11-ge47273f4d9-win64-gpl-9.0.zip',
browser_download_url: 'https://example.test/windows.zip',
digest: `sha256:${'a'.repeat(64)}`,
}],
};
const result = await withMockFetch(
async () => new Response(JSON.stringify([release]), { status: 200 }),
() => latestBtbnFfmpegStableBuild('9.0.1'),
);
assert.equal(result, undefined);
});
test('requires a complete Martin Riedl stable artifact and digest', async () => {
const html = `
<h2>Download Release Build</h2>
<div><h3>macOS (Apple Silicon/arm64)</h3>
<p><b>Release: </b>9.0.1</p>
<a href="/download/macos/arm64/build/ffmpeg.zip">FFmpeg (ZIP)</a></div>`;
const result = await withMockFetch(
async url => new Response(
String(url).endsWith('.sha256') ? `${'d'.repeat(64)} ffmpeg.zip\n` : html,
{ status: 200 },
),
() => latestMartinRiedlMacArm64Release(),
);
assert.deepEqual(result, {
version: '9.0.1',
url: 'https://ffmpeg.martin-riedl.de/download/macos/arm64/build/ffmpeg.zip',
sha256: 'd'.repeat(64),
});
});
test('reports compatible Cargo resolution drift from structured metadata', () => {
const metadata = versions => ({
packages: Object.entries(versions).map(([name, version]) => ({
name,
version,
source: 'registry+https://github.com/rust-lang/crates.io-index',
})),
});
assert.deepEqual(
diffCargoMetadata(metadata({ indexmap: '2.14.1', serde: '1.0.229' }), metadata({
indexmap: '2.14.2',
serde: '1.0.229',
})),
[{
name: 'indexmap',
version: '2.14.1',
latest: '2.14.2',
source: 'registry+https://github.com/rust-lang/crates.io-index',
}],
);
});
test('reports packages added to or removed from the resolved Cargo graph', () => {
const metadata = packages => ({
packages: packages.map(([name, version]) => ({
name,
version,
source: 'registry+https://github.com/rust-lang/crates.io-index',
})),
});
assert.deepEqual(
diffCargoMetadata(
metadata([['removed-crate', '1.0.0'], ['stable-crate', '1.0.0']]),
metadata([['added-crate', '2.0.0'], ['stable-crate', '1.0.0']]),
),
[
{
name: 'added-crate',
version: null,
latest: '2.0.0',
source: 'registry+https://github.com/rust-lang/crates.io-index',
},
{
name: 'removed-crate',
version: '1.0.0',
latest: null,
source: 'registry+https://github.com/rust-lang/crates.io-index',
},
],
);
});
+159
View File
@@ -0,0 +1,159 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js';
export function getAria2BuildScriptSha256(repoRoot) {
const buildScriptPath = path.join(repoRoot, 'scripts/aria2/build.sh');
return crypto.createHash('sha256')
.update(fs.readFileSync(buildScriptPath, 'utf8').replaceAll('\r\n', '\n'))
.digest('hex');
}
export function validateAria2Cache({
aria2CacheRoot,
target,
aria2Source,
buildScriptSha256,
toolchainFingerprint = null,
executableSuffix = '',
}) {
const manifestPath = path.join(aria2CacheRoot, 'aria2-build-manifest.json');
if (!fs.existsSync(manifestPath)) {
return { valid: false, reason: 'manifest-not-found' };
}
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (
manifest.schemaVersion !== 1
|| manifest.target !== target
|| manifest.sourceSha256 !== aria2Source.sha256
|| manifest.patchSha256 !== aria2Source.patchSha256
|| manifest.buildScriptSha256 !== buildScriptSha256
) {
return { valid: false, reason: 'manifest-metadata-mismatch' };
}
if (
toolchainFingerprint !== null
&& manifest.toolchainFingerprint !== toolchainFingerprint
) {
return { valid: false, reason: 'toolchain-fingerprint-mismatch' };
}
const exeName = `aria2c-${target}${executableSuffix}`;
const cachedExe = path.join(aria2CacheRoot, exeName);
if (!fs.existsSync(cachedExe) || sha256(cachedExe) !== manifest.files?.[exeName]) {
return { valid: false, reason: 'executable-mismatch' };
}
if (manifest.files) {
for (const [rel, expectedSha] of Object.entries(manifest.files)) {
if (rel === exeName) continue;
const libFile = path.join(aria2CacheRoot, rel);
if (!fs.existsSync(libFile) || sha256(libFile) !== expectedSha) {
return { valid: false, reason: 'library-mismatch' };
}
}
}
const actualFiles = collectRegularFiles(aria2CacheRoot, {
ignoredNames: ['aria2-build-manifest.json'],
}).map(f => path.relative(aria2CacheRoot, f).split(path.sep).join('/'));
const expectedFiles = Object.keys(manifest.files || {}).sort();
actualFiles.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
return { valid: false, reason: 'file-list-mismatch' };
}
return { valid: true, manifest };
} catch {
return { valid: false, reason: 'manifest-corrupted' };
}
}
export function restoreAria2Cache({
aria2CacheRoot,
payloadDestination,
target,
executableSuffix = '',
isWindows = false,
}) {
const exeName = `aria2c-${target}${executableSuffix}`;
const cachedExe = path.join(aria2CacheRoot, exeName);
const targetExe = path.join(payloadDestination, exeName);
fs.copyFileSync(cachedExe, targetExe);
if (!isWindows) fs.chmodSync(targetExe, 0o755);
const cachedLibs = path.join(aria2CacheRoot, 'aria2-libs');
if (fs.existsSync(cachedLibs)) {
fs.cpSync(cachedLibs, path.join(payloadDestination, 'aria2-libs'), {
recursive: true,
preserveTimestamps: true,
});
}
}
export async function saveAria2Cache({
aria2CacheRoot,
payloadDestination,
target,
aria2Source,
buildScriptSha256,
toolchainFingerprint = null,
executableSuffix = '',
aria2Runtime = null,
isWindows = false,
}) {
const cacheParent = path.dirname(aria2CacheRoot);
fs.mkdirSync(cacheParent, { recursive: true });
const stagingDir = fs.mkdtempSync(
path.join(cacheParent, `.${path.basename(aria2CacheRoot)}-staging-${process.pid}-`)
);
try {
const targetExeName = `aria2c-${target}${executableSuffix}`;
const cachedExeDest = path.join(stagingDir, targetExeName);
fs.copyFileSync(path.join(payloadDestination, targetExeName), cachedExeDest);
if (!isWindows) fs.chmodSync(cachedExeDest, 0o755);
const manifestFiles = {
[targetExeName]: sha256(cachedExeDest),
};
if (aria2Runtime && fs.existsSync(aria2Runtime)) {
const cachedLibsDest = path.join(stagingDir, 'aria2-libs');
fs.cpSync(aria2Runtime, cachedLibsDest, { recursive: true, preserveTimestamps: true });
const libFiles = collectRegularFiles(cachedLibsDest);
for (const lib of libFiles) {
const rel = path.relative(stagingDir, lib).split(path.sep).join('/');
manifestFiles[rel] = sha256(lib);
}
}
const cacheManifest = {
schemaVersion: 1,
target,
sourceSha256: aria2Source.sha256,
patchSha256: aria2Source.patchSha256,
buildScriptSha256,
toolchainFingerprint: toolchainFingerprint || null,
files: manifestFiles,
};
fs.writeFileSync(
path.join(stagingDir, 'aria2-build-manifest.json'),
`${JSON.stringify(cacheManifest, null, 2)}\n`
);
await promoteDirectory(stagingDir, aria2CacheRoot);
} catch (error) {
try {
await removePathWithRetry(stagingDir);
} catch {}
throw error;
}
}
+198
View File
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
getAria2BuildScriptSha256,
restoreAria2Cache,
saveAria2Cache,
validateAria2Cache,
} from './engine-aria2-cache.js';
import { sha256 } from './engine-payload-integrity.js';
const TARGET = 'x86_64-pc-windows-msvc';
const ARIA2_SOURCE = {
version: '1.37.0-firelink-native-dns-v1',
url: 'https://example.invalid/aria2.tar.xz',
sha256: 'a'.repeat(64),
buildFromSource: true,
patch: 'scripts/aria2/firelink.patch',
patchSha256: 'b'.repeat(64),
allocationTelemetry: true,
};
function createTestWorkspace() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-cache-test-'));
const payloadDir = path.join(root, 'payload');
const cacheRoot = path.join(root, 'cache', TARGET);
fs.mkdirSync(payloadDir, { recursive: true });
const exeName = `aria2c-${TARGET}.exe`;
const exePath = path.join(payloadDir, exeName);
fs.writeFileSync(exePath, 'binary-content-for-testing');
const libsDir = path.join(payloadDir, 'aria2-libs');
fs.mkdirSync(libsDir, { recursive: true });
fs.writeFileSync(path.join(libsDir, 'test.dll'), 'dll-content');
return { root, payloadDir, cacheRoot, exeName, exePath, libsDir };
}
test('validateAria2Cache fails closed when cache manifest is missing or invalid', () => {
const { root, cacheRoot } = createTestWorkspace();
try {
const missing = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: 'c'.repeat(64),
executableSuffix: '.exe',
});
assert.equal(missing.valid, false);
assert.equal(missing.reason, 'manifest-not-found');
fs.mkdirSync(cacheRoot, { recursive: true });
fs.writeFileSync(path.join(cacheRoot, 'aria2-build-manifest.json'), 'not json');
const corrupt = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: 'c'.repeat(64),
executableSuffix: '.exe',
});
assert.equal(corrupt.valid, false);
assert.equal(corrupt.reason, 'manifest-corrupted');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('saveAria2Cache, validateAria2Cache, and restoreAria2Cache work end-to-end', async () => {
const { root, payloadDir, cacheRoot, libsDir } = createTestWorkspace();
try {
const buildScriptSha = 'c'.repeat(64);
const toolchainFingerprint = 'toolchain-v1';
await saveAria2Cache({
aria2CacheRoot: cacheRoot,
payloadDestination: payloadDir,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
toolchainFingerprint,
executableSuffix: '.exe',
aria2Runtime: libsDir,
isWindows: true,
});
const valid = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
toolchainFingerprint,
executableSuffix: '.exe',
});
assert.equal(valid.valid, true);
const wrongFingerprint = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
toolchainFingerprint: 'toolchain-v2',
executableSuffix: '.exe',
});
assert.equal(wrongFingerprint.valid, false);
assert.equal(wrongFingerprint.reason, 'toolchain-fingerprint-mismatch');
const restoreDir = path.join(root, 'restored');
fs.mkdirSync(restoreDir, { recursive: true });
restoreAria2Cache({
aria2CacheRoot: cacheRoot,
payloadDestination: restoreDir,
target: TARGET,
executableSuffix: '.exe',
isWindows: true,
});
assert.equal(
sha256(path.join(restoreDir, `aria2c-${TARGET}.exe`)),
sha256(path.join(payloadDir, `aria2c-${TARGET}.exe`))
);
assert.equal(
sha256(path.join(restoreDir, 'aria2-libs', 'test.dll')),
sha256(path.join(payloadDir, 'aria2-libs', 'test.dll'))
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('validateAria2Cache rejects tampered files or rogue untracked files', async () => {
const { root, payloadDir, cacheRoot, libsDir } = createTestWorkspace();
try {
const buildScriptSha = 'c'.repeat(64);
await saveAria2Cache({
aria2CacheRoot: cacheRoot,
payloadDestination: payloadDir,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
executableSuffix: '.exe',
aria2Runtime: libsDir,
isWindows: true,
});
// Tamper with cached exe
const exePath = path.join(cacheRoot, `aria2c-${TARGET}.exe`);
fs.appendFileSync(exePath, 'tampered');
const tamperedExe = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
executableSuffix: '.exe',
});
assert.equal(tamperedExe.valid, false);
assert.equal(tamperedExe.reason, 'executable-mismatch');
// Restore exe, tamper with library
fs.writeFileSync(exePath, 'binary-content-for-testing');
const libPath = path.join(cacheRoot, 'aria2-libs', 'test.dll');
fs.appendFileSync(libPath, 'tampered');
const tamperedLib = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
executableSuffix: '.exe',
});
assert.equal(tamperedLib.valid, false);
assert.equal(tamperedLib.reason, 'library-mismatch');
// Restore library, add rogue file
fs.writeFileSync(libPath, 'dll-content');
fs.writeFileSync(path.join(cacheRoot, 'rogue.txt'), 'rogue');
const rogueFile = validateAria2Cache({
aria2CacheRoot: cacheRoot,
target: TARGET,
aria2Source: ARIA2_SOURCE,
buildScriptSha256: buildScriptSha,
executableSuffix: '.exe',
});
assert.equal(rogueFile.valid, false);
assert.equal(rogueFile.reason, 'file-list-mismatch');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('getAria2BuildScriptSha256 reads and hashes build.sh with normalized line endings', () => {
const repoRoot = path.resolve(import.meta.dirname, '..');
const hash = getAria2BuildScriptSha256(repoRoot);
assert.equal(typeof hash, 'string');
assert.equal(hash.length, 64);
});
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const repositoryRoot = path.resolve(import.meta.dirname, '..');
const ciWorkflow = fs.readFileSync(
path.join(repositoryRoot, '.github', 'workflows', 'ci.yml'),
'utf8',
);
const releaseWorkflow = fs.readFileSync(
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
'utf8',
);
const cacheActionSha = '1bd1e32a3bdc45362d1e726936510720a7c30a57';
function assertSafeEngineCacheWorkflow(workflow) {
assert.match(workflow, new RegExp(`actions/cache/restore@${cacheActionSha}`));
assert.match(workflow, /key: firelink-engine-payload-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.fingerprint \}\}/);
assert.match(workflow, /engine-sources\.lock\.json/);
assert.match(workflow, /scripts\/aria2\/\*\*/);
assert.match(workflow, /scripts\/engine-\*\.js/);
assert.match(workflow, /scripts\/verify-binaries\.js/);
assert.doesNotMatch(workflow, /restore-keys:/);
const restore = workflow.indexOf('actions/cache/restore@');
const validation = workflow.indexOf('id: engine-cache-validation');
const provision = workflow.indexOf('node scripts/provision-engines.js');
assert.ok(restore >= 0 && restore < validation && validation < provision);
assert.match(workflow, /continue-on-error: true/);
assert.match(workflow, /FIRELINK_TARGET_TRIPLE: \$\{\{ matrix\.target \}\}/);
}
test('CI and release restore only exact, validated engine payload caches', () => {
assertSafeEngineCacheWorkflow(ciWorkflow);
assertSafeEngineCacheWorkflow(releaseWorkflow);
});
test('only trusted main pushes save the shared engine cache', () => {
assert.match(ciWorkflow, new RegExp(`actions/cache/save@${cacheActionSha}`));
const save = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified engine payload cache'));
assert.match(save, /github\.event_name == 'push'/);
assert.match(save, /github\.ref == 'refs\/heads\/main'/);
assert.doesNotMatch(releaseWorkflow, /actions\/cache\/save@/);
});
test('CI and release use granular Aria2 build caching and safe timeouts', () => {
assert.match(ciWorkflow, /timeout-minutes: (?:4[5-9]|[5-9][0-9])/);
assert.match(ciWorkflow, /uses: Swatinem\/rust-cache@v2/);
assert.match(ciWorkflow, /key: firelink-aria2-build-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.aria2-fingerprint \}\}/);
assert.match(releaseWorkflow, /key: firelink-aria2-build-v1-\$\{\{ matrix\.target \}\}-\$\{\{ steps\.engine-toolchain\.outputs\.aria2-fingerprint \}\}/);
const saveAria2 = ciWorkflow.slice(ciWorkflow.indexOf('- name: Save verified Aria2 build cache'));
assert.match(saveAria2, /github\.event_name == 'push'/);
assert.match(saveAria2, /github\.ref == 'refs\/heads\/main'/);
});
+243
View File
@@ -0,0 +1,243 @@
import fs from 'node:fs';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { sha256 } from './engine-payload-integrity.js';
const DEFAULT_ATTEMPTS = 3;
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
const DEFAULT_RETRY_DELAYS_MS = [2_000, 5_000];
const FILE_RESET_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
function parseContentRange(value) {
const match = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(value || '');
if (!match) return undefined;
const start = Number(match[1]);
const end = Number(match[2]);
const total = match[3] === '*' ? undefined : Number(match[3]);
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end < start) {
return undefined;
}
if (total !== undefined && (!Number.isSafeInteger(total) || end >= total)) {
return undefined;
}
return { start, end, total };
}
function responseLength(response) {
const value = response.headers.get('content-length');
if (!value || !/^\d+$/.test(value)) return undefined;
const length = Number(value);
return Number.isSafeInteger(length) ? length : undefined;
}
function createDownloadTimeout(idleTimeoutMs) {
const controller = new AbortController();
let timer;
const refresh = () => {
clearTimeout(timer);
timer = setTimeout(() => {
controller.abort(new Error(`Download idle for ${idleTimeoutMs}ms`));
}, idleTimeoutMs);
};
const dispose = () => clearTimeout(timer);
refresh();
return { signal: controller.signal, refresh, dispose };
}
function abortReason(signal) {
if (signal?.reason instanceof Error) return signal.reason;
return new Error('Engine archive download aborted');
}
function throwIfAborted(signal) {
if (signal?.aborted) throw abortReason(signal);
}
function combineAbortSignals(signals) {
const activeSignals = signals.filter(Boolean);
const controller = new AbortController();
const listeners = [];
const abort = signal => {
if (!controller.signal.aborted) controller.abort(abortReason(signal));
};
for (const signal of activeSignals) {
const listener = () => abort(signal);
listeners.push([signal, listener]);
if (signal.aborted) abort(signal);
else signal.addEventListener('abort', listener, { once: true });
}
return {
signal: controller.signal,
dispose() {
for (const [signal, listener] of listeners) {
signal.removeEventListener('abort', listener);
}
},
};
}
function archiveSize(archive) {
try {
return fs.statSync(archive).size;
} catch (error) {
if (error?.code === 'ENOENT') return 0;
throw error;
}
}
function checksumMismatchError(name, expected, actual) {
const error = new Error(`Archive checksum mismatch for ${name}. Expected ${expected}, got ${actual}`);
error.code = 'ARCHIVE_CHECKSUM_MISMATCH';
return error;
}
function sleep(milliseconds, signal) {
if (!signal) return new Promise(resolve => setTimeout(resolve, milliseconds));
return new Promise((resolve, reject) => {
let timer;
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
};
const finish = () => {
cleanup();
resolve();
};
const onAbort = () => {
cleanup();
reject(abortReason(signal));
};
timer = setTimeout(finish, milliseconds);
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
});
}
async function resetArchive(archive) {
for (let attempt = 0; ; attempt += 1) {
try {
fs.rmSync(archive, { force: true });
return;
} catch (error) {
const retryable = process.platform === 'win32'
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
if (!retryable || attempt >= FILE_RESET_RETRY_DELAYS_MS.length) throw error;
await sleep(FILE_RESET_RETRY_DELAYS_MS[attempt]);
}
}
}
/**
* Download and checksum an engine archive, resuming an interrupted response
* when the provider honors HTTP range requests. A provider that ignores the
* range is handled safely by replacing the partial file instead of appending
* a second full archive to it.
*/
export async function downloadEngineArchive({
name,
url,
archive,
expectedSha256,
attempts = DEFAULT_ATTEMPTS,
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
retryDelaysMs = DEFAULT_RETRY_DELAYS_MS,
signal,
}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
throwIfAborted(signal);
const partialSize = archiveSize(archive);
if (partialSize > 0 && sha256(archive) === expectedSha256) return archive;
const downloadTimeout = createDownloadTimeout(idleTimeoutMs);
const requestSignal = combineAbortSignals([signal, downloadTimeout.signal]);
let resetForRetry = false;
try {
const response = await fetch(url, {
headers: partialSize > 0 ? { Range: `bytes=${partialSize}-` } : undefined,
redirect: 'follow',
signal: requestSignal.signal,
});
if (response.status === 416 && partialSize > 0) {
await response.body?.cancel();
resetForRetry = true;
throw new Error(`Retained partial archive range is not satisfiable for ${name}`);
}
if (!response.ok || !response.body) {
await response.body?.cancel();
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
const contentRange = response.status === 206
? parseContentRange(response.headers.get('content-range'))
: undefined;
if (response.status === 206 && (!contentRange || contentRange.start !== partialSize)) {
await response.body.cancel();
throw new Error(`Invalid Content-Range while downloading ${name}`);
}
const append = response.status === 206 && partialSize > 0;
const expectedResponseLength = responseLength(response);
if (!append && partialSize > 0) {
// The provider ignored Range and returned the complete archive.
await resetArchive(archive);
}
await pipeline(
Readable.fromWeb(response.body),
new Transform({
transform(chunk, encoding, callback) {
downloadTimeout.refresh();
callback(null, chunk, encoding);
},
}),
fs.createWriteStream(archive, { flags: append ? 'a' : 'w' }),
{ signal: requestSignal.signal },
);
throwIfAborted(signal);
const finalSize = archiveSize(archive);
const expectedFinalSize = contentRange?.total
?? (expectedResponseLength === undefined
? undefined
: (append ? partialSize + expectedResponseLength : expectedResponseLength));
if (expectedFinalSize !== undefined && finalSize !== expectedFinalSize) {
throw new Error(
`Incomplete archive for ${name}: expected ${expectedFinalSize} bytes, got ${finalSize}`,
);
}
const actual = sha256(archive);
if (actual === expectedSha256) return archive;
resetForRetry = true;
throw checksumMismatchError(name, expectedSha256, actual);
} catch (error) {
if (signal?.aborted) throw abortReason(signal);
lastError = error;
if (resetForRetry || error?.code === 'ARCHIVE_CHECKSUM_MISMATCH') {
await resetArchive(archive);
}
if (attempt === attempts) {
throw new Error(
`Failed to download ${name} after ${attempts} attempts: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error },
);
}
await sleep(retryDelaysMs[attempt - 1] ?? 0, signal);
} finally {
requestSignal.dispose();
downloadTimeout.dispose();
}
}
throw lastError;
}
+256
View File
@@ -0,0 +1,256 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { downloadEngineArchive } from './engine-download.js';
async function withMockFetch(mockFetch, callback) {
const originalFetch = globalThis.fetch;
globalThis.fetch = mockFetch;
try {
return await callback();
} finally {
globalThis.fetch = originalFetch;
}
}
function makeBody(chunks, failure) {
return new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(Buffer.from(chunk));
if (failure) controller.error(failure);
else controller.close();
},
});
}
function makeArchivePath() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-download-'));
return {
directory,
archive: path.join(directory, 'engine.zip'),
};
}
function digest(value) {
return createHash('sha256').update(value).digest('hex');
}
test('resumes an interrupted archive response from the retained partial file', async () => {
const { directory, archive } = makeArchivePath();
const full = Buffer.from('complete archive payload');
const prefix = full.subarray(0, 8);
const middle = full.subarray(8, 15);
const suffix = full.subarray(15);
fs.writeFileSync(archive, Buffer.concat([prefix, middle]));
const ranges = [];
let calls = 0;
try {
await withMockFetch(async (_url, options) => {
calls += 1;
ranges.push(options.headers?.Range);
if (calls === 1) {
return new Response(makeBody([], new Error('connection reset')), {
status: 206,
headers: {
'Content-Length': String(suffix.length),
'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`,
},
});
}
return new Response(makeBody([suffix]), {
status: 206,
headers: {
'Content-Length': String(suffix.length),
'Content-Range': `bytes ${prefix.length + middle.length}-${full.length - 1}/${full.length}`,
},
});
}, async () => {
await downloadEngineArchive({
name: 'test',
url: 'https://example.test/engine.zip',
archive,
expectedSha256: digest(full),
attempts: 2,
retryDelaysMs: [0],
});
});
assert.deepEqual(
ranges,
[`bytes=${prefix.length + middle.length}-`, `bytes=${prefix.length + middle.length}-`],
);
assert.deepEqual(fs.readFileSync(archive), full);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('replaces a partial archive when the provider ignores the Range request', async () => {
const { directory, archive } = makeArchivePath();
const full = Buffer.from('complete archive after range fallback');
fs.writeFileSync(archive, Buffer.from('stale partial bytes'));
let requestedRange;
try {
await withMockFetch(async (_url, options) => {
requestedRange = options.headers?.Range;
return new Response(full, {
status: 200,
headers: { 'Content-Length': String(full.length) },
});
}, async () => {
await downloadEngineArchive({
name: 'test',
url: 'https://example.test/engine.zip',
archive,
expectedSha256: digest(full),
attempts: 1,
});
});
assert.equal(requestedRange, 'bytes=19-');
assert.deepEqual(fs.readFileSync(archive), full);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('accepts a complete retained archive without issuing an unsatisfiable range', async () => {
const { directory, archive } = makeArchivePath();
const full = Buffer.from('complete archive retained after a late connection reset');
fs.writeFileSync(archive, full);
let calls = 0;
try {
await withMockFetch(async () => {
calls += 1;
throw new Error('fetch should not be called for a complete retained archive');
}, async () => {
await downloadEngineArchive({
name: 'test',
url: 'https://example.test/engine.zip',
archive,
expectedSha256: digest(full),
attempts: 1,
});
});
assert.equal(calls, 0);
assert.deepEqual(fs.readFileSync(archive), full);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('restarts from zero after an unsatisfiable retained range', async () => {
const { directory, archive } = makeArchivePath();
const full = Buffer.from('complete archive after a stale range response');
fs.writeFileSync(archive, Buffer.from('stale partial bytes'));
const ranges = [];
let calls = 0;
try {
await withMockFetch(async (_url, options) => {
calls += 1;
ranges.push(options.headers?.Range);
if (calls === 1) return new Response(null, { status: 416 });
return new Response(full, {
status: 200,
headers: { 'Content-Length': String(full.length) },
});
}, async () => {
await downloadEngineArchive({
name: 'test',
url: 'https://example.test/engine.zip',
archive,
expectedSha256: digest(full),
attempts: 2,
retryDelaysMs: [0],
});
});
assert.deepEqual(ranges, ['bytes=19-', undefined]);
assert.deepEqual(fs.readFileSync(archive), full);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('propagates external cancellation without retrying an in-flight archive', async () => {
const { directory, archive } = makeArchivePath();
const abortController = new AbortController();
let calls = 0;
try {
await withMockFetch(async (_url, options) => {
calls += 1;
assert.equal(options.signal.aborted, false);
setTimeout(() => {
abortController.abort(new Error('provisioning interrupted'));
}, 10);
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(Buffer.from('partial archive bytes'));
},
}), {
status: 200,
headers: { 'Content-Length': '100' },
});
}, async () => {
await assert.rejects(
downloadEngineArchive({
name: 'test',
url: 'https://example.test/engine.zip',
archive,
expectedSha256: digest(Buffer.from('never completed')),
attempts: 3,
retryDelaysMs: [500, 500],
signal: abortController.signal,
}),
error => {
assert.match(error.message, /provisioning interrupted/);
return true;
},
);
});
assert.equal(calls, 1);
assert.ok(fs.statSync(archive).size > 0);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
test('rejects and removes an archive with a mismatched checksum', async () => {
const { directory, archive } = makeArchivePath();
const corrupt = Buffer.from('corrupt engine archive');
try {
await withMockFetch(
async () => new Response(corrupt, {
status: 200,
headers: { 'Content-Length': String(corrupt.length) },
}),
async () => {
await assert.rejects(
downloadEngineArchive({
name: 'ffmpeg',
url: 'https://example.test/ffmpeg.zip',
archive,
expectedSha256: digest(Buffer.from('trusted engine archive')),
attempts: 1,
}),
/Archive checksum mismatch for ffmpeg/,
);
},
);
assert.equal(fs.existsSync(archive), false);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
+118
View File
@@ -0,0 +1,118 @@
import fs from 'node:fs';
import path from 'node:path';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
function canonicalize(value) {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map(key => [key, canonicalize(value[key])]),
);
}
return value;
}
export function buildPayloadProvenance(targetSources) {
if (!targetSources || typeof targetSources !== 'object') {
throw new Error('Engine source lock is missing the target provenance.');
}
return Object.fromEntries(
Object.entries(targetSources).map(([name, source]) => [
name,
{
version: source.version,
url: source.url || source.sourceUrl,
sha256: source.sha256 || source.sourceSha256,
...(source.buildFromSource === true
? {
patchSha256: source.patchSha256,
allocationTelemetry: source.allocationTelemetry === true,
}
: {}),
...(name === 'aria2c' && source.firelinkRouteContract
? { firelinkRouteContract: source.firelinkRouteContract }
: {}),
},
]),
);
}
export function assertPayloadManifestProvenance(manifest, targetSources, target) {
if (manifest?.schemaVersion !== 1) {
throw new Error(`Unsupported engine payload manifest schema for ${target}.`);
}
if (manifest.target !== target) {
throw new Error(`Engine payload manifest target mismatch for ${target}.`);
}
const expected = buildPayloadProvenance(targetSources);
if (JSON.stringify(canonicalize(manifest.generatedFrom))
!== JSON.stringify(canonicalize(expected))) {
throw new Error(`Engine payload manifest provenance mismatch for ${target}.`);
}
}
function resolveManifestFile(root, relative) {
if (typeof relative !== 'string' || relative.length === 0) {
throw new Error('Engine payload manifest contains an invalid file path.');
}
const resolvedRoot = path.resolve(root);
const candidate = path.resolve(resolvedRoot, relative);
const relativeToRoot = path.relative(resolvedRoot, candidate);
if (
relativeToRoot === '..'
|| relativeToRoot.startsWith(`..${path.sep}`)
|| path.isAbsolute(relativeToRoot)
) {
throw new Error(`Engine payload manifest escapes its root: ${relative}.`);
}
return candidate;
}
export function readAndValidatePayloadManifest(root, targetSources, target) {
const manifestPath = path.join(root, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error(`Engine payload manifest is missing for ${target}.`);
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
throw new Error(`Engine payload manifest is invalid for ${target}: ${error.message}`);
}
assertPayloadManifestProvenance(manifest, targetSources, target);
if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
throw new Error(`Engine payload manifest files are invalid for ${target}.`);
}
const expectedFiles = Object.keys(manifest.files).sort();
const resolvedFiles = new Map(
expectedFiles.map(relative => [relative, resolveManifestFile(root, relative)]),
);
const actualFiles = collectRegularFiles(root, {
ignoredNames: ['payload-manifest.json'],
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
throw new Error(`Engine payload files do not match the manifest for ${target}.`);
}
for (const relative of expectedFiles) {
const expected = manifest.files[relative];
if (!/^[a-f0-9]{64}$/.test(expected)) {
throw new Error(`Engine payload manifest checksum is invalid: ${relative}.`);
}
const file = resolvedFiles.get(relative);
if (!fs.statSync(file).isFile() || sha256(file) !== expected) {
throw new Error(`Engine payload manifest checksum mismatch: ${relative}.`);
}
}
return manifest;
}
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertPayloadManifestProvenance,
buildPayloadProvenance,
readAndValidatePayloadManifest,
} from './engine-payload-manifest.js';
import { sha256 } from './engine-payload-integrity.js';
const TARGET = 'x86_64-unknown-linux-gnu';
const SOURCES = {
'yt-dlp': {
version: '2026.08.19',
url: 'https://example.invalid/yt-dlp.zip',
sha256: 'a'.repeat(64),
},
deno: {
version: '2.9.6',
url: 'https://example.invalid/deno.zip',
sha256: 'b'.repeat(64),
},
ffmpeg: {
version: '9.0.1',
url: 'https://example.invalid/ffmpeg.tar.xz',
sha256: 'c'.repeat(64),
},
aria2c: {
version: '1.37.0-firelink-native-dns-v1',
url: 'https://example.invalid/aria2.tar.xz',
sha256: 'd'.repeat(64),
buildFromSource: true,
patchSha256: 'e'.repeat(64),
allocationTelemetry: true,
firelinkRouteContract: {
revision: 'firelink-native-dns-v1',
dnsResolver: 'native-async',
networkTargetPolicy: 'firelink-v1',
networkTargetPolicyDigest: 'sha256:test',
},
},
};
function createPayload() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-manifest-'));
const file = path.join(root, 'aria2c');
fs.writeFileSync(file, 'verified engine');
const manifest = {
schemaVersion: 1,
target: TARGET,
generatedFrom: buildPayloadProvenance(SOURCES),
files: { aria2c: sha256(file) },
};
fs.writeFileSync(path.join(root, 'payload-manifest.json'), `${JSON.stringify(manifest)}\n`);
return { root, manifest };
}
test('payload manifest validation binds files and source provenance', () => {
const { root, manifest } = createPayload();
try {
assert.deepEqual(readAndValidatePayloadManifest(root, SOURCES, TARGET), manifest);
assert.doesNotThrow(() => assertPayloadManifestProvenance(manifest, SOURCES, TARGET));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('payload manifest validation rejects changed provenance and path traversal', () => {
const { root, manifest } = createPayload();
try {
const changed = { ...manifest, generatedFrom: { ...manifest.generatedFrom } };
changed.generatedFrom.aria2c = {
...changed.generatedFrom.aria2c,
patchSha256: 'f'.repeat(64),
};
fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(changed));
assert.throws(
() => readAndValidatePayloadManifest(root, SOURCES, TARGET),
/provenance mismatch/,
);
const traversal = {
...manifest,
files: { '../outside': '0'.repeat(64) },
};
fs.writeFileSync(path.join(root, 'payload-manifest.json'), JSON.stringify(traversal));
assert.throws(
() => readAndValidatePayloadManifest(root, SOURCES, TARGET),
/escapes its root|files do not match/,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+164
View File
@@ -0,0 +1,164 @@
import fs from 'node:fs';
import path from 'node:path';
const RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000];
function pathExists(value) {
try {
fs.lstatSync(value);
return true;
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw error;
}
}
function previousPayloadPrefix(destination) {
return `.${path.basename(destination)}.previous-`;
}
function previousPayloads(destination) {
const parent = path.dirname(destination);
const prefix = previousPayloadPrefix(destination);
return fs.readdirSync(parent, { withFileTypes: true })
.filter(entry => entry.name.startsWith(prefix))
.map(entry => path.join(parent, entry.name));
}
function previousPayloadOwner(destination, candidate) {
const suffix = path.basename(candidate).slice(previousPayloadPrefix(destination).length);
const separator = suffix.indexOf('-');
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
return /^\d+$/.test(pid) ? Number(pid) : null;
}
function isProcessAlive(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === 'EPERM';
}
}
async function removeOrphanedPreviousPayloads(destination) {
for (const candidate of previousPayloads(destination)) {
const owner = previousPayloadOwner(destination, candidate);
if (owner === null || isProcessAlive(owner)) continue;
await removePathWithRetry(candidate);
}
}
function provisioningTemporaryPrefix(target) {
return `.firelink-engines-${target}-`;
}
function provisioningTemporaryOwner(target, candidate) {
const suffix = path.basename(candidate).slice(provisioningTemporaryPrefix(target).length);
const separator = suffix.indexOf('-');
const pid = separator >= 0 ? suffix.slice(0, separator) : suffix;
return /^\d+$/.test(pid) ? Number(pid) : null;
}
/**
* Removes only staging directories created by a provisioner whose PID is no
* longer alive. Legacy directories without an owner PID remain untouched so
* a concurrent or ambiguous provisioner can never lose its staging tree.
*/
export async function removeOrphanedProvisioningDirectories(destinationParent, target) {
const prefix = provisioningTemporaryPrefix(target);
for (const entry of fs.readdirSync(destinationParent, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith(prefix)) continue;
const candidate = path.join(destinationParent, entry.name);
const owner = provisioningTemporaryOwner(target, candidate);
if (owner === null || owner === process.pid || isProcessAlive(owner)) continue;
await removePathWithRetry(candidate);
}
}
/**
* Restores the only previous payload left by a process that died after moving
* the destination aside but before publishing its replacement. Multiple
* candidates are ambiguous and remain untouched for manual recovery.
*/
export function recoverInterruptedPromotion(destination) {
if (pathExists(destination)) return;
const candidates = previousPayloads(destination);
if (candidates.length === 0) return;
if (candidates.length > 1) {
throw new Error(
`Cannot recover engine payload at ${destination}: found ${candidates.length} previous payloads`
);
}
const candidate = candidates[0];
if (!fs.lstatSync(candidate).isDirectory()) {
throw new Error(`Cannot recover engine payload from non-directory backup: ${candidate}`);
}
fs.renameSync(candidate, destination);
}
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
export async function removePathWithRetry(value) {
for (let attempt = 0; ; attempt += 1) {
try {
fs.rmSync(value, { recursive: true, force: true });
return;
} catch (error) {
const retryable = process.platform === 'win32'
&& ['EACCES', 'EBUSY', 'EPERM'].includes(error?.code);
if (!retryable || attempt >= RETRY_DELAYS_MS.length) throw error;
await sleep(RETRY_DELAYS_MS[attempt]);
}
}
}
/**
* Publishes a fully verified payload without exposing a partially written
* directory. The staging directory must share a filesystem with destination.
*/
export async function promoteDirectory(staging, destination) {
recoverInterruptedPromotion(destination);
const stagingStats = fs.lstatSync(staging);
if (!stagingStats.isDirectory()) {
throw new Error(`Engine payload staging path is not a directory: ${staging}`);
}
const parent = path.dirname(destination);
const backup = path.join(
parent,
`.${path.basename(destination)}.previous-${process.pid}-${process.hrtime.bigint()}`
);
let movedExisting = false;
try {
if (pathExists(destination)) {
fs.renameSync(destination, backup);
movedExisting = true;
}
fs.renameSync(staging, destination);
} catch (error) {
if (movedExisting && !pathExists(destination) && pathExists(backup)) {
try {
fs.renameSync(backup, destination);
} catch (restoreError) {
throw new AggregateError(
[error, restoreError],
`Failed to publish engine payload and restore the previous payload at ${destination}`
);
}
} else if (movedExisting && pathExists(destination) && pathExists(backup)) {
// Another provisioner won the promotion race; discard only our backup.
await removePathWithRetry(backup);
}
throw error;
}
if (movedExisting) await removePathWithRetry(backup);
await removeOrphanedPreviousPayloads(destination);
}
@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
promoteDirectory,
recoverInterruptedPromotion,
removeOrphanedProvisioningDirectories,
removePathWithRetry,
} from './engine-payload-promotion.js';
function temporaryDirectory() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-payload-promotion-'));
}
test('promotes a verified staging directory and replaces the previous payload', async () => {
const root = temporaryDirectory();
try {
const destination = path.join(root, 'target');
const staging = path.join(root, 'staging', 'payload');
fs.mkdirSync(destination, { recursive: true });
fs.writeFileSync(path.join(destination, 'engine'), 'old');
fs.mkdirSync(staging, { recursive: true });
fs.writeFileSync(path.join(staging, 'engine'), 'new');
await promoteDirectory(staging, destination);
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
assert.equal(fs.existsSync(staging), false);
assert.equal(fs.readdirSync(root).some(name => name.includes('.previous-')), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects an invalid staging path without removing the previous payload', async () => {
const root = temporaryDirectory();
try {
const destination = path.join(root, 'target');
const staging = path.join(root, 'staging-file');
fs.mkdirSync(destination, { recursive: true });
fs.writeFileSync(path.join(destination, 'engine'), 'old');
fs.writeFileSync(staging, 'not a directory');
await assert.rejects(() => promoteDirectory(staging, destination), /not a directory/);
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'old');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('recovers one interrupted previous payload before publishing a replacement', async () => {
const root = temporaryDirectory();
try {
const destination = path.join(root, 'target');
const backup = path.join(root, '.target.previous-123-456');
const staging = path.join(root, 'staging', 'payload');
fs.mkdirSync(backup, { recursive: true });
fs.writeFileSync(path.join(backup, 'engine'), 'old');
fs.mkdirSync(staging, { recursive: true });
fs.writeFileSync(path.join(staging, 'engine'), 'new');
await promoteDirectory(staging, destination);
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
assert.equal(fs.existsSync(backup), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('fails closed when interrupted recovery has multiple candidates', () => {
const root = temporaryDirectory();
try {
const destination = path.join(root, 'target');
const firstBackup = path.join(root, '.target.previous-123-456');
const secondBackup = path.join(root, '.target.previous-789-012');
fs.mkdirSync(firstBackup, { recursive: true });
fs.mkdirSync(secondBackup, { recursive: true });
assert.throws(
() => recoverInterruptedPromotion(destination),
/found 2 previous payloads/
);
assert.equal(fs.existsSync(destination), false);
assert.equal(fs.existsSync(firstBackup), true);
assert.equal(fs.existsSync(secondBackup), true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('removes a temporary payload tree after it becomes disposable', async () => {
const root = temporaryDirectory();
try {
const temporary = path.join(root, 'temporary');
fs.mkdirSync(path.join(temporary, 'nested'), { recursive: true });
fs.writeFileSync(path.join(temporary, 'nested', 'archive'), 'payload');
await removePathWithRetry(temporary);
assert.equal(fs.existsSync(temporary), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('removes orphaned backups from dead provisioners after a successful publish', async () => {
const root = temporaryDirectory();
try {
const destination = path.join(root, 'target');
const orphanedBackup = path.join(root, '.target.previous-999999999-123456');
const staging = path.join(root, 'staging', 'payload');
fs.mkdirSync(destination, { recursive: true });
fs.writeFileSync(path.join(destination, 'engine'), 'old');
fs.mkdirSync(orphanedBackup, { recursive: true });
fs.writeFileSync(path.join(orphanedBackup, 'engine'), 'orphaned');
fs.mkdirSync(staging, { recursive: true });
fs.writeFileSync(path.join(staging, 'engine'), 'new');
await promoteDirectory(staging, destination);
assert.equal(fs.existsSync(orphanedBackup), false);
assert.equal(fs.readFileSync(path.join(destination, 'engine'), 'utf8'), 'new');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('removes only provisioning staging owned by a dead PID', async () => {
const root = temporaryDirectory();
try {
const target = 'x86_64-unknown-linux-gnu';
const orphaned = path.join(root, `.firelink-engines-${target}-999999999-dead`);
const live = path.join(root, `.firelink-engines-${target}-${process.pid}-live`);
const legacy = path.join(root, `.firelink-engines-${target}-legacy`);
fs.mkdirSync(orphaned, { recursive: true });
fs.mkdirSync(live, { recursive: true });
fs.mkdirSync(legacy, { recursive: true });
await removeOrphanedProvisioningDirectories(root, target);
assert.equal(fs.existsSync(orphaned), false);
assert.equal(fs.existsSync(live), true);
assert.equal(fs.existsSync(legacy), true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { getAria2BuildScriptSha256 } from './engine-aria2-cache.js';
const WINDOWS_PACKAGES = [
'autoconf',
'automake',
'libtool',
'gettext-devel',
'pkgconf',
'make',
'patch',
'binutils',
'mingw-w64-x86_64-gcc',
'mingw-w64-x86_64-binutils',
'mingw-w64-x86_64-pkgconf',
'mingw-w64-x86_64-openssl',
'mingw-w64-x86_64-libssh2',
'mingw-w64-x86_64-c-ares',
'mingw-w64-x86_64-expat',
'mingw-w64-x86_64-sqlite3',
'mingw-w64-x86_64-zlib',
];
const LINUX_PACKAGES = [
'gcc',
'g++',
'make',
'patch',
'binutils',
'autoconf',
'automake',
'libtool',
'gettext',
'autopoint',
'pkg-config',
'libssl-dev',
'libssh2-1-dev',
'libgcrypt20-dev',
'libc-ares-dev',
'libexpat1-dev',
'libsqlite3-dev',
'zlib1g-dev',
];
function run(command, args) {
try {
return execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).replaceAll('\r\n', '\n').trim();
} catch (error) {
const detail = error.stderr?.toString().trim() || error.message;
throw new Error(`Could not fingerprint the engine toolchain with ${command}: ${detail}`);
}
}
function writeOutput(name, value) {
const line = `${name}=${value}\n`;
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, line);
} else {
process.stdout.write(line);
}
}
function main() {
const target = process.env.FIRELINK_TARGET_TRIPLE;
if (!target) throw new Error('FIRELINK_TARGET_TRIPLE is required.');
const records = [`target=${target}`];
if (process.platform === 'win32') {
const msysRoot = process.env.FIRELINK_MSYS2_ROOT;
if (!msysRoot) throw new Error('FIRELINK_MSYS2_ROOT is required on Windows.');
const bash = path.join(msysRoot, 'usr', 'bin', 'bash.exe');
const packages = WINDOWS_PACKAGES.join(' ');
records.push(`msys2-packages=${run(bash, ['-lc', `pacman -Q ${packages}`])}`);
} else if (process.platform === 'linux') {
records.push(`debian-packages=${run('dpkg-query', [
'-W',
'-f=${binary:Package}=${Version}\\n',
...LINUX_PACKAGES,
])}`);
for (const [command, args] of [
['gcc', ['--version']],
['make', ['--version']],
['autoconf', ['--version']],
['automake', ['--version']],
['pkg-config', ['--version']],
]) {
records.push(`${command}=${run(command, args).split('\n', 1)[0]}`);
}
} else {
throw new Error(`Unsupported engine toolchain host: ${process.platform}`);
}
const fingerprint = crypto.createHash('sha256').update(records.join('\n')).digest('hex');
writeOutput('fingerprint', fingerprint);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const sourceLockPath = path.join(repoRoot, 'engine-sources.lock.json');
if (fs.existsSync(sourceLockPath)) {
const sourceLock = JSON.parse(fs.readFileSync(sourceLockPath, 'utf8'));
const aria2Source = sourceLock.targets?.[target]?.aria2c;
if (aria2Source) {
const canonicalize = val => {
if (Array.isArray(val)) return val.map(canonicalize);
if (val && typeof val === 'object') {
return Object.fromEntries(
Object.keys(val).sort().map(k => [k, canonicalize(val[k])])
);
}
return val;
};
const patchPath = path.join(repoRoot, aria2Source.patch || 'scripts/aria2/firelink.patch');
const buildShPath = path.join(repoRoot, 'scripts/aria2/build.sh');
const patchSha = fs.existsSync(patchPath)
? crypto.createHash('sha256').update(fs.readFileSync(patchPath, 'utf8').replaceAll('\r\n', '\n')).digest('hex')
: (aria2Source.patchSha256 || '');
const buildShSha = fs.existsSync(buildShPath)
? getAria2BuildScriptSha256(repoRoot)
: '';
const aria2Records = [
...records,
`aria2-source=${JSON.stringify(canonicalize(aria2Source))}`,
`aria2-patch-sha256=${patchSha}`,
`aria2-build-sh-sha256=${buildShSha}`,
];
const aria2Fingerprint = crypto.createHash('sha256').update(aria2Records.join('\n')).digest('hex');
writeOutput('aria2-fingerprint', aria2Fingerprint);
}
}
}
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+169
View File
@@ -0,0 +1,169 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { removePathWithRetry } from './engine-payload-promotion.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const ARCH_MAP = { x64: 'x86_64', arm64: 'aarch64' };
const PLATFORM_MAP = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu',
};
const SAFE_TARGET_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
function argumentValue(args, name) {
const index = args.indexOf(name);
if (index >= 0) return args[index + 1];
const prefix = `${name}=`;
const inline = args.find(argument => argument.startsWith(prefix));
return inline?.slice(prefix.length);
}
export function assertSafeTarget(target) {
if (typeof target !== 'string' || !SAFE_TARGET_PATTERN.test(target)) {
throw new Error(`Invalid target triple: ${target ?? '<missing>'}`);
}
return target;
}
export function resolveTargetTriple(
args = process.argv.slice(2),
env = process.env,
platform = os.platform(),
arch = os.arch(),
) {
const hostTarget = ARCH_MAP[arch] && PLATFORM_MAP[platform]
? `${ARCH_MAP[arch]}-${PLATFORM_MAP[platform]}`
: undefined;
const target = argumentValue(args, '--target')
|| env.TAURI_ENV_TARGET_TRIPLE
|| env.FIRELINK_TARGET_TRIPLE
|| hostTarget;
return assertSafeTarget(target);
}
export function resolveOutputRoot(args = process.argv.slice(2), env = process.env) {
const outputRoot = argumentValue(args, '--output-root') || env.FIRELINK_ENGINE_OUTPUT_ROOT;
if (!outputRoot) {
throw new Error(
'No engine output workspace was provided. Run through npm run tauri or set FIRELINK_ENGINE_OUTPUT_ROOT.',
);
}
return path.resolve(outputRoot);
}
function canonicalPathWithMissingComponents(value) {
let cursor = path.resolve(value);
const missing = [];
while (true) {
try {
const canonical = fs.realpathSync.native(cursor);
return path.join(canonical, ...missing.reverse());
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
const parent = path.dirname(cursor);
if (parent === cursor) throw error;
missing.push(path.basename(cursor));
cursor = parent;
}
}
}
function comparablePath(value) {
const normalized = path.normalize(value);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function isWithinPath(root, candidate) {
const relative = path.relative(comparablePath(root), comparablePath(candidate));
return relative === ''
|| (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
}
export function assertSafeOutputRoot(outputRoot, forbiddenRoots = []) {
const canonicalOutputRoot = canonicalPathWithMissingComponents(outputRoot);
for (const forbiddenRoot of forbiddenRoots) {
const canonicalForbiddenRoot = canonicalPathWithMissingComponents(forbiddenRoot);
if (isWithinPath(canonicalForbiddenRoot, canonicalOutputRoot)) {
throw new Error(
`Refusing to use a repository-shared engine workspace: ${outputRoot}`,
);
}
}
return canonicalOutputRoot;
}
export function stripVerbatimPrefix(filePath) {
return typeof filePath === 'string' && filePath.startsWith('\\\\?\\')
? filePath.slice(4)
: filePath;
}
export function resolveWorkspaceTempBase(referencePath = repoRoot) {
if (process.env.FIRELINK_ENGINE_WORKSPACE_BASE) {
return path.resolve(process.env.FIRELINK_ENGINE_WORKSPACE_BASE);
}
if (process.platform === 'win32') {
const referenceDrive = path.parse(path.resolve(referencePath)).root.toLowerCase();
if (process.env.RUNNER_TEMP) {
const runnerTemp = path.resolve(process.env.RUNNER_TEMP);
if (path.parse(runnerTemp).root.toLowerCase() === referenceDrive) {
return runnerTemp;
}
}
const osTemp = path.resolve(os.tmpdir());
if (path.parse(osTemp).root.toLowerCase() === referenceDrive) {
return osTemp;
}
const adjacentTemp = path.join(path.resolve(referencePath, '..'), '.firelink-engine-workspaces');
fs.mkdirSync(adjacentTemp, { recursive: true, mode: 0o700 });
return adjacentTemp;
}
if (process.env.RUNNER_TEMP) {
return path.resolve(process.env.RUNNER_TEMP);
}
return os.tmpdir();
}
export function createEngineWorkspace(target) {
assertSafeTarget(target);
const tempBase = resolveWorkspaceTempBase();
const rawWorkspace = fs.realpathSync.native(
fs.mkdtempSync(path.join(tempBase, `firelink-engine-${target}-${process.pid}-`)),
);
const workspace = stripVerbatimPrefix(rawWorkspace);
const outputRoot = path.join(workspace, 'engine-dist');
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
return { outputRoot, runtimeRoot: outputRoot, workspace };
}
export function engineResourceConfig(outputRoot) {
const source = `${stripVerbatimPrefix(path.resolve(outputRoot))}${path.sep}`;
return JSON.stringify({
bundle: {
resources: {
[source]: 'engine-dist/',
},
},
});
}
export async function removeEngineWorkspace(workspace) {
const resolved = stripVerbatimPrefix(path.resolve(workspace));
const basename = path.basename(resolved);
if (!basename.startsWith('firelink-engine-')) {
throw new Error(`Refusing to remove an unexpected engine workspace: ${resolved}`);
}
await removePathWithRetry(resolved);
}
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
assertSafeTarget,
assertSafeOutputRoot,
createEngineWorkspace,
engineResourceConfig,
removeEngineWorkspace,
resolveOutputRoot,
resolveTargetTriple,
resolveWorkspaceTempBase,
stripVerbatimPrefix,
} from './engine-workspace.js';
test('target resolution accepts explicit and inline target arguments', () => {
assert.equal(
resolveTargetTriple(['--target', 'x86_64-unknown-linux-gnu'], {}, 'darwin', 'arm64'),
'x86_64-unknown-linux-gnu',
);
assert.equal(
resolveTargetTriple(['--target=x86_64-pc-windows-msvc'], {}, 'darwin', 'arm64'),
'x86_64-pc-windows-msvc',
);
});
test('target validation rejects path traversal before filesystem use', () => {
assert.throws(() => assertSafeTarget('../outside'), /Invalid target triple/);
assert.throws(
() => resolveTargetTriple(['--target', 'x86_64/../../outside'], {}, 'darwin', 'arm64'),
/Invalid target triple/,
);
});
test('engine workspaces are unique and produce an absolute Tauri resource mapping', async () => {
const first = createEngineWorkspace('aarch64-apple-darwin');
const second = createEngineWorkspace('aarch64-apple-darwin');
try {
assert.notEqual(first.workspace, second.workspace);
assert.equal(fs.statSync(first.outputRoot).isDirectory(), true);
const config = JSON.parse(engineResourceConfig(first.outputRoot));
assert.equal(
config.bundle.resources[`${path.resolve(first.outputRoot)}${path.sep}`],
'engine-dist/',
);
} finally {
await removeEngineWorkspace(first.workspace);
await removeEngineWorkspace(second.workspace);
}
});
test('staging requires an explicit private output workspace', () => {
assert.throws(() => resolveOutputRoot([], {}), /No engine output workspace/);
assert.equal(
resolveOutputRoot(['--output-root', '/tmp/firelink-engine-run'], {}).endsWith(
path.join('firelink-engine-run'),
),
true,
);
});
test('shared repository output roots and descendants are rejected', () => {
const repoRoot = path.resolve('/repo');
assert.throws(
() => assertSafeOutputRoot('/repo/src-tauri/engine-dist/target', [
repoRoot,
path.join(repoRoot, 'src-tauri'),
path.join(repoRoot, 'src-tauri', 'engine-dist'),
]),
/repository-shared engine workspace/,
);
});
test('output roots are checked after resolving symlinked parents', () => {
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-engine-workspace-test-'));
const sharedRoot = path.join(temporaryRoot, 'shared');
const linkedRoot = path.join(temporaryRoot, 'linked');
try {
fs.mkdirSync(path.join(sharedRoot, 'src-tauri', 'engine-dist'), { recursive: true });
fs.symlinkSync(sharedRoot, linkedRoot, 'dir');
assert.throws(
() => assertSafeOutputRoot(path.join(linkedRoot, 'src-tauri', 'engine-dist', 'target'), [
sharedRoot,
path.join(sharedRoot, 'src-tauri'),
path.join(sharedRoot, 'src-tauri', 'engine-dist'),
]),
/repository-shared engine workspace/,
);
} finally {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
});
test('stripVerbatimPrefix removes Win32 verbatim namespaces', () => {
assert.equal(stripVerbatimPrefix('\\\\?\\D:\\a\\_temp'), 'D:\\a\\_temp');
assert.equal(stripVerbatimPrefix('D:\\a\\_temp'), 'D:\\a\\_temp');
assert.equal(stripVerbatimPrefix('/tmp/firelink'), '/tmp/firelink');
});
test('resolveWorkspaceTempBase honors FIRELINK_ENGINE_WORKSPACE_BASE override', () => {
const custom = path.resolve('/custom/engine/base');
const prev = process.env.FIRELINK_ENGINE_WORKSPACE_BASE;
process.env.FIRELINK_ENGINE_WORKSPACE_BASE = custom;
try {
assert.equal(resolveWorkspaceTempBase(), custom);
} finally {
if (prev === undefined) delete process.env.FIRELINK_ENGINE_WORKSPACE_BASE;
else process.env.FIRELINK_ENGINE_WORKSPACE_BASE = prev;
}
});
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function run(script, args) {
const result = spawnSync(process.execPath, [path.join(repoRoot, 'scripts', script), ...args], {
cwd: repoRoot,
stdio: 'inherit',
windowsHide: true,
});
if (result.error) {
console.error(`[FAIL] Could not run ${script}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
run('stage-engines.js', []);
run('verify-binaries.js', ['--staged']);
+171 -44
View File
@@ -1,13 +1,29 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
import { buildPayloadProvenance } from './engine-payload-manifest.js';
import { downloadEngineArchive } from './engine-download.js';
import {
promoteDirectory,
recoverInterruptedPromotion,
removeOrphanedProvisioningDirectories,
removePathWithRetry,
} from './engine-payload-promotion.js';
import { assertAria2RouteSource } from './aria2-route-contract.js';
import {
getAria2BuildScriptSha256,
restoreAria2Cache,
saveAria2Cache,
validateAria2Cache,
} from './engine-aria2-cache.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const execFileAsync = promisify(execFile);
const sourceLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
);
@@ -31,43 +47,71 @@ if (!targetSources) {
process.exit(1);
}
if (targetSources.aria2c?.firelinkRouteContract) {
try {
assertAria2RouteSource(targetSources.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
const destination = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), `firelink-engines-${target}-`));
const isWindows = target.includes('windows');
const executableSuffix = isWindows ? '.exe' : '';
const provisioningAbortController = new AbortController();
const signalNames = process.platform === 'win32'
? ['SIGINT', 'SIGTERM']
: ['SIGINT', 'SIGTERM', 'SIGHUP'];
const signalHandlers = new Map();
for (const signalName of signalNames) {
const handler = () => {
if (!provisioningAbortController.signal.aborted) {
provisioningAbortController.abort(new Error(`Engine provisioning interrupted by ${signalName}`));
}
};
signalHandlers.set(signalName, handler);
process.on(signalName, handler);
}
let temporary;
let payloadDestination;
function throwIfProvisioningAborted() {
if (provisioningAbortController.signal.aborted) {
throw provisioningAbortController.signal.reason;
}
}
async function download(name, source) {
throwIfProvisioningAborted();
const sourcePath = new URL(source.url).pathname;
const archive = path.join(
temporary,
`${name}${sourcePath.endsWith('.tar.xz') ? '.tar.xz' : '.zip'}`
);
const response = await fetch(source.url, { redirect: 'follow' });
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${name}: HTTP ${response.status}`);
}
const output = fs.createWriteStream(archive);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!output.write(Buffer.from(value))) {
await new Promise(resolve => output.once('drain', resolve));
}
}
await new Promise(resolve => output.end(resolve));
const actual = sha256(archive);
if (actual !== source.sha256) {
throw new Error(`Archive checksum mismatch for ${name}. Expected ${source.sha256}, got ${actual}`);
}
await downloadEngineArchive({
name,
url: source.url,
archive,
expectedSha256: source.sha256,
signal: provisioningAbortController.signal,
});
throwIfProvisioningAborted();
const extracted = path.join(temporary, `${name}-extracted`);
fs.mkdirSync(extracted);
if (archive.endsWith('.zip') && process.platform !== 'win32') {
execFileSync('unzip', ['-q', archive, '-d', extracted], { stdio: 'inherit' });
await execFileAsync('unzip', ['-q', archive, '-d', extracted], {
stdio: 'inherit',
signal: provisioningAbortController.signal,
});
} else {
execFileSync('tar', ['-xf', archive, '-C', extracted], { stdio: 'inherit' });
await execFileAsync('tar', ['-xf', archive, '-C', extracted], {
stdio: 'inherit',
signal: provisioningAbortController.signal,
});
}
throwIfProvisioningAborted();
return extracted;
}
@@ -89,51 +133,51 @@ function findFile(root, names) {
}
function copyExecutable(source, engine) {
const output = path.join(destination, `${engine}-${target}${executableSuffix}`);
const output = path.join(payloadDestination, `${engine}-${target}${executableSuffix}`);
fs.copyFileSync(source, output);
if (!isWindows) fs.chmodSync(output, 0o755);
}
function writePayloadManifest() {
const files = collectRegularFiles(destination, {
const files = collectRegularFiles(payloadDestination, {
ignoredNames: ['payload-manifest.json'],
});
const manifest = {
schemaVersion: 1,
target,
generatedFrom: Object.fromEntries(
Object.entries(targetSources).map(([name, source]) => [
name,
{
version: source.version,
url: source.url || source.sourceUrl,
sha256: source.sha256 || source.sourceSha256
}
])
),
generatedFrom: buildPayloadProvenance(targetSources),
files: Object.fromEntries(
files.map(file => [
path.relative(destination, file).split(path.sep).join('/'),
path.relative(payloadDestination, file).split(path.sep).join('/'),
sha256(file)
])
)
};
fs.writeFileSync(
path.join(destination, 'payload-manifest.json'),
path.join(payloadDestination, 'payload-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
}
try {
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
const destinationParent = path.dirname(destination);
fs.mkdirSync(destinationParent, { recursive: true });
recoverInterruptedPromotion(destination);
await removeOrphanedProvisioningDirectories(destinationParent, target);
throwIfProvisioningAborted();
// Keep staging on the destination filesystem so the final rename is atomic.
temporary = fs.mkdtempSync(
path.join(destinationParent, `.firelink-engines-${target}-${process.pid}-`)
);
payloadDestination = path.join(temporary, 'payload');
fs.mkdirSync(payloadDestination, { recursive: true });
const ytdlp = await download('yt-dlp', targetSources['yt-dlp']);
copyExecutable(
findFile(ytdlp, isWindows ? ['yt-dlp.exe'] : ['yt-dlp_linux']),
'yt-dlp'
);
fs.cpSync(path.join(ytdlp, '_internal'), path.join(destination, '_internal'), {
fs.cpSync(path.join(ytdlp, '_internal'), path.join(payloadDestination, '_internal'), {
recursive: true,
preserveTimestamps: true
});
@@ -144,11 +188,94 @@ try {
const ffmpeg = await download('ffmpeg', targetSources.ffmpeg);
copyExecutable(findFile(ffmpeg, isWindows ? ['ffmpeg.exe'] : ['ffmpeg']), 'ffmpeg');
const aria2 = await download('aria2c', targetSources.aria2c);
copyExecutable(findFile(aria2, isWindows ? ['aria2c.exe'] : ['aria2c']), 'aria2c');
const aria2Source = targetSources.aria2c;
if (aria2Source.buildFromSource !== true || aria2Source.allocationTelemetry !== true) {
throw new Error('Aria2 provisioning requires the allocation telemetry source build.');
}
const patchFile = path.join(repoRoot, aria2Source.patch);
if (sha256(patchFile) !== aria2Source.patchSha256) throw new Error('Aria2 source patch checksum mismatch');
const aria2CacheRoot = process.env.FIRELINK_ARIA2_CACHE_DIR
|| path.join(destinationParent, '.aria2-cache', target);
const buildScriptSha256 = getAria2BuildScriptSha256(repoRoot);
const toolchainFingerprint = process.env.FIRELINK_TOOLCHAIN_FINGERPRINT || null;
const cacheValidation = validateAria2Cache({
aria2CacheRoot,
target,
aria2Source,
buildScriptSha256,
toolchainFingerprint,
executableSuffix,
});
if (cacheValidation.valid) {
restoreAria2Cache({
aria2CacheRoot,
payloadDestination,
target,
executableSuffix,
isWindows,
});
console.log(`Reused cached Aria2 build from ${aria2CacheRoot}`);
} else {
const aria2 = await download('aria2c', targetSources.aria2c);
const sourceRoots = fs.readdirSync(aria2, { withFileTypes: true })
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(aria2, entry.name, 'configure.ac')))
.map(entry => path.join(aria2, entry.name));
if (sourceRoots.length !== 1) throw new Error('Aria2 archive must contain exactly one source root');
const [sourceRoot] = sourceRoots;
const bash = isWindows ? path.join(process.env.FIRELINK_MSYS2_ROOT || 'C:/msys64', 'usr/bin/bash.exe') : 'bash';
await execFileAsync(bash, [path.join(repoRoot, 'scripts/aria2/build.sh').replaceAll('\\', '/'), sourceRoot, patchFile], {
signal: provisioningAbortController.signal,
env: { ...process.env, ...(isWindows ? { MSYSTEM: 'MINGW64' } : {}) },
maxBuffer: 32 * 1024 * 1024,
timeout: 30 * 60 * 1000,
});
copyExecutable(path.join(sourceRoot, 'firelink-build', 'src', `aria2c${executableSuffix}`), 'aria2c');
const aria2Runtime = path.join(sourceRoot, 'aria2-libs');
if (fs.existsSync(aria2Runtime)) {
fs.cpSync(aria2Runtime, path.join(payloadDestination, 'aria2-libs'), {
recursive: true,
preserveTimestamps: true,
});
}
try {
await saveAria2Cache({
aria2CacheRoot,
payloadDestination,
target,
aria2Source,
buildScriptSha256,
toolchainFingerprint,
executableSuffix,
aria2Runtime,
isWindows,
});
console.log(`Saved built Aria2 cache to ${aria2CacheRoot}`);
} catch (cacheError) {
console.warn(`Could not save Aria2 build cache: ${cacheError.message}`);
}
}
writePayloadManifest();
throwIfProvisioningAborted();
await promoteDirectory(payloadDestination, destination);
console.log(`Provisioned locked engine payload at ${destination}`);
} finally {
fs.rmSync(temporary, { recursive: true, force: true });
if (temporary) {
try {
await removePathWithRetry(temporary);
} catch (cleanupError) {
if (provisioningAbortController.signal.aborted) {
console.warn(`Could not remove temporary directory during abort: ${cleanupError.message}`);
} else {
throw cleanupError;
}
}
}
for (const [signalName, handler] of signalHandlers) {
process.removeListener(signalName, handler);
}
}
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
const repositoryRoot = path.resolve(import.meta.dirname, '..');
const releaseWorkflow = fs.readFileSync(
path.join(repositoryRoot, '.github', 'workflows', 'release.yml'),
'utf8',
);
test('release Linux dependency installation is mirror-normalized and bounded', () => {
assert.match(releaseWorkflow, /azure\\\.archive\\\.ubuntu\\\.com/);
assert.equal((releaseWorkflow.match(/Acquire::Retries=3/g) || []).length, 2);
assert.equal((releaseWorkflow.match(/timeout --foreground --signal=TERM --kill-after=30s 10m apt-get/g) || []).length, 2);
assert.doesNotMatch(releaseWorkflow, /^\s*sudo apt-get (update|install)/m);
});
test('macOS release verification uses the app mounted from the final DMG', () => {
assert.match(releaseWorkflow, /npm run verify:macos-signing -- --dmg "\$DMG"/);
assert.match(releaseWorkflow, /hdiutil attach -nobrowse -readonly -mountpoint "\$MOUNT_POINT" "\$DMG"/);
assert.match(releaseWorkflow, /find "\$MOUNT_POINT" -maxdepth 1 -type d -name 'Firelink\.app'/);
assert.match(releaseWorkflow, /node scripts\/verify-binaries\.js --search-root "\$APP"/);
assert.doesNotMatch(releaseWorkflow, /verify:macos-signing -- --app "\$APP" --dmg/);
});
test('release workflow normalizes all 6 distribution target artifacts', () => {
assert.match(releaseWorkflow, /rename_asset '\*\.dmg' "Firelink_\$\{VERSION\}_macOS-ARM64\.dmg"/);
assert.match(releaseWorkflow, /rename_asset '\*\.AppImage' "Firelink_\$\{VERSION\}_Linux-x64\.AppImage"/);
assert.match(releaseWorkflow, /rename_asset '\*\.deb' "Firelink_\$\{VERSION\}_Linux-x64\.deb"/);
assert.match(releaseWorkflow, /rename_asset '\*\.rpm' "Firelink_\$\{VERSION\}_Linux-x64\.rpm"/);
assert.match(releaseWorkflow, /rename_asset '\*\.exe' "Firelink_\$\{VERSION\}_Windows-x64-setup\.exe"/);
assert.match(releaseWorkflow, /rename_asset '\*\.zip' "Firelink_\$\{VERSION\}_Windows-x64-portable\.zip"/);
});
test('Windows release job packages portable ZIP with portable.flag and data cleanup', () => {
assert.match(releaseWorkflow, /Set-Content -Path \(Join-Path \$portableRoot 'portable\.flag'\) -Value 'portable'/);
assert.match(releaseWorkflow, /node scripts\/smoke-packaged-app\.js --executable \$portableExe --assert-no-visible-child-windows --assert-portable-data/);
assert.match(releaseWorkflow, /Remove-Item -Recurse -Force \$portableDataDir/);
assert.match(releaseWorkflow, /refusing to package a ZIP containing runtime data/);
});
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
ARIA2_SYSTEM_RESOLVER_OPTIONS,
ARIA2_ROUTE_OPTIONS,
assertAria2Baseline,
assertAria2RouteOptions,
assertAria2SystemResolverOptions,
hasAria2RouteCapabilities,
} from './aria2-route-contract.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
const platform = {
darwin: 'apple-darwin',
linux: 'unknown-linux-gnu',
win32: 'pc-windows-msvc',
}[process.platform];
if (!arch || !platform) {
throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
}
const targetTriple = `${arch}-${platform}`;
const argumentIndex = process.argv.indexOf('--binary');
const binaryPath = path.resolve(
argumentIndex >= 0
? process.argv[argumentIndex + 1]
: process.env.FIRELINK_ENGINE_OUTPUT_ROOT
? path.join(
process.env.FIRELINK_ENGINE_OUTPUT_ROOT,
targetTriple,
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
)
: path.join(
repoRoot,
'src-tauri',
'binaries',
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
),
);
if (!fs.existsSync(binaryPath)) {
throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
}
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
async function availablePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const address = server.address();
const port = address && typeof address !== 'string' ? address.port : undefined;
await new Promise(resolve => server.close(resolve));
if (!port) throw new Error('Could not reserve a local port');
return port;
}
async function rpc(port, secret, method, params = []) {
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method,
params: [`token:${secret}`, ...params],
}),
signal: AbortSignal.timeout(3000),
});
const body = await response.json();
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
return body.result;
}
async function forceRemoveIfPresent(port, secret, gid) {
try {
await rpc(port, secret, 'aria2.forceRemove', [gid]);
} catch (error) {
if (!/not found|no such download|active download not found/i.test(error.message)) throw error;
}
}
function childExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForChildExit(child, timeoutMs = 3000) {
if (childExited(child)) return true;
return new Promise(resolve => {
let settled = false;
let timer;
const finish = result => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.off('exit', onExit);
resolve(result);
};
const onExit = () => finish(true);
timer = setTimeout(() => finish(false), timeoutMs);
child.once('exit', onExit);
if (childExited(child)) finish(true);
});
}
async function waitForRpc(port, secret) {
const deadline = Date.now() + 10000;
let lastError;
while (Date.now() < deadline) {
try {
return await rpc(port, secret, 'aria2.getVersion');
} catch (error) {
lastError = error;
await wait(100);
}
}
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
}
function bencode(value) {
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
if (typeof value === 'string') return bencode(Buffer.from(value));
if (typeof value === 'number') return Buffer.from(`i${value}e`);
if (value && typeof value === 'object') {
const entries = Object.entries(value).sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
return Buffer.concat([
Buffer.from('d'),
...entries.flatMap(([key, child]) => [bencode(key), bencode(child)]),
Buffer.from('e'),
]);
}
throw new Error(`Unsupported bencode value: ${typeof value}`);
}
async function stop(child, port, secret) {
if (!child || childExited(child)) return;
try {
await rpc(port, secret, 'aria2.shutdown');
} catch {
// The process may already have exited.
}
let exited = await waitForChildExit(child);
if (!exited) {
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
timeout: 3000,
});
} catch {
// The process may have exited between the timeout and taskkill.
}
} else {
child.kill('SIGTERM');
}
exited = await waitForChildExit(child);
}
if (!exited && process.platform !== 'win32') {
child.kill('SIGKILL');
exited = await waitForChildExit(child);
}
if (!exited) {
throw new Error(`Aria2 process ${child.pid} did not exit after forced cleanup`);
}
}
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-resolver-'));
const secret = `firelink-resolver-${crypto.randomUUID()}`;
const rpcPort = await availablePort();
const contentServer = http.createServer((_request, response) => {
response.writeHead(200, { 'content-length': '1' });
response.end('x');
});
await new Promise((resolve, reject) => {
contentServer.once('error', reject);
contentServer.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const contentPort = contentServer.address().port;
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
const environment = fs.existsSync(libraryPath)
? {
...process.env,
OPENSSL_MODULES: libraryPath,
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
...(process.platform === 'win32'
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
: {}),
}
: process.env;
const child = spawn(binaryPath, [
'--enable-rpc=true',
`--rpc-listen-port=${rpcPort}`,
'--rpc-listen-all=false',
`--rpc-secret=${secret}`,
`--dir=${tempRoot}`,
'--file-allocation=none',
'--enable-dht=false',
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
'--console-log-level=error',
'--quiet=true',
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
assertAria2Baseline(version);
const routeCapabilitiesAvailable = hasAria2RouteCapabilities(version);
if (routeCapabilitiesAvailable) {
// The fixture server is intentionally loopback. Disable only the custom
// target policy for this smoke daemon after capabilities are attested.
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
console.log(`[INFO] aria2 ${version.version || 'unknown'}; optional Firelink route capabilities available`);
} else {
console.log(`[INFO] aria2 ${version.version || 'unknown'}; using stock system-resolver capabilities`);
}
const systemFixtureOptions = routeCapabilitiesAvailable
? { ...ARIA2_SYSTEM_RESOLVER_OPTIONS, 'network-target-policy': 'none' }
: { ...ARIA2_SYSTEM_RESOLVER_OPTIONS };
const uriResult = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${contentPort}/file`], {
...systemFixtureOptions,
out: 'resolver-normal.bin',
}]);
const uriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [uriResult]);
assertAria2SystemResolverOptions(uriOptions, 'direct aria2.addUri');
const torrent = bencode({
info: {
length: 1,
name: 'resolver-torrent.bin',
pieces: Buffer.alloc(20),
'piece length': 16384,
},
}).toString('base64');
const torrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
...systemFixtureOptions,
dir: tempRoot,
}]);
const torrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [torrentResult]);
assertAria2SystemResolverOptions(torrentOptions, 'direct aria2.addTorrent');
const proxyRoute = 'http://127.0.0.1:9';
const normalizedProxyRoute = new URL(proxyRoute).toString();
const proxiedUriResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/file'], {
...systemFixtureOptions,
'all-proxy': proxyRoute,
pause: 'true',
out: 'resolver-proxied.bin',
}]);
const proxiedUriOptions = await rpc(rpcPort, secret, 'aria2.getOption', [proxiedUriResult]);
if (proxiedUriOptions['all-proxy'] !== normalizedProxyRoute) {
throw new Error(`aria2.addUri did not retain the configured proxy route: ${JSON.stringify(proxiedUriOptions)}`);
}
assertAria2SystemResolverOptions(proxiedUriOptions, 'proxied aria2.addUri');
const proxiedTorrentResult = await rpc(rpcPort, secret, 'aria2.addTorrent', [torrent, [], {
...systemFixtureOptions,
'all-proxy': proxyRoute,
pause: 'true',
dir: tempRoot,
}]);
const proxiedTorrentOptions = await rpc(rpcPort, secret, 'aria2.getOption', [proxiedTorrentResult]);
if (proxiedTorrentOptions['all-proxy'] !== normalizedProxyRoute) {
throw new Error(`aria2.addTorrent did not retain the configured proxy route: ${JSON.stringify(proxiedTorrentOptions)}`);
}
assertAria2SystemResolverOptions(proxiedTorrentOptions, 'proxied aria2.addTorrent');
if (routeCapabilitiesAvailable) {
const alternateResult = await rpc(rpcPort, secret, 'aria2.addUri', [['https://route-owned.invalid/alternate'], {
...ARIA2_ROUTE_OPTIONS,
pause: 'true',
out: 'resolver-alternate.bin',
}]);
const alternateOptions = await rpc(rpcPort, secret, 'aria2.getOption', [alternateResult]);
assertAria2RouteOptions(alternateOptions, 'alternate aria2.addUri');
await forceRemoveIfPresent(rpcPort, secret, alternateResult);
}
await forceRemoveIfPresent(rpcPort, secret, proxiedUriResult);
await forceRemoveIfPresent(rpcPort, secret, proxiedTorrentResult);
console.log('[PASS] Aria2 preserved system route options for direct/proxied normal/Torrent transfers');
} catch (error) {
const detail = stderr.trim();
throw new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
} finally {
try {
await stop(child, rpcPort, secret);
} finally {
await new Promise(resolve => contentServer.close(resolve));
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
ARIA2_LOCAL_FIXTURE_OPTIONS,
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
assertAria2Baseline,
hasAria2RouteCapabilities,
} from './aria2-route-contract.js';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()];
const platform = { darwin: 'apple-darwin', linux: 'unknown-linux-gnu', win32: 'pc-windows-msvc' }[process.platform];
if (!arch || !platform) throw new Error(`Unsupported host: ${os.arch()} / ${process.platform}`);
const targetTriple = `${arch}-${platform}`;
const argumentIndex = process.argv.indexOf('--binary');
const binaryPath = path.resolve(argumentIndex >= 0
? process.argv[argumentIndex + 1]
: process.env.FIRELINK_ENGINE_OUTPUT_ROOT
? path.join(
process.env.FIRELINK_ENGINE_OUTPUT_ROOT,
targetTriple,
`aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`,
)
: path.join(repoRoot, 'src-tauri', 'binaries', `aria2c-${targetTriple}${process.platform === 'win32' ? '.exe' : ''}`));
if (!fs.existsSync(binaryPath)) throw new Error(`Aria2 binary does not exist: ${binaryPath}`);
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
async function listen(server) {
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, resolve);
});
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Could not allocate local fixture port');
return address.port;
}
async function availablePort() {
const server = net.createServer();
const port = await listen(server);
await new Promise(resolve => server.close(resolve));
return port;
}
async function rpc(port, secret, method, params = []) {
const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: crypto.randomUUID(),
method,
params: [`token:${secret}`, ...params],
}),
signal: AbortSignal.timeout(5000),
});
const body = await response.json();
if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`);
if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`);
return body.result;
}
async function waitForRpc(port, secret) {
const deadline = Date.now() + 10000;
let lastError;
while (Date.now() < deadline) {
try {
return await rpc(port, secret, 'aria2.getVersion');
} catch (error) {
lastError = error;
await wait(100);
}
}
throw new Error(`Aria2 RPC did not become ready: ${lastError?.message || 'unknown error'}`);
}
async function waitForTerminal(port, secret, gid, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs;
let latest;
while (Date.now() < deadline) {
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, [
'status', 'errorCode', 'errorMessage', 'completedLength', 'totalLength',
]]);
if (['complete', 'error', 'removed'].includes(latest.status)) return latest;
await wait(100);
}
throw new Error(`Aria2 gid ${gid} did not become terminal: ${JSON.stringify(latest)}`);
}
async function waitForProgress(port, secret, gid, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
let latest;
while (Date.now() < deadline) {
latest = await rpc(port, secret, 'aria2.tellStatus', [gid, ['status', 'completedLength']]);
if (latest.status === 'active' && Number(latest.completedLength) > 0) return latest;
await wait(25);
}
throw new Error(`Aria2 gid ${gid} made no observable progress: ${JSON.stringify(latest)}`);
}
function serveBuffer(request, response, buffer) {
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
if (!match) {
response.writeHead(200, { 'content-length': String(buffer.length), 'accept-ranges': 'bytes' });
response.end(buffer);
return;
}
const start = Number(match[1]);
const requestedEnd = match[2] ? Number(match[2]) : buffer.length - 1;
const end = Math.min(requestedEnd, buffer.length - 1);
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
response.end();
return;
}
const body = buffer.subarray(start, end + 1);
response.writeHead(206, {
'content-length': String(body.length),
'content-range': `bytes ${start}-${end}/${buffer.length}`,
'accept-ranges': 'bytes',
});
response.end(body);
}
function serveThrottledBuffer(request, response, buffer) {
const match = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range || '');
const start = match ? Number(match[1]) : 0;
const requestedEnd = match && match[2] ? Number(match[2]) : buffer.length - 1;
const end = Math.min(requestedEnd, buffer.length - 1);
if (!Number.isSafeInteger(start) || start < 0 || start > end) {
response.writeHead(416, { 'content-range': `bytes */${buffer.length}` });
response.end();
return;
}
const body = buffer.subarray(start, end + 1);
response.writeHead(match ? 206 : 200, {
'content-length': String(body.length),
...(match ? { 'content-range': `bytes ${start}-${end}/${buffer.length}` } : {}),
'accept-ranges': 'bytes',
});
let offset = 0;
const timer = setInterval(() => {
if (response.destroyed || offset >= body.length) {
clearInterval(timer);
if (!response.destroyed) response.end();
return;
}
const next = Math.min(offset + 32 * 1024, body.length);
response.write(body.subarray(offset, next));
offset = next;
}, 20);
response.once('close', () => clearInterval(timer));
}
function childExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
async function waitForChildExit(child, timeoutMs = 8000) {
if (childExited(child)) return true;
return new Promise(resolve => {
let settled = false;
const finish = result => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.off('exit', onExit);
resolve(result);
};
const onExit = () => finish(true);
const timer = setTimeout(() => finish(false), timeoutMs);
child.once('exit', onExit);
if (childExited(child)) finish(true);
});
}
async function stop(child, port, secret) {
if (!child || childExited(child)) return;
try {
await rpc(port, secret, 'aria2.shutdown');
} catch {
// It may already be stopping.
}
let exited = await waitForChildExit(child);
if (!exited) {
if (process.platform === 'win32') {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', timeout: 3000 });
} catch {
// It may have exited between the timeout and taskkill.
}
} else {
child.kill('SIGTERM');
}
exited = await waitForChildExit(child);
}
if (!exited && process.platform !== 'win32') {
child.kill('SIGKILL');
exited = await waitForChildExit(child);
}
if (!exited) throw new Error(`Aria2 process ${child.pid} did not exit after cleanup`);
}
async function removeTempRoot(tempRoot) {
let lastError;
for (let attempt = 1; attempt <= 10; attempt += 1) {
try {
fs.rmSync(tempRoot, { recursive: true, force: true });
return;
} catch (error) {
lastError = error;
await wait(100 * attempt);
}
}
throw lastError;
}
const payload = Buffer.alloc(4 * 1024 * 1024, 0x5a);
const checksum = crypto.createHash('sha256').update(payload).digest('hex');
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-aria2-transfers-'));
const serverStatPath = path.join(tempRoot, 'server-stat.txt');
fs.writeFileSync(serverStatPath, '', { mode: 0o600 });
let finalRequests = 0;
let finalCredentials = [];
let redirectSourceCredentials;
let smokePassed = false;
let smokeFailure;
const targetServer = http.createServer((request, response) => {
finalRequests += 1;
finalCredentials.push({
authorization: request.headers.authorization,
cookie: request.headers.cookie,
custom: request.headers['x-firelink-secret'],
});
serveBuffer(request, response, payload);
});
const targetPort = await listen(targetServer);
const fixtureServer = http.createServer((request, response) => {
switch (new URL(request.url, 'http://fixture.invalid').pathname) {
case '/range':
serveBuffer(request, response, payload);
break;
case '/no-range':
response.writeHead(200, { 'content-length': String(payload.length) });
response.end(payload);
break;
case '/throttled':
serveThrottledBuffer(request, response, payload);
break;
case '/authenticated': {
const expectedAuthorization = `Basic ${Buffer.from('fixture-user:fixture-password').toString('base64')}`;
if (request.headers.authorization !== expectedAuthorization
|| request.headers.cookie !== 'fixture-cookie=present'
|| request.headers['x-firelink-auth'] !== 'present') {
response.writeHead(401, { 'content-length': '0' });
response.end();
break;
}
serveBuffer(request, response, payload);
break;
}
case '/redirect':
redirectSourceCredentials = {
authorization: request.headers.authorization,
cookie: request.headers.cookie,
custom: request.headers['x-firelink-secret'],
};
response.writeHead(302, { location: `http://127.0.0.1:${targetPort}/final` });
response.end();
break;
case '/missing':
response.writeHead(404, { 'content-length': '0' });
response.end();
break;
case '/malformed':
response.writeHead(200, { 'content-length': String(payload.length * 2) });
response.write(payload.subarray(0, 1024));
response.destroy();
break;
case '/slow': {
const slowLength = 64 * 1024;
let written = 0;
response.writeHead(200, { 'content-length': String(slowLength) });
const timer = setInterval(() => {
if (response.destroyed || written >= slowLength) {
clearInterval(timer);
if (!response.destroyed) response.end();
return;
}
response.write(Buffer.alloc(1024, 0x73));
written += 1024;
}, 500);
response.once('close', () => clearInterval(timer));
break;
}
default:
response.writeHead(404, { 'content-length': '0' });
response.end();
}
});
const fixturePort = await listen(fixtureServer);
const rpcPort = await availablePort();
const unavailableProxyPort = await availablePort();
const secret = `firelink-transfers-${crypto.randomUUID()}`;
const configPath = path.join(tempRoot, 'aria2.conf');
fs.writeFileSync(configPath, `rpc-secret=${secret}\n`, { mode: 0o600 });
const libraryPath = path.join(path.dirname(binaryPath), 'aria2-libs');
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
const environment = fs.existsSync(libraryPath)
? {
...process.env,
OPENSSL_MODULES: libraryPath,
...(process.platform === 'darwin' ? { DYLD_LIBRARY_PATH: libraryPath } : {}),
...(process.platform === 'win32'
? { [pathKey]: `${libraryPath}${path.delimiter}${process.env[pathKey] || ''}` }
: {}),
}
: process.env;
const child = spawn(binaryPath, [
'--enable-rpc=true',
`--conf-path=${configPath}`,
`--rpc-listen-port=${rpcPort}`,
'--rpc-listen-all=false',
`--dir=${tempRoot}`,
'--file-allocation=none',
'--enable-dht=false',
'--console-log-level=error',
'--quiet=true',
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
`--server-stat-if=${serverStatPath}`,
`--server-stat-of=${serverStatPath}`,
], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk.toString(); });
try {
const version = await waitForRpc(rpcPort, secret);
assertAria2Baseline(version);
if (hasAria2RouteCapabilities(version)) {
// The fixture server is intentionally loopback. Disable only the custom
// target policy after the optional capabilities have been attested.
await rpc(rpcPort, secret, 'aria2.changeGlobalOption', [{ 'network-target-policy': 'none' }]);
}
console.log(`[INFO] aria2 ${version.version || 'unknown'} normal-transfer smoke (system resolver)`);
const rangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'range.bin', split: '4', 'max-connection-per-server': '4', 'min-split-size': '1M',
}]);
const rangeStatus = await waitForTerminal(rpcPort, secret, rangeGid);
if (rangeStatus.status !== 'complete' || !fs.readFileSync(path.join(tempRoot, 'range.bin')).equals(payload)) {
throw new Error(`bounded-range transfer failed: ${JSON.stringify(rangeStatus)}`);
}
const noRangeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/no-range`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'no-range.bin', split: '1', 'max-connection-per-server': '1',
}]);
if ((await waitForTerminal(rpcPort, secret, noRangeGid)).status !== 'complete') {
throw new Error('single-connection no-range transfer did not complete');
}
const authenticatedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/authenticated`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'authenticated.bin', 'http-user': 'fixture-user', 'http-passwd': 'fixture-password',
header: ['Cookie: fixture-cookie=present', 'X-Firelink-Auth: present'],
}]);
if ((await waitForTerminal(rpcPort, secret, authenticatedGid)).status !== 'complete') {
throw new Error('authenticated cookie/header transfer did not complete');
}
const resumeGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'resume.bin', split: '1', continue: 'true',
}]);
await waitForProgress(rpcPort, secret, resumeGid);
await rpc(rpcPort, secret, 'aria2.pause', [resumeGid]);
const pausedStatus = await rpc(rpcPort, secret, 'aria2.tellStatus', [resumeGid, ['status', 'completedLength']]);
if (pausedStatus.status !== 'paused' || Number(pausedStatus.completedLength) <= 0) {
throw new Error(`normal transfer did not pause with resumable progress: ${JSON.stringify(pausedStatus)}`);
}
await rpc(rpcPort, secret, 'aria2.unpause', [resumeGid]);
if ((await waitForTerminal(rpcPort, secret, resumeGid)).status !== 'complete') {
throw new Error('paused normal transfer did not resume to completion');
}
const cancelGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/throttled`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'cancel.bin', split: '1',
}]);
await waitForProgress(rpcPort, secret, cancelGid);
await rpc(rpcPort, secret, 'aria2.remove', [cancelGid]);
const cancelStatus = await waitForTerminal(rpcPort, secret, cancelGid);
if (cancelStatus.status !== 'removed') {
throw new Error(`normal transfer cancellation did not reach removed: ${JSON.stringify(cancelStatus)}`);
}
const mirrorGid = await rpc(rpcPort, secret, 'aria2.addUri', [[
`http://127.0.0.1:${fixturePort}/missing`,
`http://127.0.0.1:${fixturePort}/range`,
], { ...ARIA2_LOCAL_FIXTURE_OPTIONS, out: 'mirror.bin', split: '1', 'max-tries': '1', 'uri-selector': 'adaptive' }]);
const mirrorStatus = await waitForTerminal(rpcPort, secret, mirrorGid);
if (mirrorStatus.status !== 'complete') throw new Error(`adaptive mirror failover failed: ${JSON.stringify(mirrorStatus)}`);
const checksumGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'checksum.bin', checksum: `sha-256=${checksum}`, 'check-integrity': 'true',
}]);
if ((await waitForTerminal(rpcPort, secret, checksumGid)).status !== 'complete') {
throw new Error('valid checksum transfer did not complete');
}
const mismatchGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'checksum-mismatch.bin', checksum: `sha-256=${'0'.repeat(64)}`, 'check-integrity': 'true',
}]);
const mismatchStatus = await waitForTerminal(rpcPort, secret, mismatchGid);
if (mismatchStatus.status !== 'error') throw new Error(`checksum mismatch was not rejected: ${JSON.stringify(mismatchStatus)}`);
// Model Firelink's manual preflight: credentials reach the original origin,
// the redirect is not followed by the HTTP client, and Aria2 receives only
// the resolved cross-origin URL without credential options.
const redirectProbe = await fetch(`http://127.0.0.1:${fixturePort}/redirect`, {
headers: {
Range: 'bytes=0-0',
Authorization: 'Bearer fixture-secret',
Cookie: 'fixture=secret',
'X-Firelink-Secret': 'fixture',
},
redirect: 'manual',
signal: AbortSignal.timeout(5000),
});
if (redirectProbe.status !== 302) throw new Error(`redirect preflight returned HTTP ${redirectProbe.status}`);
if (!redirectSourceCredentials || Object.values(redirectSourceCredentials).some(value => !value)) {
throw new Error(`redirect source did not receive its scoped credentials: ${JSON.stringify(redirectSourceCredentials)}`);
}
const redirectLocation = redirectProbe.headers.get('location');
if (!redirectLocation) throw new Error('redirect preflight returned no Location header');
const resolvedRedirect = new URL(redirectLocation, redirectProbe.url);
const redirectGid = await rpc(rpcPort, secret, 'aria2.addUri', [[resolvedRedirect.toString()], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'redirect.bin',
}]);
const redirectStatus = await waitForTerminal(rpcPort, secret, redirectGid);
if (redirectStatus.status !== 'complete' || finalRequests === 0 || finalCredentials.some(headers => Object.values(headers).some(Boolean))) {
throw new Error(`redirect credential boundary failed: ${JSON.stringify({ redirectStatus, finalRequests, finalCredentials })}`);
}
const missingGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/missing`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'missing.bin', 'max-tries': '1',
}]);
const missingStatus = await waitForTerminal(rpcPort, secret, missingGid);
if (missingStatus.status !== 'error' || !['3', '4'].includes(missingStatus.errorCode)) {
throw new Error(`not-found error classification changed: ${JSON.stringify(missingStatus)}`);
}
const lowSpeedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/slow`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'low-speed.bin', 'max-tries': '1', 'lowest-speed-limit': '1M', timeout: '20',
}]);
const lowSpeedStatus = await waitForTerminal(rpcPort, secret, lowSpeedGid, 25000);
if (lowSpeedStatus.status !== 'error' || lowSpeedStatus.errorCode !== '5') {
throw new Error(`low-speed error classification changed: ${JSON.stringify(lowSpeedStatus)}`);
}
const malformedGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/malformed`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'malformed.bin', 'max-tries': '1',
}]);
if ((await waitForTerminal(rpcPort, secret, malformedGid)).status !== 'error') {
throw new Error('malformed response unexpectedly completed');
}
const proxyGid = await rpc(rpcPort, secret, 'aria2.addUri', [[`http://127.0.0.1:${fixturePort}/range`], {
...ARIA2_LOCAL_FIXTURE_OPTIONS,
out: 'proxy.bin', 'all-proxy': `http://127.0.0.1:${unavailableProxyPort}`, 'max-tries': '1',
}]);
if ((await waitForTerminal(rpcPort, secret, proxyGid)).status !== 'error') {
throw new Error('unavailable proxy unexpectedly completed');
}
smokePassed = true;
} catch (error) {
const detail = stderr.trim();
smokeFailure = new Error(`${error.message}${detail ? `\n${detail}` : ''}`);
} finally {
try {
await stop(child, rpcPort, secret);
if (smokePassed) {
const stat = fs.readFileSync(serverStatPath, 'utf8');
if (!stat.includes('host=127.0.0.1')) {
throw new Error(`Aria2 did not persist adaptive mirror statistics: ${JSON.stringify(stat)}`);
}
}
} catch (error) {
if (!smokeFailure) smokeFailure = error;
} finally {
try {
await Promise.all([
new Promise(resolve => fixtureServer.close(resolve)),
new Promise(resolve => targetServer.close(resolve)),
]);
await removeTempRoot(tempRoot);
} catch (error) {
if (!smokeFailure) smokeFailure = error;
}
}
}
if (smokeFailure) throw smokeFailure;
console.log('[PASS] Aria2 normal transfers, auth/cookies, resume/cancel, mirrors, integrity, redirects, low-speed/not-found classification, malformed responses, proxy failures, and server statistics');
+276 -26
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env node
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
function argValue(name) {
const index = process.argv.indexOf(name);
@@ -15,12 +17,24 @@ if (!executableArg) {
const executable = path.resolve(executableArg);
const assertNoVisibleChildWindows = process.argv.includes('--assert-no-visible-child-windows');
const assertPortableData = process.argv.includes('--assert-portable-data');
const MAX_STABILITY_MS = 60_000;
const MAX_CONSECUTIVE_STABILITY_FAILURES = 3;
const stabilityMsValue = Number.parseInt(argValue('--stability-ms') || '5000', 10);
const stabilityMs = Number.isFinite(stabilityMsValue) && stabilityMsValue >= 0
? Math.min(stabilityMsValue, MAX_STABILITY_MS)
: 5000;
const READY_PORT_TIMEOUT_MS = 500;
// Portable-package checks intentionally inspect their disposable bundle's
// data directory. Every other smoke run gets its own disposable profile.
const smokeStorageRoot = assertPortableData ? null : fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-smoke-')));
const child = spawn(executable, [], {
cwd: process.env.RUNNER_TEMP || process.env.TMPDIR || process.cwd(),
detached: process.platform !== 'win32',
env: {
...process.env,
FIRELINK_SMOKE_TEST: '1',
FIRELINK_SMOKE_STORAGE_ROOT: smokeStorageRoot || '',
WEBKIT_DISABLE_COMPOSITING_MODE: '1',
GDK_BACKEND: 'x11',
},
@@ -30,12 +44,14 @@ const child = spawn(executable, [], {
let stderr = '';
let spawnError = null;
let readyPort = null;
let childExit = null;
child.on('error', error => {
spawnError = error;
});
child.on('exit', (code, signal) => {
childExit = { code, signal };
if (readyPort === null) {
console.error(`Child exited prematurely with code ${code} signal ${signal}`);
}
@@ -44,6 +60,7 @@ child.on('exit', (code, signal) => {
child.stderr.on('data', data => {
stderr += data.toString();
});
child.stdout.on('data', () => {});
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
@@ -51,19 +68,25 @@ function sleep(ms) {
async function findReadyPort() {
for (let attempt = 0; attempt < 200 && readyPort === null; attempt += 1) {
if (spawnError) {
if (spawnError || childExit) {
break;
}
for (let port = 6412; port <= 6422; port += 1) {
const ports = Array.from({ length: 11 }, (_, index) => 6412 + index);
const matches = await Promise.all(ports.map(async port => {
try {
const response = await fetch(`http://127.0.0.1:${port}/ping`);
if (response.headers.get('x-firelink-server') === '1') {
readyPort = port;
break;
}
} catch {}
}
const response = await fetch(`http://127.0.0.1:${port}/ping`, {
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
});
const matchesChild = response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
await response.body?.cancel();
return matchesChild ? port : null;
} catch {
return null;
}
}));
readyPort = matches.find(port => port !== null) ?? null;
if (readyPort === null) {
await sleep(250);
@@ -71,6 +94,55 @@ async function findReadyPort() {
}
}
async function checkReadyPort() {
if (readyPort === null) return false;
try {
const response = await fetch(`http://127.0.0.1:${readyPort}/ping`, {
signal: AbortSignal.timeout(READY_PORT_TIMEOUT_MS),
});
const matchesChild = response.headers.get('x-firelink-server') === '1'
&& response.headers.get('x-firelink-smoke-process-id') === String(child.pid);
await response.body?.cancel();
return matchesChild;
} catch {
return false;
}
}
async function assertStableReady() {
const deadline = Date.now() + stabilityMs;
let consecutiveFailures = 0;
while (Date.now() < deadline) {
if (spawnError) {
throw new Error(`Packaged Firelink failed during stability check: ${spawnError.message}`);
}
if (childExit) {
throw new Error(
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
);
}
if (await checkReadyPort()) {
consecutiveFailures = 0;
} else {
consecutiveFailures += 1;
if (consecutiveFailures >= MAX_CONSECUTIVE_STABILITY_FAILURES) {
throw new Error('Packaged Firelink stopped exposing its extension ping endpoint during stability check.');
}
}
await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
}
if (childExit) {
throw new Error(
`Packaged Firelink exited during stability check with code ${childExit.code} signal ${childExit.signal}.`,
);
}
if (!await checkReadyPort() && !await checkReadyPort()) {
throw new Error('Packaged Firelink was not healthy at the end of its stability check.');
}
}
function assertNoVisibleWindows(rootPid) {
if (process.platform !== 'win32') {
return;
@@ -120,43 +192,221 @@ if ($visible.Count -gt 0) {
}
}
function terminateChild() {
if (!child.pid) {
return;
function waitForChildExit(timeoutMs) {
if (childExit) {
return Promise.resolve(true);
}
return new Promise(resolve => {
let timer;
const onExit = () => {
clearTimeout(timer);
child.off('exit', onExit);
resolve(true);
};
timer = setTimeout(() => {
child.off('exit', onExit);
resolve(false);
}, timeoutMs);
child.once('exit', onExit);
});
}
function isProcessGroupAlive(rootPid) {
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
return;
return false;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill('SIGTERM');
process.kill(-rootPid, 0);
return true;
} catch (error) {
return error?.code !== 'ESRCH';
}
}
await findReadyPort();
async function waitForProcessGroupExit(rootPid, timeoutMs) {
if (process.platform === 'win32') {
return true;
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessGroupAlive(rootPid)) {
return true;
}
await sleep(100);
}
return !isProcessGroupAlive(rootPid);
}
function windowsBundleRoot() {
return path.dirname(executable).replaceAll("'", "''");
}
function windowsBundleProcessIds() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
Select-Object -ExpandProperty ProcessId
`;
try {
const output = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return output
.split(/\r?\n/)
.map(value => Number.parseInt(value.trim(), 10))
.filter(Number.isInteger);
} catch {
return null;
}
}
function terminateWindowsBundleProcesses() {
const root = windowsBundleRoot();
const script = `
$root = '${root}'
$rootPrefix = $root.TrimEnd('\\') + '\\'
Get-CimInstance Win32_Process |
Where-Object {
$commandLine = $_.CommandLine
$commandLine -and $commandLine.TrimStart([char]34).StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
`;
try {
execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
async function waitForWindowsBundleExit(timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const processIds = windowsBundleProcessIds();
if (processIds?.length === 0) {
return true;
}
await sleep(100);
}
return windowsBundleProcessIds()?.length === 0;
}
async function terminateChild() {
if (!child.pid) {
return true;
}
const childWasRunning = !childExit;
if (process.platform === 'win32') {
if (childWasRunning) {
try {
execFileSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true,
});
} catch {}
}
const childExited = await waitForChildExit(10000);
const bundleExited = await waitForWindowsBundleExit(5000);
if (childExited && bundleExited) {
return true;
}
terminateWindowsBundleProcesses();
return await waitForChildExit(5000) && await waitForWindowsBundleExit(5000);
}
if (childWasRunning && !childExit) {
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
if (!childExit) {
child.kill('SIGTERM');
}
}
}
const childExited = await waitForChildExit(5000);
const processGroupExited = await waitForProcessGroupExit(child.pid, 5000);
if (childExited && processGroupExited) {
return true;
}
try {
process.kill(-child.pid, 'SIGKILL');
} catch {
if (!childExited) {
child.kill('SIGKILL');
}
}
return await waitForChildExit(5000) && await waitForProcessGroupExit(child.pid, 5000);
}
async function assertPortableStorage() {
const portableRoot = path.dirname(executable);
const marker = path.join(portableRoot, 'portable.flag');
const database = path.join(portableRoot, 'data', 'firelink.sqlite');
const webviewData = path.join(portableRoot, 'data', 'webview');
for (let attempt = 0; attempt < 40; attempt += 1) {
const markerReady = fs.statSync(marker, { throwIfNoEntry: false })?.isFile();
const databaseReady = fs.statSync(database, { throwIfNoEntry: false })?.isFile();
const webviewReady = fs.statSync(webviewData, { throwIfNoEntry: false })?.isDirectory();
if (markerReady && databaseReady && webviewReady) {
return;
}
await sleep(250);
}
throw new Error(
`Portable storage was not ready: marker=${marker}, database=${database}, webview=${webviewData}`,
);
}
try {
await findReadyPort();
if (readyPort === null) {
if (spawnError) {
console.error(`Packaged Firelink failed to start: ${spawnError.message}`);
throw new Error(`Packaged Firelink failed to start: ${spawnError.message}`);
} else if (childExit) {
throw new Error(
`Packaged Firelink exited before exposing extension ping endpoint with code ${childExit.code} signal ${childExit.signal}.`,
);
} else {
console.error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
throw new Error(`Packaged Firelink did not expose extension ping endpoint. Stderr:\n${stderr.slice(-1000)}`);
}
process.exit(1);
}
if (assertNoVisibleChildWindows) {
assertNoVisibleWindows(child.pid);
}
if (assertPortableData) {
await assertPortableStorage();
}
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort}`);
await assertStableReady();
console.log(`Packaged Firelink smoke passed on 127.0.0.1:${readyPort} with ${stabilityMs}ms stability`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
} finally {
terminateChild();
if (!await terminateChild()) {
console.error('Packaged Firelink could not be terminated cleanly; refusing to report smoke success.');
process.exitCode = 1;
} else if (smokeStorageRoot) {
fs.rmSync(smokeStorageRoot, { recursive: true, force: true });
}
}
File diff suppressed because it is too large Load Diff
+66 -65
View File
@@ -1,33 +1,29 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256, treeDigest } from './engine-payload-integrity.js';
import { sha256, treeDigest } from './engine-payload-integrity.js';
import { readAndValidatePayloadManifest } from './engine-payload-manifest.js';
import { promoteDirectory, removePathWithRetry } from './engine-payload-promotion.js';
import {
assertSafeOutputRoot,
resolveOutputRoot,
resolveTargetTriple,
} from './engine-workspace.js';
import { assertAria2RouteSource } from './aria2-route-contract.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const binariesRoot = path.join(repoRoot, 'src-tauri', 'binaries');
const outputRoot = path.join(repoRoot, 'src-tauri', 'engine-dist');
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engines.lock.json'), 'utf8'));
const sourceLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8'));
const archMap = { x64: 'x86_64', arm64: 'aarch64' };
const platformMap = {
darwin: 'apple-darwin',
win32: 'pc-windows-msvc',
linux: 'unknown-linux-gnu',
};
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const hostTarget = `${archMap[os.arch()]}-${platformMap[os.platform()]}`;
const target = argValue('--target')
|| process.env.TAURI_ENV_TARGET_TRIPLE
|| process.env.FIRELINK_TARGET_TRIPLE
|| hostTarget;
const target = resolveTargetTriple();
const outputRoot = assertSafeOutputRoot(resolveOutputRoot(), [
repoRoot,
path.join(repoRoot, 'src-tauri'),
path.join(repoRoot, 'src-tauri', 'engine-dist'),
]);
const isWindowsTarget = target.includes('windows');
const suffix = isWindowsTarget ? '.exe' : '';
const engines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
@@ -52,6 +48,15 @@ if (!source) {
}
if (targetLock) {
if (targetLock.engines?.aria2c?.firelinkRouteContract) {
try {
assertAria2RouteSource(targetLock.engines.aria2c, target);
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
for (const engine of engines) {
const name = `${engine}-${target}${suffix}`;
const expected = targetLock.engines?.[engine]?.sha256;
@@ -75,58 +80,54 @@ if (targetLock) {
}
}
} else {
const manifestPath = path.join(source, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
console.error(`No committed lock or payload manifest exists for ${target}.`);
const sourceTargetLock = sourceLock.targets?.[target];
if (!sourceTargetLock) {
console.error(`No source lock exists for the provisioned engine target ${target}.`);
process.exit(1);
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
if (manifest.target !== target) {
console.error(`Payload manifest target mismatch: ${manifest.target}`);
try {
const manifest = readAndValidatePayloadManifest(source, sourceTargetLock, target);
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
assertAria2RouteSource(manifest.generatedFrom.aria2c, target);
}
} catch (error) {
console.error(error.message);
process.exit(1);
}
for (const [relative, expected] of Object.entries(manifest.files || {})) {
const file = path.join(source, relative);
if (!fs.existsSync(file) || sha256(file) !== expected) {
console.error(`Payload manifest mismatch: ${relative}`);
process.exit(1);
}
fs.mkdirSync(outputRoot, { recursive: true, mode: 0o700 });
const destination = path.join(outputRoot, target);
const temporaryRoot = fs.mkdtempSync(path.join(outputRoot, `.staging-${target}-${process.pid}-`));
const temporaryDestination = path.join(temporaryRoot, target);
try {
fs.mkdirSync(temporaryDestination, { recursive: true, mode: 0o700 });
for (const name of expectedNames) {
fs.copyFileSync(path.join(source, name), path.join(temporaryDestination, name));
if (!isWindowsTarget) {
fs.chmodSync(path.join(temporaryDestination, name), 0o755);
}
}
const actualFiles = collectRegularFiles(source, {
ignoredNames: ['payload-manifest.json'],
}).map(file => path.relative(source, file).split(path.sep).join('/'));
const expectedFiles = Object.keys(manifest.files || {}).sort();
actualFiles.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
console.error(`Payload contains files not covered by manifest for ${target}.`);
process.exit(1);
for (const runtimeDir of ['_internal', 'aria2-libs']) {
const sourceDir = path.join(source, runtimeDir);
if (fs.existsSync(sourceDir)) {
fs.cpSync(sourceDir, path.join(temporaryDestination, runtimeDir), {
recursive: true,
dereference: false,
preserveTimestamps: true,
});
}
}
}
const destination = path.join(outputRoot, target);
fs.rmSync(outputRoot, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const name of expectedNames) {
fs.copyFileSync(path.join(source, name), path.join(destination, name));
if (!isWindowsTarget) {
fs.chmodSync(path.join(destination, name), 0o755);
const payloadManifest = path.join(source, 'payload-manifest.json');
if (fs.existsSync(payloadManifest)) {
fs.copyFileSync(payloadManifest, path.join(temporaryDestination, 'payload-manifest.json'));
}
await promoteDirectory(temporaryDestination, destination);
} finally {
await removePathWithRetry(temporaryRoot);
}
for (const runtimeDir of ['_internal', 'aria2-libs']) {
const sourceDir = path.join(source, runtimeDir);
if (fs.existsSync(sourceDir)) {
fs.cpSync(sourceDir, path.join(destination, runtimeDir), {
recursive: true,
dereference: false,
preserveTimestamps: true,
});
}
}
const payloadManifest = path.join(source, 'payload-manifest.json');
if (fs.existsSync(payloadManifest)) {
fs.copyFileSync(payloadManifest, path.join(destination, 'payload-manifest.json'));
}
console.log(`Staged Firelink engines for ${target} from ${source}`);
console.log(`Staged Firelink engines for ${target} from ${source} into ${destination}`);
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import {
createEngineWorkspace,
engineResourceConfig,
removeEngineWorkspace,
resolveTargetTriple,
} from './engine-workspace.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '..');
const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
const ENGINE_TREE_COMMANDS = new Set(['dev', 'build', 'bundle']);
export function commandUsesEngineTree(args) {
return args.some(argument => ENGINE_TREE_COMMANDS.has(argument));
}
export function commandIsStandaloneBundle(args) {
return args.includes('bundle');
}
function signalExitCode(signal) {
return {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143,
}[signal] ?? 1;
}
const args = process.argv.slice(2);
const usesEngineWorkspace = commandUsesEngineTree(args);
let engineWorkspace;
let child;
let receivedSignal;
let escalationTimer;
let interruptedProcessPid;
function windowsTaskkillPath() {
const systemRoot = process.env.SystemRoot || process.env.WINDIR;
return systemRoot ? path.join(systemRoot, 'System32', 'taskkill.exe') : 'taskkill.exe';
}
function forceTerminateProcessTree(pid) {
if (!pid) return Promise.resolve();
if (process.platform !== 'win32') {
try {
process.kill(-pid, 'SIGKILL');
} catch (error) {
if (error?.code !== 'ESRCH') {
console.error(`[WARN] Could not force-terminate the Tauri process group: ${error.message}`);
}
}
return Promise.resolve();
}
return new Promise(resolve => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
let killer;
try {
killer = spawn(
windowsTaskkillPath(),
['/PID', String(pid), '/T', '/F'],
{ stdio: 'ignore', windowsHide: true },
);
} catch {
finish();
return;
}
killer.once('error', finish);
killer.once('close', finish);
});
}
function handleSignal(signal) {
receivedSignal ??= signal;
if (child && child.exitCode === null && child.signalCode === null) {
const pid = child.pid;
interruptedProcessPid ??= pid;
if (process.platform === 'win32') {
// Node's Windows child.kill() does not reliably terminate descendants.
// taskkill's process-tree mode is the OS-supported equivalent of the
// POSIX process-group kill used below.
void forceTerminateProcessTree(pid);
return;
}
try {
if (!pid) {
child.kill(signal);
} else {
process.kill(-pid, signal);
}
} catch (error) {
if (error?.code !== 'ESRCH') {
console.error(`[WARN] Could not terminate the Tauri process group: ${error.message}`);
}
}
if (!escalationTimer) {
escalationTimer = setTimeout(() => {
void forceTerminateProcessTree(pid);
}, 2_000);
escalationTimer.unref();
}
return;
}
process.exitCode = signalExitCode(signal);
}
function runChild(command, commandArgs, env) {
const spawned = spawn(command, commandArgs, {
cwd: repoRoot,
env,
stdio: 'inherit',
windowsHide: true,
detached: process.platform !== 'win32',
});
child = spawned;
return new Promise((resolve, reject) => {
let settled = false;
const settle = callback => value => {
if (settled) return;
settled = true;
if (child === spawned) child = undefined;
callback(value);
};
spawned.once('error', settle(reject));
spawned.once('close', (code, signal) => settle(resolve)({ code, signal }));
});
}
async function run() {
const handlers = new Map(['SIGHUP', 'SIGINT', 'SIGTERM'].map(signal => [
signal,
() => handleSignal(signal),
]));
for (const [signal, handler] of handlers) process.once(signal, handler);
try {
const env = { ...process.env };
let commandArgs = args;
if (usesEngineWorkspace) {
const target = resolveTargetTriple(args, env);
engineWorkspace = createEngineWorkspace(target);
env.FIRELINK_ENGINE_WORKSPACE = engineWorkspace.workspace;
env.FIRELINK_ENGINE_OUTPUT_ROOT = engineWorkspace.outputRoot;
env.FIRELINK_ENGINE_RUNTIME_ROOT = engineWorkspace.runtimeRoot;
env.FIRELINK_TARGET_TRIPLE = target;
if (
(args.includes('build') || args.includes('bundle'))
&& env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1'
) {
commandArgs = [...args, '--config', engineResourceConfig(engineWorkspace.outputRoot)];
}
}
if (receivedSignal) return;
if (usesEngineWorkspace && commandIsStandaloneBundle(args) && env.FIRELINK_SKIP_ENGINE_RESOURCE !== '1') {
let preparation;
try {
preparation = await runChild(
process.execPath,
[path.join(repoRoot, 'scripts', 'prepare-tauri-engines.js')],
env,
);
} catch (error) {
throw new Error(`Engine preparation failed: ${error.message}`, { cause: error });
}
if (receivedSignal) return;
if (preparation.signal) {
process.exitCode = signalExitCode(preparation.signal);
return;
}
if (preparation.code !== 0) {
process.exitCode = preparation.code ?? 1;
return;
}
env.FIRELINK_ENGINE_BUNDLE_PREPARED = '1';
}
if (receivedSignal) return;
const result = await runChild(process.execPath, [tauriCli, ...commandArgs], env);
if (receivedSignal) {
process.exitCode = signalExitCode(receivedSignal);
} else if (result.signal) {
process.exitCode = signalExitCode(result.signal);
} else {
process.exitCode = result.code ?? 1;
}
} finally {
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
if (escalationTimer) clearTimeout(escalationTimer);
if (interruptedProcessPid) await forceTerminateProcessTree(interruptedProcessPid);
if (engineWorkspace) {
try {
await removeEngineWorkspace(engineWorkspace.workspace);
} catch (error) {
console.error(`[WARN] Could not remove the temporary engine workspace: ${error.message}`);
}
}
}
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
run().catch(error => {
console.error(`[FAIL] Tauri command failed: ${error.message}`);
process.exitCode = 1;
});
}
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import test from 'node:test';
import { commandIsStandaloneBundle, commandUsesEngineTree } from './tauri-command.js';
test('Tauri engine-consuming commands use an engine workspace', () => {
assert.equal(commandUsesEngineTree(['dev']), true);
assert.equal(commandUsesEngineTree(['build', '--target', 'x86_64-unknown-linux-gnu']), true);
assert.equal(commandUsesEngineTree(['bundle', '--bundles', 'appimage']), true);
assert.equal(commandUsesEngineTree(['info']), false);
assert.equal(commandUsesEngineTree(['--help']), false);
});
test('standalone bundle commands prepare engines before Tauri starts', () => {
assert.equal(commandIsStandaloneBundle(['bundle', '--bundles', 'app']), true);
assert.equal(commandIsStandaloneBundle(['build', '--bundles', 'app']), false);
});
test('the bundle hook accepts a completed wrapper preflight', () => {
const result = spawnSync(
process.execPath,
[path.join(import.meta.dirname, 'before-tauri-bundle.js')],
{
cwd: path.join(import.meta.dirname, '..'),
env: {
...process.env,
FIRELINK_ENGINE_BUNDLE_PREPARED: '1',
FIRELINK_SKIP_ENGINE_RESOURCE: '',
FIRELINK_ENGINE_OUTPUT_ROOT: '',
},
stdio: 'pipe',
},
);
assert.equal(result.status, 0, result.stderr.toString());
});
+246 -56
View File
@@ -2,8 +2,20 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { execFileSync, spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import {
resolveOutputRoot,
resolveTargetTriple,
} from './engine-workspace.js';
import {
ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
assertAria2Baseline,
assertAria2AllocationCapabilities,
assertAria2RouteSource,
} from './aria2-route-contract.js';
import { readAndValidatePayloadManifest } from './engine-payload-manifest.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -28,9 +40,7 @@ if (!currentArch || !currentPlatform) {
process.exit(1);
}
const targetTriple = argValue('--target')
|| process.env.FIRELINK_TARGET_TRIPLE
|| `${currentArch}-${currentPlatform}`;
const targetTriple = resolveTargetTriple();
const hostTriple = `${currentArch}-${currentPlatform}`;
const canExecuteTarget = targetTriple === hostTriple;
const isWindows = targetTriple.includes('windows');
@@ -40,20 +50,42 @@ const ext = isWindows ? '.exe' : '';
const suffix = `-${targetTriple}${ext}`;
const scriptsDir = __dirname;
const repoRoot = path.resolve(__dirname, '..');
const sourceLock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'engine-sources.lock.json'), 'utf8')
);
const searchRoot = argValue('--search-root');
function findEngineRoot(root) {
const expected = `yt-dlp-${targetTriple}${ext}`;
const matches = [];
const resolvedRoot = path.resolve(root);
const hasExpectedEngineFile = directory => {
try {
return fs.lstatSync(path.join(directory, expected)).isFile();
} catch (error) {
if (error?.code === 'ENOENT') return false;
throw new Error(`Unable to inspect packaged engine root '${directory}': ${error.message}`);
}
};
if (hasExpectedEngineFile(resolvedRoot)) {
matches.push(resolvedRoot);
}
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`Unable to inspect packaged engine directory '${directory}': ${error.message}`);
}
for (const entry of entries) {
const candidate = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (fs.existsSync(path.join(candidate, expected))) matches.push(candidate);
if (hasExpectedEngineFile(candidate)) matches.push(candidate);
walk(candidate);
}
}
};
walk(path.resolve(root));
walk(resolvedRoot);
if (matches.length !== 1) {
throw new Error(`Expected exactly one packaged engine root under ${root}, found ${matches.length}`);
}
@@ -62,7 +94,7 @@ function findEngineRoot(root) {
const configuredRoot = argValue('--root')
|| (process.argv.includes('--staged')
? path.join(scriptsDir, '..', 'src-tauri', 'engine-dist', targetTriple)
? path.join(resolveOutputRoot(), targetTriple)
: searchRoot
? findEngineRoot(searchRoot)
: null);
@@ -70,6 +102,8 @@ const binariesDir = configuredRoot
? path.resolve(configuredRoot)
: path.join(scriptsDir, '..', 'src-tauri', 'binaries');
const requiredEngines = ['yt-dlp', 'aria2c', 'ffmpeg', 'deno'];
const stagedVerification = process.argv.includes('--staged');
const sourceTargetLock = sourceLock.targets?.[targetTriple];
const FORBIDDEN_OTOOL_PATHS = ['/opt/homebrew', '/usr/local/Cellar'];
const FORBIDDEN_STDERR = [
@@ -90,6 +124,23 @@ function ok(msg) {
console.log(`[OK] ${msg}`);
}
if (sourceTargetLock) {
try {
const manifest = readAndValidatePayloadManifest(binariesDir, sourceTargetLock, targetTriple);
if (manifest.generatedFrom?.aria2c?.firelinkRouteContract) {
assertAria2RouteSource(manifest.generatedFrom.aria2c, targetTriple);
}
ok('Payload manifest provenance and checksums');
} catch (error) {
fail(error.message);
}
}
if (exitCode !== 0) {
console.error('\nAborting: engine payload integrity checks failed.');
process.exit(1);
}
function rejectSymlinks(root, label) {
if (!fs.existsSync(root)) {
return;
@@ -133,9 +184,13 @@ function engineEnv(engine) {
return process.env;
}
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
return {
...process.env,
OPENSSL_MODULES: modulesDir,
...(process.platform === 'win32'
? { [pathKey]: `${modulesDir}${path.delimiter}${process.env[pathKey] || ''}` }
: {}),
};
}
@@ -317,6 +372,82 @@ function runEngine(label, engine, args, timeout = 30000) {
}
}
function waitForProcessExit(proc, timeoutMs) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return Promise.resolve(true);
}
return new Promise(resolve => {
let settled = false;
let timer;
const finish = value => {
if (settled) return;
settled = true;
clearTimeout(timer);
proc.removeListener('exit', onExit);
resolve(value);
};
const onExit = () => finish(true);
timer = setTimeout(() => finish(false), timeoutMs);
proc.once('exit', onExit);
if (proc.exitCode !== null || proc.signalCode !== null) {
finish(true);
}
});
}
async function terminateProcess(proc, label) {
if (proc.exitCode === null && proc.signalCode === null) {
try {
proc.kill('SIGTERM');
} catch {}
if (await waitForProcessExit(proc, 2000)) {
return true;
}
try {
proc.kill('SIGKILL');
} catch {}
if (await waitForProcessExit(proc, 2000)) {
return true;
}
fail(`${label} did not terminate after SIGTERM and SIGKILL.`);
return false;
}
return true;
}
function findAvailablePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen({ host: '127.0.0.1', port: 0 }, () => {
const address = server.address();
if (!address || typeof address === 'string') {
server.close();
reject(new Error('Could not determine the verifier RPC port.'));
return;
}
const port = address.port;
server.close(error => {
if (error) {
reject(error);
} else {
resolve(port);
}
});
});
});
}
function isPortBindingFailure(message) {
return /address already in use|failed to bind|could not bind|listen failed/i.test(message);
}
const coldStartTimeout = isMacOS ? 120000 : 30000;
if (canExecuteTarget) {
runEngine('yt-dlp cold start', 'yt-dlp', ['--version'], coldStartTimeout);
@@ -345,25 +476,6 @@ if (canExecuteTarget) {
return;
}
const port = 16801 + (process.pid % 1000);
const proc = spawn(p, [
'--enable-rpc',
`--rpc-listen-port=${port}`,
'--rpc-max-request-size=1K',
'--quiet',
'--console-log-level=error',
'--rpc-listen-all=false',
], {
env: engineEnv('aria2c'),
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 15000,
});
let rpcStderr = '';
proc.stderr.on('data', (d) => {
rpcStderr += d.toString();
});
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'firelink-verify',
@@ -371,44 +483,122 @@ if (canExecuteTarget) {
params: [],
});
const result = await new Promise((resolve) => {
const maxAttempts = 20;
let attempts = 0;
function tryFetch() {
attempts++;
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
})
.then(async (res) => {
resolve({ ok: true, data: await res.text() });
})
.catch(() => {
if (attempts >= maxAttempts) {
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
return;
}
setTimeout(tryFetch, 300);
});
let result = { ok: false, error: 'RPC test did not run.' };
let rpcStderr = '';
const maxPortAttempts = 3;
for (let portAttempt = 0; portAttempt < maxPortAttempts; portAttempt += 1) {
let port;
try {
port = await findAvailablePort();
} catch (error) {
result = { ok: false, error: `Could not reserve an RPC port: ${error.message}` };
break;
}
tryFetch();
});
const proc = spawn(p, [
'--enable-rpc',
`--rpc-listen-port=${port}`,
'--rpc-max-request-size=1K',
'--quiet',
'--console-log-level=error',
'--rpc-listen-all=false',
...ARIA2_SYSTEM_RESOLVER_DAEMON_ARGS,
], {
env: engineEnv('aria2c'),
stdio: ['ignore', 'ignore', 'pipe'],
timeout: 15000,
});
// Clean up
proc.kill('SIGTERM');
setTimeout(() => {
try {
proc.kill('SIGKILL');
} catch {}
}, 2000);
let spawnError = null;
let childExit = null;
let attemptStderr = '';
proc.once('error', error => {
spawnError = error;
});
proc.once('exit', (code, signal) => {
childExit = { code, signal };
});
proc.stderr.on('data', data => {
attemptStderr += data.toString();
});
const attemptResult = await new Promise(resolve => {
const maxAttempts = 20;
let attempts = 0;
function resolveProcessFailure() {
if (spawnError) {
resolve({ ok: false, error: `aria2c failed to spawn: ${spawnError.message}` });
} else if (childExit) {
resolve({
ok: false,
error: `aria2c exited before RPC became ready with code ${childExit.code} signal ${childExit.signal}.`,
});
}
}
function tryFetch() {
if (spawnError || childExit) {
resolveProcessFailure();
return;
}
attempts += 1;
fetch(`http://127.0.0.1:${port}/jsonrpc`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
})
.then(async response => {
resolve({ ok: true, data: await response.text() });
})
.catch(() => {
if (spawnError || childExit) {
resolveProcessFailure();
} else if (attempts >= maxAttempts) {
resolve({ ok: false, error: `RPC not ready after ${maxAttempts} attempts` });
} else {
setTimeout(tryFetch, 300);
}
});
}
tryFetch();
});
const exitedBeforeCleanup = childExit;
const terminated = proc.pid
? await terminateProcess(proc, 'aria2 RPC verifier process')
: true;
rpcStderr = attemptStderr;
result = attemptResult;
if (
result.ok
&& exitedBeforeCleanup
&& (exitedBeforeCleanup.code !== 0 || exitedBeforeCleanup.signal !== null)
) {
result = {
ok: false,
error: `aria2c exited after responding with code ${exitedBeforeCleanup.code} signal ${exitedBeforeCleanup.signal}.`,
};
}
if (!terminated || result.ok || !isPortBindingFailure(`${result.error || ''}\n${rpcStderr}`)) {
break;
}
if (portAttempt + 1 < maxPortAttempts) {
console.log(`[INFO] RPC port ${port} became unavailable; retrying with a new port.`);
}
}
if (result.ok) {
try {
const resp = JSON.parse(result.data);
if (resp?.result?.version) {
assertAria2Baseline(resp.result);
assertAria2AllocationCapabilities(resp.result);
ok(`aria2 RPC version: ${resp.result.version}`);
} else {
fail(`aria2 RPC unexpected response: ${result.data}`);
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const defaultRepositoryRoot = path.resolve(scriptDirectory, '..');
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
export function exactVersionTag(extensionRoot, expectedTag) {
try {
const tags = execFileSync(
'git',
['-C', extensionRoot, 'tag', '--points-at', 'HEAD', '--list', '--', expectedTag],
{
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
env: {
...process.env,
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
},
}
)
.split(/\r?\n/)
.map(tag => tag.trim())
.find(tag => tag === expectedTag);
return tags || null;
} catch {
return null;
}
}
export function verifyCompanionRelease({
repositoryRoot = defaultRepositoryRoot,
resolveExactTag = exactVersionTag,
requireExactTag = true,
} = {}) {
const extensionRoot = path.join(repositoryRoot, 'Extensions', 'Browser');
const packagePath = path.join(extensionRoot, 'package.json');
const manifestPath = path.join(extensionRoot, 'manifest.json');
if (!fs.existsSync(packagePath) || !fs.existsSync(manifestPath)) {
throw new Error('Companion package.json and manifest.json must both exist.');
}
const packageVersion = readJson(packagePath).version;
const manifestVersion = readJson(manifestPath).version;
if (!packageVersion || packageVersion !== manifestVersion) {
throw new Error(
`Companion versions do not agree: package.json=${packageVersion || 'missing'}, ` +
`manifest.json=${manifestVersion || 'missing'}.`
);
}
if (typeof packageVersion !== 'string' || !SEMVER.test(packageVersion)) {
throw new Error(`Companion version ${String(packageVersion)} is not a valid semantic version.`);
}
const expectedTag = `v${packageVersion}`;
if (!requireExactTag) {
return { tag: null, version: packageVersion };
}
const tag = resolveExactTag(extensionRoot, expectedTag);
if (!tag) {
throw new Error(
`Companion HEAD is not exactly tagged ${expectedTag}; publish the Companion release first.`
);
}
if (tag !== expectedTag) {
throw new Error(`Companion HEAD tag ${tag} does not match ${expectedTag}.`);
}
return { tag, version: packageVersion };
}
function main() {
try {
const requireExactTag = !process.argv.includes('--allow-untagged');
const { tag, version } = verifyCompanionRelease({ requireExactTag });
console.log(
requireExactTag
? `Companion release ${version} matches exact tag ${tag}.`
: `Non-publishing Companion package metadata agrees at version ${version}.`
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
main();
}
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { execFileSync } from 'node:child_process';
import { exactVersionTag, verifyCompanionRelease } from './verify-companion-release.js';
function createFixture(packageVersion, manifestVersion) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-companion-release-'));
const extensionRoot = path.join(root, 'Extensions', 'Browser');
fs.mkdirSync(extensionRoot, { recursive: true });
fs.writeFileSync(
path.join(extensionRoot, 'package.json'),
`${JSON.stringify({ version: packageVersion }, null, 2)}\n`
);
fs.writeFileSync(
path.join(extensionRoot, 'manifest.json'),
`${JSON.stringify({ version: manifestVersion }, null, 2)}\n`
);
return root;
}
test('accepts matching Companion metadata and exact release tag', () => {
const root = createFixture('2.0.7', '2.0.7');
try {
let resolvedExpectedTag;
assert.deepEqual(
verifyCompanionRelease({
repositoryRoot: root,
resolveExactTag: (_extensionRoot, expectedTag) => {
resolvedExpectedTag = expectedTag;
return 'v2.0.7';
},
}),
{ tag: 'v2.0.7', version: '2.0.7' }
);
assert.equal(resolvedExpectedTag, 'v2.0.7');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects mismatched Companion package and manifest versions', () => {
const root = createFixture('2.0.7', '2.0.6');
try {
assert.throws(
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.7' }),
/versions do not agree/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects a malformed Companion semantic version before tag lookup', () => {
const root = createFixture('--contains=HEAD', '--contains=HEAD');
let tagLookupCalled = false;
try {
assert.throws(
() => verifyCompanionRelease({
repositoryRoot: root,
resolveExactTag: () => {
tagLookupCalled = true;
return 'v--contains=HEAD';
},
}),
/not a valid semantic version/
);
assert.equal(tagLookupCalled, false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects an untagged Companion commit', () => {
const root = createFixture('2.0.7', '2.0.7');
try {
assert.throws(
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => null }),
/not exactly tagged v2.0.7/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('accepts aligned untagged Companion metadata for a non-publishing audit', () => {
const root = createFixture('2.0.7', '2.0.7');
let tagLookupCalled = false;
try {
assert.deepEqual(
verifyCompanionRelease({
repositoryRoot: root,
requireExactTag: false,
resolveExactTag: () => {
tagLookupCalled = true;
return null;
},
}),
{ tag: null, version: '2.0.7' }
);
assert.equal(tagLookupCalled, false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('rejects a Companion tag for another version', () => {
const root = createFixture('2.0.7', '2.0.7');
try {
assert.throws(
() => verifyCompanionRelease({ repositoryRoot: root, resolveExactTag: () => 'v2.0.6' }),
/does not match v2.0.7/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('exactVersionTag resolves tag on HEAD with isolated git environment', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-git-test-'));
const previousGlobalConfig = process.env.GIT_CONFIG_GLOBAL;
try {
const gitEnv = {
...process.env,
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@example.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@example.com',
};
execFileSync('git', ['init', root], { env: gitEnv, stdio: 'ignore' });
execFileSync('git', ['-C', root, 'commit', '--allow-empty', '-m', 'test'], { env: gitEnv, stdio: 'ignore' });
execFileSync('git', ['-C', root, 'tag', 'v2.0.7'], { env: gitEnv, stdio: 'ignore' });
const globalConfig = path.join(root, 'global.gitconfig');
fs.writeFileSync(globalConfig, '[alias]\n\ttag = !printf "v2.0.8\\n"\n');
process.env.GIT_CONFIG_GLOBAL = globalConfig;
assert.equal(exactVersionTag(root, 'v2.0.7'), 'v2.0.7');
assert.equal(exactVersionTag(root, 'v2.0.8'), null);
} finally {
if (previousGlobalConfig === undefined) {
delete process.env.GIT_CONFIG_GLOBAL;
} else {
process.env.GIT_CONFIG_GLOBAL = previousGlobalConfig;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { collectRegularFiles, sha256 } from './engine-payload-integrity.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(__dirname, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function fail(message) {
console.error(`[FAIL] ${message}`);
process.exit(1);
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
env: { ...process.env, ...options.env },
stdio: options.stdio ?? 'inherit',
encoding: options.stdio === 'pipe' ? 'utf8' : undefined,
});
if (result.error) {
fail(`Failed to run ${command}: ${result.error.message}`);
}
if (result.status !== 0) {
if (options.stdio === 'pipe') {
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
}
fail(`${command} exited with status ${result.status}`);
}
return result;
}
function findSingle(directory, extension, label) {
if (!fs.existsSync(directory) || !fs.statSync(directory).isDirectory()) {
fail(`${label} directory does not exist: ${directory}`);
}
const matches = fs.readdirSync(directory)
.filter(name => name.endsWith(extension))
.map(name => path.join(directory, name));
if (matches.length !== 1) {
fail(`Expected exactly one ${label}, found ${matches.length} in ${directory}`);
}
return matches[0];
}
function assertPackageListing(packageFile, packageType, expectedPath) {
const result = packageType === 'deb'
? run('dpkg-deb', ['--contents', packageFile], { stdio: 'pipe' })
: run('rpm', ['-qpl', packageFile], { stdio: 'pipe' });
const listing = result.stdout ?? '';
assertSafePackageListing(listing, packageType);
if (!listing.includes(expectedPath)) {
fail(`${packageType} package is missing ${expectedPath}`);
}
if (!/usr\/share\/applications\/[^/]+\.desktop/.test(listing)) {
fail(`${packageType} package is missing its desktop entry`);
}
}
function assertPackageRecommendations(packageFile, packageType) {
const result = packageType === 'deb'
? run('dpkg-deb', ['--field', packageFile, 'Recommends'], { stdio: 'pipe' })
: run('rpm', ['-qp', '--recommends', packageFile], { stdio: 'pipe' });
const recommendations = result.stdout ?? '';
const dependencyNames = packageType === 'deb'
? recommendations
.split(/[,|]/)
.map(value => value.trim().split(/\s+/, 1)[0]?.split(':', 1)[0])
: recommendations
.split('\n')
.map(value => value.trim().split(/\s+/, 1)[0]);
for (const dependency of ['desktop-file-utils', 'xdg-utils']) {
if (!dependencyNames.includes(dependency)) {
fail(`${packageType} package is missing its ${dependency} recommendation`);
}
}
}
export function parseDebianPackagePath(line) {
const match = line.match(/^\S+\s+\S+\s+\S+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+(.*)$/);
if (!match) {
throw new Error(`Could not parse a Debian package path: ${line}`);
}
return match[1].replace(/^\.\//, '');
}
export function isSafePackagePath(packagePath) {
if (packagePath === '') {
return true;
}
const parts = packagePath.split('/');
return !parts.includes('..') && (parts[0] === 'usr' || packagePath === 'usr');
}
function assertSafePackageListing(listing, packageType) {
const lines = listing.split('\n').filter(Boolean);
const paths = packageType === 'deb'
? lines.map(line => {
try {
return parseDebianPackagePath(line);
} catch (error) {
fail(error.message);
}
})
: lines.map(line => line.replace(/^\/+/, ''));
for (const packagePath of paths) {
if (!isSafePackagePath(packagePath)) {
fail(`${packageType} package contains an unsafe path: ${packagePath}`);
}
}
}
function extractDeb(packageFile, destination) {
fs.mkdirSync(destination, { recursive: true });
run('dpkg-deb', ['--extract', packageFile, destination]);
}
function extractRpm(packageFile, destination) {
fs.mkdirSync(destination, { recursive: true });
run('bsdtar', [
'--extract',
'--file', packageFile,
'--directory', destination,
'--no-same-owner',
'--no-same-permissions',
]);
}
function readPayloadManifest(root, label) {
const manifestPath = path.join(root, 'payload-manifest.json');
if (!fs.existsSync(manifestPath)) {
fail(`${label} payload manifest is missing`);
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch (error) {
fail(`${label} payload manifest is invalid: ${error.message}`);
}
return manifest;
}
function findPayloadRoot(root, target, label) {
const expectedBinary = `yt-dlp-${target}`;
const matches = [];
const walk = directory => {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const candidate = path.join(directory, entry.name);
if (!entry.isDirectory()) continue;
if (fs.existsSync(path.join(candidate, expectedBinary)) && fs.existsSync(path.join(candidate, 'payload-manifest.json'))) {
matches.push(candidate);
}
walk(candidate);
}
};
walk(root);
if (matches.length !== 1) {
fail(`Expected exactly one ${label} engine payload root, found ${matches.length}`);
}
return matches[0];
}
function assertPayloadMatchesSource(sourceRoot, packagedRoot, target, label) {
const sourceManifest = readPayloadManifest(sourceRoot, 'Provisioned engine');
if (sourceManifest.target !== target) {
fail(`Provisioned engine payload target mismatch: expected ${target}, got ${sourceManifest.target}`);
}
const packagedManifest = readPayloadManifest(packagedRoot, label);
if (packagedManifest.target !== target) {
fail(`${label} payload target mismatch: expected ${target}, got ${packagedManifest.target}`);
}
const expectedFiles = Object.keys(sourceManifest.files || {}).sort();
const packagedFiles = collectRegularFiles(packagedRoot, { ignoredNames: ['payload-manifest.json'] })
.map(file => path.relative(packagedRoot, file).split(path.sep).join('/'))
.sort();
if (JSON.stringify(packagedFiles) !== JSON.stringify(expectedFiles)) {
fail(`${label} payload files differ from the provisioned engine manifest`);
}
for (const relative of expectedFiles) {
const packagedFile = path.join(packagedRoot, relative);
if (sha256(packagedFile) !== sourceManifest.files[relative]) {
fail(`${label} payload checksum mismatch: ${relative}`);
}
}
}
function findExecutable(root) {
const candidates = [
path.join(root, 'usr', 'bin', 'firelink'),
path.join(root, 'usr', 'bin', 'Firelink'),
];
const executable = candidates.find(candidate => {
if (!fs.existsSync(candidate)) return false;
const stat = fs.lstatSync(candidate);
return stat.isFile() && !stat.isSymbolicLink();
});
if (!executable) {
fail(`Packaged Firelink executable was not found under ${root}`);
}
return executable;
}
function verifyExtractedPackage(packageType, packageFile, target, root) {
const sourceRoot = path.join(repoRoot, 'src-tauri', 'provisioned-engines', target);
const packagedRoot = findPayloadRoot(root, target, packageType);
assertPayloadMatchesSource(sourceRoot, packagedRoot, target, packageType);
run(process.execPath, [
path.join(repoRoot, 'scripts', 'verify-binaries.js'),
'--search-root',
root,
'--target',
target,
]);
const executable = findExecutable(root);
run('xvfb-run', [
'-a',
process.execPath,
path.join(repoRoot, 'scripts', 'smoke-packaged-app.js'),
'--executable',
executable,
], { env: { APPDIR: root } });
}
function main() {
const target = argValue('--target');
if (!target) fail('Pass --target <Rust target triple>.');
if (os.platform() !== 'linux') fail('Linux package verification must run on Linux.');
const bundleRoot = path.join(repoRoot, 'src-tauri', 'target', target, 'release', 'bundle');
const deb = findSingle(path.join(bundleRoot, 'deb'), '.deb', 'Debian package');
const rpm = findSingle(path.join(bundleRoot, 'rpm'), '.rpm', 'RPM package');
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-linux-packages-'));
const debRoot = path.join(extractionRoot, 'deb');
const rpmRoot = path.join(extractionRoot, 'rpm');
try {
assertPackageListing(deb, 'deb', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
assertPackageRecommendations(deb, 'deb');
extractDeb(deb, debRoot);
verifyExtractedPackage('deb', deb, target, debRoot);
assertPackageListing(rpm, 'rpm', 'usr/share/metainfo/com.nimbold.firelink.metainfo.xml');
assertPackageRecommendations(rpm, 'rpm');
extractRpm(rpm, rpmRoot);
verifyExtractedPackage('rpm', rpm, target, rpmRoot);
console.log('Linux .deb and .rpm payload and launch verification passed.');
} finally {
fs.rmSync(extractionRoot, { recursive: true, force: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
main();
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSafePackagePath, parseDebianPackagePath } from './verify-linux-packages.js';
test('parses current dpkg-deb listings without a ./ prefix', () => {
assert.equal(
parseDebianPackagePath('drwxr-xr-x 0/0 0 2026-07-12 07:24 usr/share/'),
'usr/share/'
);
});
test('parses legacy dpkg-deb listings with a ./ prefix', () => {
assert.equal(
parseDebianPackagePath('-rwxr-xr-x root/root 123 2026-07-12 07:24 ./usr/bin/firelink'),
'usr/bin/firelink'
);
});
test('accepts the package root in legacy dpkg-deb listings', () => {
assert.equal(
parseDebianPackagePath('drwxr-xr-x root/root 0 2026-07-12 07:24 ./'),
''
);
assert.equal(isSafePackagePath(''), true);
});
test('rejects paths outside the package usr tree', () => {
assert.equal(isSafePackagePath('../tmp/firelink'), false);
assert.equal(isSafePackagePath('etc/firelink'), false);
});
test('rejects malformed dpkg-deb listing lines', () => {
assert.throws(
() => parseDebianPackagePath('not a dpkg-deb listing'),
/Could not parse a Debian package path/
);
});
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(scriptDirectory, '..');
function argValue(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readPackageVersion(root) {
return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
}
function readTauriVersion(root) {
return JSON.parse(
fs.readFileSync(path.join(root, 'src-tauri', 'tauri.conf.json'), 'utf8')
).version;
}
function readCargoVersion(root) {
const cargo = fs.readFileSync(path.join(root, 'src-tauri', 'Cargo.toml'), 'utf8');
const packageSection = cargo.match(/^\[package\]\s*([\s\S]*?)(?=^\[)/m)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) {
throw new Error('Could not read the [package] version from src-tauri/Cargo.toml.');
}
return version;
}
function versionFromRef(ref) {
if (!ref || !ref.startsWith('v')) {
throw new Error(`Expected a semantic version tag such as v1.2.3, received ${ref || 'nothing'}.`);
}
const version = ref.slice(1);
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Release tag ${ref} does not contain a valid semantic version.`);
}
return version;
}
const versions = {
'package.json': readPackageVersion(repositoryRoot),
'src-tauri/Cargo.toml': readCargoVersion(repositoryRoot),
'src-tauri/tauri.conf.json': readTauriVersion(repositoryRoot),
};
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1) {
console.error('Application version manifests do not agree:');
for (const [file, version] of Object.entries(versions)) console.error(` ${file}=${version}`);
process.exit(1);
}
const allowUntagged = process.argv.includes('--allow-untagged');
const tag = argValue('--tag') || process.env.GITHUB_REF_NAME;
const expected = allowUntagged ? Object.values(versions)[0] : versionFromRef(tag);
const mismatches = Object.entries(versions)
.filter(([, version]) => version !== expected)
.map(([file, version]) => `${file}=${version}`);
if (!allowUntagged && mismatches.length > 0) {
console.error(`Release tag ${tag} does not match the application manifests (expected ${expected}).`);
for (const mismatch of mismatches) console.error(` ${mismatch}`);
process.exit(1);
}
console.log(
allowUntagged
? `Non-publishing package version ${expected} matches ${Object.keys(versions).length} manifests.`
: `Release version ${expected} matches ${Object.keys(versions).length} manifests.`
);
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const verifier = path.join(repositoryRoot, 'scripts', 'verify-release-version.js');
const currentVersion = JSON.parse(
fs.readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8')
).version;
function runVerifier(tag, ...extraArguments) {
return spawnSync(process.execPath, [verifier, '--tag', tag, ...extraArguments], {
cwd: repositoryRoot,
encoding: 'utf8',
});
}
test('release version verifier accepts the aligned current version', () => {
const result = runVerifier(`v${currentVersion}`);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`Release version ${currentVersion} matches`));
});
test('release version verifier rejects the already-published prior tag', () => {
const result = runVerifier('v1.1.1');
assert.equal(result.status, 1);
assert.match(result.stderr, /does not match the application manifests/);
});
test('release version verifier rejects non-semver tag names', () => {
const result = runVerifier('release-candidate');
assert.equal(result.status, 1);
assert.match(result.stderr, /semantic version tag/);
});
test('release version verifier accepts an untagged non-publishing package audit', () => {
const result = runVerifier('main', '--allow-untagged');
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, new RegExp(`Non-publishing package version ${currentVersion} matches`));
});
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
const windowStyles = fs.readFileSync('src/index.css', 'utf8');
const propertiesWindowSource = fs.readFileSync('src-tauri/src/properties_window.rs', 'utf8');
const mainWindowSource = fs.readFileSync('src-tauri/src/lib.rs', 'utf8');
const windowsConfiguration = JSON.parse(
fs.readFileSync('src-tauri/tauri.windows.conf.json', 'utf8')
);
const cssBlock = selector => {
const opening = `${selector} {`;
const start = windowStyles.indexOf(opening);
assert.notEqual(start, -1, `${selector} should exist`);
const end = windowStyles.indexOf('}', start + opening.length);
assert.notEqual(end, -1, `${selector} should have a closing brace`);
return windowStyles.slice(start, end + 1);
};
test('Windows native shadows remain disabled for the main and Properties windows', () => {
assert.deepEqual(
{
transparent: windowsConfiguration.app.windows[0].transparent,
decorations: windowsConfiguration.app.windows[0].decorations,
shadow: windowsConfiguration.app.windows[0].shadow,
},
{ transparent: true, decorations: false, shadow: false }
);
assert.match(
mainWindowSource,
/let main_window_config = app\s*\.config\(\)\s*\.app\s*\.windows[\s\S]*?\.find\(\|window\| window\.label == "main"\)/
);
assert.match(
mainWindowSource,
/WebviewWindowBuilder::from_config\(\s*app\.handle\(\),\s*&main_window_config,\s*\)/
);
assert.match(
propertiesWindowSource,
/#\[cfg\(target_os = "windows"\)\]\s*let builder = builder\.transparent\(true\)\.shadow\(false\);/
);
});
test('Windows selects stronger renderer-owned contours for every theme', () => {
const expectedTokens = [
[':root', '220 12% 30% / 0.60', '220 10% 30% / 0.18'],
['.theme-light', '220 12% 30% / 0.60', '220 10% 30% / 0.18'],
['.theme-dark', '0 0% 100% / 0.35', '0 0% 100% / 0.14'],
['.theme-dracula', '228 14% 84% / 0.45', '228 14% 84% / 0.18'],
['.theme-nord', '218 27% 88% / 0.45', '218 27% 88% / 0.18'],
];
for (const [selector, active, inactive] of expectedTokens) {
const block = cssBlock(selector);
assert.match(block, new RegExp(`--window-frame-windows-active: ${active.replace('.', '\\.')}\\s*;`));
assert.match(block, new RegExp(`--window-frame-windows-inactive: ${inactive.replace('.', '\\.')}\\s*;`));
}
const windowsBlock = cssBlock('html[data-platform="windows"]');
assert.match(
windowsBlock,
/--window-frame-active:\s*var\(--window-frame-windows-active\);/
);
assert.match(
windowsBlock,
/--window-frame-inactive:\s*var\(--window-frame-windows-inactive\);/
);
});
test('maximized Windows shells remain square and borderless', () => {
assert.match(
windowStyles,
/html\[data-platform="windows"\] :is\(\.app-shell, \.properties-window-shell\)\[data-window-maximized="true"\] \{\s*border-color:\s*transparent;/
);
assert.match(
windowStyles,
/html\[data-platform="linux"\] :is\(\.app-shell, \.properties-window-shell\),\s*html\[data-platform="windows"\] :is\(\.app-shell, \.properties-window-shell\)\[data-window-maximized="true"\] \{\s*border-radius:\s*0;/
);
});
+1217 -1103
View File
File diff suppressed because it is too large Load Diff
+27 -21
View File
@@ -1,6 +1,6 @@
[package]
name = "firelink"
version = "1.0.3"
version = "1.4.2"
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
authors = ["NimBold"]
edition = "2021"
@@ -24,45 +24,51 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "test"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-opener = "2.5.5"
tauri-plugin-dialog = "2.7.3"
tauri-plugin-shell = "2.3.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "process", "io-util", "rt", "rt-multi-thread", "macros", "sync", "time"] }
regex = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "socks"] }
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json", "stream", "socks"] }
rustls = { version = "0.23.44", default-features = false, features = ["ring", "std", "tls12"] }
uuid = { version = "1", features = ["v4"] }
ts-rs = { version = "12", features = ["serde-compat", "uuid-impl"] }
tauri-plugin-notification = "2.3.3"
sysinfo = "0.39.3"
tauri-plugin-notification = "2.4.0"
tauri-plugin-clipboard-manager = "2.3.3"
sysinfo = "0.39.6"
hmac = "0.13"
sha2 = "0.11"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
sha1 = "0.11"
base64 = { version = "0.23.1", default-features = false, features = ["std"] }
tauri-plugin-deep-link = "2.4.10"
tauri-plugin-single-instance = { version = "2.4.4", features = ["deep-link"] }
tempfile = "3"
thiserror = "2.0.18"
thiserror = "2.0.20"
axum = "0.8.9"
tower-http = { version = "0.7", features = ["cors"] }
sysproxy = "0.3.0"
tower-http = { version = "0.7", features = ["cors", "limit"] }
semver = "1.0.28"
keepawake = "0.6.0"
keepawake = "0.6.1"
system_shutdown = "4.1.0"
tokio-tungstenite = "0.29.0"
futures-util = { version = "0.3.32", features = ["sink"] }
tokio-tungstenite = "0.30.0"
futures-util = { version = "0.3.33", features = ["sink"] }
chrono = "0.4.38"
url = "2"
rusqlite = { version = "0.40.1", features = ["bundled"] }
log = "0.4.32"
tauri-plugin-log = "2"
rusqlite = { version = "0.40.2", features = ["bundled"] }
log = "0.4.34"
tauri-plugin-log = "2.9.1"
trash = "5"
async-trait = "0.1"
keyring-core = "1.0.0"
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] }
objc = "0.2.7"
keyring = { version = "3", features = ["apple-native"] }
unicode-normalization = "0.1.25"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3", features = ["windows-native"] }
windows-native-keyring-store = "1.1.0"
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3", features = ["sync-secret-service", "vendored"] }
zbus-secret-service-keyring-store = { version = "1.0.1", features = ["crypto-rust"] }
+23
View File
@@ -0,0 +1,23 @@
# Rust dependency advisory policy
`cargo audit` is a required CI gate. Vulnerability advisories must be resolved;
the gate must not be bypassed with a broad ignore list.
As of 2026-09-05, Cargo reports no vulnerability advisories. It does report
the following informational warnings, which remain visible in CI output:
- `RUSTSEC-2024-0411` through `RUSTSEC-2024-0420` (GTK3 bindings) and
`RUSTSEC-2024-0370` (`proc-macro-error`) are Linux-only dependencies reached
through Tauri/Wry's GTK3 and tray integration.
- `RUSTSEC-2024-0429` (`glib` 0.18.5 iterator unsoundness) is in that same
Linux Tauri/Wry GTK3 graph. Firelink does not directly use
`glib::VariantStrIter`, but this remains an upstream risk rather than a
Firelink-level remediation.
- `RUSTSEC-2025-0075`, `RUSTSEC-2025-0080`, `RUSTSEC-2025-0081`,
`RUSTSEC-2025-0098`, and `RUSTSEC-2025-0100` are unmaintained UNIC crates
reached through `tauri-utils -> urlpattern`.
Review these paths with every Tauri/Wry update and no later than 2026-12-05.
Remove this acknowledgement when the upstream graph no longer contains the
affected packages. Do not add these advisory IDs to Cargo's ignore list: a
future severity change must remain visible.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+125 -29
View File
@@ -717,35 +717,6 @@ g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN
BSeOE6Fuwg==
-----END CERTIFICATE-----
# Issuer: CN=Atos TrustedRoot 2011 O=Atos
# Subject: CN=Atos TrustedRoot 2011 O=Atos
# Label: "Atos TrustedRoot 2011"
# Serial: 6643877497813316402
# MD5 Fingerprint: ae:b9:c4:32:4b:ac:7f:5d:66:cc:77:94:bb:2a:77:56
# SHA1 Fingerprint: 2b:b1:f5:3e:55:0c:1d:c5:f1:d4:e6:b7:6a:46:4b:55:06:02:ac:21
# SHA256 Fingerprint: f3:56:be:a2:44:b7:a9:1e:b3:5d:53:ca:9a:d7:86:4a:ce:01:8e:2d:35:d5:f8:f9:6d:df:68:a6:f4:1a:a4:74
-----BEGIN CERTIFICATE-----
MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UE
AwwVQXRvcyBUcnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQG
EwJERTAeFw0xMTA3MDcxNDU4MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMM
FUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsGA1UECgwEQXRvczELMAkGA1UEBhMC
REUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVhTuXbyo7LjvPpvMp
Nb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr54rM
VD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+
SZFhyBH+DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ
4J7sVaE3IqKHBAUsR320HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0L
cp2AMBYHlT8oDv3FdU9T1nSatCQujgKRz3bFmx5VdJx4IbHwLfELn8LVlhgf8FQi
eowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7Rl+lwrrw7GWzbITAPBgNV
HRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZbNshMBgG
A1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3
DQEBCwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8j
vZfza1zv7v1Apt+hk6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kP
DpFrdRbhIfzYJsdHt6bPWHJxfrrhTZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pc
maHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a961qn8FYiqTxlVMYVqL2Gns2D
lmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G3mB/ufNPRJLv
KrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed
-----END CERTIFICATE-----
# Issuer: CN=QuoVadis Root CA 1 G3 O=QuoVadis Limited
# Subject: CN=QuoVadis Root CA 1 G3 O=QuoVadis Limited
# Label: "QuoVadis Root CA 1 G3"
@@ -3861,3 +3832,128 @@ y7Ve7hekm9sEdu4pKeiv+62sUvTXK9Z3hBC9xdIoaDQCQTV2WnXzkoYI9bIeCvZl
C9p2x1L/Cx6AcCIwwzPbGO2E14vs7dOoY4G1VnxHx1YwlGhza9IuqbnZLBwpvQy6
uWWL
-----END CERTIFICATE-----
# Issuer: CN=SECOM TLS RSA Root CA 2024 O=SECOM Trust Systems Co., Ltd.
# Subject: CN=SECOM TLS RSA Root CA 2024 O=SECOM Trust Systems Co., Ltd.
# Label: "SECOM TLS RSA Root CA 2024"
# Serial: 17188327524208271538
# MD5 Fingerprint: d0:a4:db:32:eb:44:98:d2:62:0b:3e:bc:4d:7c:5c:e9
# SHA1 Fingerprint: fb:97:96:7c:ef:8d:98:63:06:c0:3b:b6:11:f8:e0:13:97:a2:98:d3
# SHA256 Fingerprint: 14:35:f2:25:c5:d2:52:d7:a2:19:48:cc:3c:e6:2a:ec:fa:88:00:1e:3d:d7:2d:1c:c3:55:51:00:eb:37:2f:93
-----BEGIN CERTIFICATE-----
MIIFmjCCA4KgAwIBAgIJAO6JNNDLgOCyMA0GCSqGSIb3DQEBDAUAMFoxCzAJBgNV
BAYTAkpQMSYwJAYDVQQKEx1TRUNPTSBUcnVzdCBTeXN0ZW1zIENvLiwgTHRkLjEj
MCEGA1UEAxMaU0VDT00gVExTIFJTQSBSb290IENBIDIwMjQwHhcNMjQwMTMxMDUx
MTU1WhcNNDkwMTE0MDUxMTU1WjBaMQswCQYDVQQGEwJKUDEmMCQGA1UEChMdU0VD
T00gVHJ1c3QgU3lzdGVtcyBDby4sIEx0ZC4xIzAhBgNVBAMTGlNFQ09NIFRMUyBS
U0EgUm9vdCBDQSAyMDI0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
4TjizUwzxbInq8Tx11gaFYNk5fO+34y7TyM4neh0UgL5JIZbJNLTz2x//L/B71+5
m6X6nGIr7d4lFJBGtjO677hXOz93zkcWaUTm3VbOAjBlt4YWxlcccBHXuZ7o3Q+4
R+ormrBdHeJ1CTUEG8ttQbKIl3G7OZYbnH8/pP8cjPub/0kDVNuMzp7xsVRROOis
Qt53fMoJLlYgoebbuMphOqMCtjkJ7R6efEMfLp8UAVi9ZaLRn76ET/CJkk925ndu
uufC4BatS4mnXFmxN0vUXb0ij9B8O/D8gixQEsVSD4GK8FWRPh3bVd/6bzdkHGJj
y21XI0yejVomZUbRrOfNuz0boPGV1pt18fFC39IHQEth3OFqb5NDO3L+A9bNqTgA
yUgRmIn4ucgDc/Ri/Km3V51ueZjy1/yk0qwJVadAVVrCt56iNeXOyEvzJADGgDQ8
E1Pdaqct8Cynz/47ReQM62vFYO08wcQkrjmX/tesiko1V1yyaf6EfPzUFzmaGy9x
vkCwdbm15EdTolOjE0H2Vb5/APDOyCFEokiYGmXTLdAUl0wKZ4IyjkHGzy0jhpaX
EXE/GJcEvI6VzEchjaBL03EJ0h9pG4OqeIOycKvAo3A+TbetyfsrgYyHzU0a7/qU
jGat1AAq1nVljMpKqpinPTsf/d9H39FTUeJL7TpzzjUCAwEAAaNjMGEwHQYDVR0O
BBYEFCzrchKOWHdkNRVWNQFXB6l9DTbmMB8GA1UdIwQYMBaAFCzrchKOWHdkNRVW
NQFXB6l9DTbmMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG
SIb3DQEBDAUAA4ICAQAVwsvluSafaez5tFPR/hRTBzRxEyMMQF3XJXCVi3yegZyK
oec7hmE6jx2ZM8KgM1kn2yJRwFXHX8zUW9nBLEDWc4wuE8LrlZqhGZM9pJQXGmGz
ResDJV6JgRBna+j4sA1M7yIdlvL0sAfFXFCTRaWTD4E1V99RLrFzWfTcC+e180hD
uNMpqOEo46+lMeW/Wvh7ifOQs+kiK0O2gHxQDNxslSavnCs4V7l8HRDJ2La10o70
Bo7VLzf1W8MBvv0VTnxB+NjT5qTAbhGFh9Gvp4BaJpmdUf0C5CEP6dbQlfgxWfzY
r69yVT6dPQB+GFEaY03IMY+AcBCs+om1fNxrQXt9zoofMBNFbLhvpNH/JsXWdGUz
fNbO12uswTa5wah8LB18FTQN2/zPHYmvBEoLuyUgZ09VNLJo5YA0kXItVYkLjMe2
SixzK4scUHv81IK99I91DWx7FwMVKw2xgFp+ZLYB2dnpQQrqwlW64glHUcK2N9BD
snjLSxeZ+UPECh9RxH4WAcKiZW+cqaKMmhP2WBfR4IcR7NOL32ml11ds87hhV1CZ
WWFCJAcCidYZz6CZa8exzHojP9SB5RH0/v1KdHAisqhSjtJl/UIAHIQ48elOn8wr
TdFap4Yb5aHglmMeNx+fAIhDluWVfxTO7H4dTPU+SFVRMLAh+wwKZfqb94nMeQ==
-----END CERTIFICATE-----
# Issuer: CN=SECOM TLS ECC Root CA 2024 O=SECOM Trust Systems Co., Ltd.
# Subject: CN=SECOM TLS ECC Root CA 2024 O=SECOM Trust Systems Co., Ltd.
# Label: "SECOM TLS ECC Root CA 2024"
# Serial: 9329818985461676612
# MD5 Fingerprint: 99:d3:9d:e4:d2:b1:2d:f0:2a:04:67:85:f3:df:46:d6
# SHA1 Fingerprint: 7a:1f:22:2d:72:b2:c3:19:87:44:db:61:69:e8:a6:4b:d7:0d:44:0e
# SHA256 Fingerprint: 6a:b2:ab:75:f5:1c:b4:f4:f0:15:62:03:fb:f6:f6:46:23:2f:51:4b:e0:59:f6:28:33:30:8b:82:b4:d7:2d:b1
-----BEGIN CERTIFICATE-----
MIICTDCCAdGgAwIBAgIJAIF6LO+PI3pEMAoGCCqGSM49BAMDMFoxCzAJBgNVBAYT
AkpQMSYwJAYDVQQKEx1TRUNPTSBUcnVzdCBTeXN0ZW1zIENvLiwgTHRkLjEjMCEG
A1UEAxMaU0VDT00gVExTIEVDQyBSb290IENBIDIwMjQwHhcNMjQwMTMxMDU1MjM0
WhcNNDkwMTE0MDU1MjM0WjBaMQswCQYDVQQGEwJKUDEmMCQGA1UEChMdU0VDT00g
VHJ1c3QgU3lzdGVtcyBDby4sIEx0ZC4xIzAhBgNVBAMTGlNFQ09NIFRMUyBFQ0Mg
Um9vdCBDQSAyMDI0MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE7NzFMtu9dzQXSNC1
2fabk0+GlC5finB3R7XaZonRUd20aFiWObtuNBCLUZSfk6QXAE55BjEXsXQ/NG8y
UqicXjsu9ksDK3JZBgCwLOVh6+nwJXTvso/dEj/GUYH5mBdoo2MwYTAdBgNVHQ4E
FgQUO3YReyl04k4GTFaCQNAhL3qzydUwHwYDVR0jBBgwFoAUO3YReyl04k4GTFaC
QNAhL3qzydUwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZI
zj0EAwMDaQAwZgIxAN3ib8fi1pMYtAPjMilB5e5/H+t5CL0xPL+cZ5oTTZuSCjpA
n1v7F/VAr8bFxQXAowIxAKsBVO1ACFp7skwzPvdv1EUY5a897WGLT4lb+bjxFAWy
l8wDcZJdwGZ/pAHxt1AJ1g==
-----END CERTIFICATE-----
# Issuer: CN=Telia EC TLS Root CA v3 O=Telia Company AB
# Subject: CN=Telia EC TLS Root CA v3 O=Telia Company AB
# Label: "Telia EC TLS Root CA v3"
# Serial: 8028200332079443110287896228123317
# MD5 Fingerprint: b6:fa:6a:5c:42:fb:c5:67:72:c0:e0:2f:72:fb:5a:44
# SHA1 Fingerprint: b4:d6:07:c2:a5:95:bc:5b:f4:67:4d:c9:dc:6f:6f:0a:00:7a:a5:35
# SHA256 Fingerprint: 09:8e:08:a9:1d:bb:f7:74:78:b9:6c:ce:b8:9b:14:13:a5:da:37:b7:c8:62:60:6a:95:5d:eb:07:17:9f:43:26
-----BEGIN CERTIFICATE-----
MIICMjCCAbegAwIBAgIPAYvSIlRjTQSLbOVHH9K1MAoGCCqGSM49BAMDMEoxCzAJ
BgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFCMSAwHgYDVQQDDBdU
ZWxpYSBFQyBUTFMgUm9vdCBDQSB2MzAeFw0yMzExMTUwODU1MjZaFw00ODA1MjMx
MTAwMDBaMEoxCzAJBgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFC
MSAwHgYDVQQDDBdUZWxpYSBFQyBUTFMgUm9vdCBDQSB2MzB2MBAGByqGSM49AgEG
BSuBBAAiA2IABMHIlhVDLbmFKUpW0iK4dpryT6emYOeS31JPwWnWPmkWRrAkTbPX
40sQfHI9mpR7Rbktu3ngg6W+BBSXSechtMCnBmWXj/EaVlmV5cY1jD2HoTfhBQ3A
acpCNMLJK4NpZaNjMGEwHwYDVR0jBBgwFoAU1GToQ4g6cy/QGnGCNgtehd7H3kMw
HQYDVR0OBBYEFNRk6EOIOnMv0BpxgjYLXoXex95DMA4GA1UdDwEB/wQEAwIBBjAP
BgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMDA2kAMGYCMQCXAUdS/9bbJ8A1JYaG
f/bWt/s7Ta0ot5Ulno8OjSNYRWQIlS4tVWldvTAVA7heOFgCMQCvKr8+Z2Rn+OBr
5UHzlgBObpad1LuwNTRcdNgUJxIWadcki+UBLEi1/AURKV5md2M=
-----END CERTIFICATE-----
# Issuer: CN=Telia RSA TLS Root CA v3 O=Telia Company AB
# Subject: CN=Telia RSA TLS Root CA v3 O=Telia Company AB
# Label: "Telia RSA TLS Root CA v3"
# Serial: 8028214673410753541795188766939845
# MD5 Fingerprint: f4:8b:9c:f3:f8:63:cf:dc:25:8f:b4:bb:a2:e9:9d:e2
# SHA1 Fingerprint: b5:2e:88:4e:40:c1:11:fb:50:c7:e2:4f:ac:18:2b:bd:68:15:d2:34
# SHA256 Fingerprint: d1:3d:b1:29:4c:45:eb:c6:fc:86:c6:bb:f6:9f:a2:9b:df:e6:92:df:f7:c7:13:c2:43:c7:a9:56:c6:a2:28:4c
-----BEGIN CERTIFICATE-----
MIIFgjCCA2qgAwIBAgIPAYvSUKtCVSxHWr2h3BrFMA0GCSqGSIb3DQEBDAUAMEsx
CzAJBgNVBAYTAlNFMRkwFwYDVQQKDBBUZWxpYSBDb21wYW55IEFCMSEwHwYDVQQD
DBhUZWxpYSBSU0EgVExTIFJvb3QgQ0EgdjMwHhcNMjMxMTE1MDk0NzQyWhcNNDgw
NTIzMTEwMDAwWjBLMQswCQYDVQQGEwJTRTEZMBcGA1UECgwQVGVsaWEgQ29tcGFu
eSBBQjEhMB8GA1UEAwwYVGVsaWEgUlNBIFRMUyBSb290IENBIHYzMIICIjANBgkq
hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsV89KG19hCf4S1Fvk8D3TyDERhmcvx8F
7Kmb4WATx3ije1id3KHxRE0TKmcNCbAQ57bvHFEYa4hR2l20VjVadExqOW+2ld99
MbEiO+jRVOz+BbxLxJnmGwCqI+BfuTjjVReDxsxjQvjgBsClaO/sm5i70nlZcWGR
tIkvWDK3NNkT5RtwXc/O8NTFVpbUqT6cRjIj3olAblR+lRf4Ffy5o+Q9fabjYn9Z
9S4itruElcEFf9Ljk7fwdTycT/rvJW9w/B3G2a3r0f/zXNOVruIBcqE6pkSospAC
U2bG42fYKrbM/GWnp7u+p9Frz4jaNwpb4YHuEeS8BratNcP8X62jXIvvKHxlsMDJ
Cnb4U8JzFOLsU6mohVY58BdZrvi0Gk9UOuqmgoG6dskHoksjZTlK61D/InzmEoA1
yAYJFDVysjRxDUOu9cAwANbqmq77WIFL6BpnZgVqPtMfG6wN8BrTKdapvilVsYR5
9BFgIsAVBMxrGh+W+QcvmJafUpASvlArKvVG2FI4i6PiLjSBT0+6F6EQLrYqefOQ
F/fBNEXb+njUQ0SUVrAqtH4Y+OjCI/a4/JJQppxeemZcQ0SUShgiI5AM5xHO5iya
UrTjYH4zxUz9j+1FEbDH/xpstr1gXBykspup+hRTaJcbA+UbpJqtWZndAPddJmt6
YJQ+dU3pDu8CAwEAAaNjMGEwHwYDVR0jBBgwFoAUsMep0t2yKFZzBJSMFFxIbzdS
kqgwHQYDVR0OBBYEFLDHqdLdsihWcwSUjBRcSG83UpKoMA4GA1UdDwEB/wQEAwIB
BjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBDAUAA4ICAQBdYzFsNGDRk7bR
/AgRKq+5637YuOW+w6uhpoS0VnKMUpyHCwku86hEvqivakPtfmlm4bFwt++sb/8O
XsWBqtfbXMaBNDTZl8XRMJuLWOW2JrbKkRzgG0eBUcvsadG1rrhbmZqYvFXaAZO7
o4TdOZzxhBB5GOAWWXB3IeraNP4J63zyo9n8Gqw3sJBG44em5hoYjBffP+npibys
lnslRi4L6xHsCYj/Pab+OlqbMCB6v+sTCLeEIukRVzoR9aQ45pEK7Z1QBnSsbAKQ
tss0JKD9d/mX143H1xePjPhTXlv5JCkhrcj+SShz0P9+EHoWe6m9lyUEOIVn0rp+
yVJWNbmyDv3VkwFxHC1ApSQsgSimjGQ4wtr6cSmordYxkV+Ro8lOIIhRksXPyDk2
7gW6IjUXCkZKpxFjkL3jiBSc8SkxnwCWtXg8xwNwdFVNBGLCCuJnsneYXjJNqzRq
UcoGwzsvF3Qi/ZnHUNvISdevlgIAXL4Wvrxaqvoa01wB+GCfs57RTGE4TvAGhKNK
us8K3hRT1BSpigzMIRzSxtAOrqPN6j//QSmW9f8Jcncri4j2ihSpVrFU0NdNkMhZ
eAKidTFPsxCVFuW4Aniz7jqiw5sWtjbQrlW035izIEU4sYwQoC1Nx0Svy+mMTRai
50LqFQ+A1/Hq6xHHDNx7CI83d23Erw==
-----END CERTIFICATE-----
@@ -1,60 +0,0 @@
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
curl_cffi-0.15.0.dist-info/RECORD,,
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=ORhsskaIg_jwHIDYD7oRH5nzN3fKYa7xVFg-8WceJ5E,313
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
curl_cffi/__pycache__/aio.cpython-314.pyc,,
curl_cffi/__pycache__/const.cpython-314.pyc,,
curl_cffi/__pycache__/curl.cpython-314.pyc,,
curl_cffi/__pycache__/utils.cpython-314.pyc,,
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
@@ -1 +0,0 @@
{"archive_info": {"hash": "sha256=630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550", "hashes": {"sha256": "630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: curl_cffi
Version: 0.15.0
Version: 0.16.0
Summary: libcurl ffi bindings for Python, with impersonation support.
Author-email: lexiforest <infinitesheldon@gmail.com>
License-Expression: MIT
@@ -19,11 +19,12 @@ Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cffi>=2.0.0
Requires-Dist: certifi>=2024.2.2
Requires-Dist: rich
Provides-Extra: extra
Requires-Dist: readability-lxml>=0.8.1; extra == "extra"
Requires-Dist: markdownify>=1.1.0; extra == "extra"
Requires-Dist: lxml_html_clean; extra == "extra"
Provides-Extra: cli
Requires-Dist: rich; extra == "cli"
Provides-Extra: dev
Requires-Dist: charset_normalizer<4.0,>=3.3.2; extra == "dev"
Requires-Dist: coverage<7.0,>=6.4.1; extra == "dev"
@@ -90,12 +91,6 @@ Python 3.10 is the minimum supported version since v0.14.
If youre looking for a meeting recording API, consider checking out [Recall.ai](https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=lexiforest-curl_cffi), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
## Residential Proxies
<a href="https://www.thordata.com/?ls=github&lk=curl_" target="_blank"><img src="https://raw.githubusercontent.com/lexiforest/curl_cffi/main/assets/thordata.png" alt="Thordata" height="126" width="240"></a>
Thordata: A reliable and cost-effective proxy service provider. One-click collection of public network data, providing enterprises and developers with stable, efficient, and compliant global proxy IP services. Register for a free trial of [residential proxies](https://www.thordata.com/?ls=github&lk=curl_) and receive 2000 free SERP API calls.
## Sponsors
Maintenance of this project is made possible by all the <a href="https://github.com/lexiforest/curl_cffi/graphs/contributors">contributors</a> and <a href="https://github.com/sponsors/lexiforest">sponsors</a>. If you'd like to sponsor this project and have your avatar or company logo appear below <a href="https://github.com/sponsors/lexiforest">click here</a>. 💖
@@ -124,6 +119,15 @@ No browser automation. Just simple API calls that return the exact cookies and h
------
## Impersonate Suite
`curl-cffi` is part of the impersonate suite.
- [curl-impersonate](https://github.com/lexiforest/curl-impersonate). A curl distribution that impersonates browsers.
- [curl_cffi](https://github.com/lexiforest/curl_cffi). Python binding to curl-impersonate.
- [impers](https://github.com/lexiforest/impers). Node.js binding to curl-impersonate.
- [impersonate.pro](https://impersonate.pro). Commercial support, more fingerprints and integrated solutions.
## Features
- Supports JA3/TLS and http2 fingerprints impersonation, including recent browsers and custom fingerprints.
@@ -163,25 +167,28 @@ replacement for "claws" and "agents".
|http/2|✅|❌|✅|
|http/3|☑️<sup>1</sup>|❌|✅|
|human-friendly|☑️<sup>2</sup>|✅|✅|
|colorful|❌|✅|✅|
|colorful|❌|✅|✅<sup>3</sup>|
|fingerprints|❌|❌|✅|
Notes:
1. You need an http/3 enabled curl build, it's not enabled by default, at leat on my machine.
2. As a long time command line user, I personally feel very comfortable using `curl -X POST httpbin.org`, but some users may prefer `http GET httpbin.org` syntax. If you prefer the curl syntax, you can keep using `curl-impersonate`.
3. Install `curl_cffi[cli]` for colorful CLI output. Without `rich`, the CLI uses plain text output.
## Install
pip install curl_cffi --upgrade
This should work on Linux, macOS and Windows out of the box.
If it does not work on you platform, you may need to compile and install `curl-impersonate`
first and set some environment variables like `LD_LIBRARY_PATH`.
Android support, including Termux, is currently in beta, you can install the beta release for testing.
On macOS, you can also install via Homebrew:
brew install lexiforest/tap/curl-cffi
<small>Android support, including Termux, is currently in beta, you can install the beta release for testing.
For BSD systems, we need to get libcurl-impersonate compile first, and then add support in curl_cffi.
If you are using these OSes, please lend an hand.
If you are using these OSes, please lend an hand.</small>
To install beta releases:
@@ -235,11 +242,6 @@ r = curl_cffi.get(
impersonate="chrome"
)
# Randomly choose a browser version based on current market share in real world
# from: https://caniuse.com/usage-table
# NOTE: this is a pro feature.
r = curl_cffi.get("https://example.com", impersonate="realworld")
# To pin a specific version, use version numbers together.
r = curl_cffi.get("https://tls.browserleaks.com/json", impersonate="chrome124")
@@ -273,45 +275,42 @@ print(r.json())
### Supported impersonate browsers
`curl_cffi` supports the same browser versions as supported by my [fork](https://github.com/lexiforest/curl-impersonate) of [curl-impersonate](https://github.com/lwthiker/curl-impersonate):
`curl_cffi` supports the same browser versions preset as supported by our [fork](https://github.com/lexiforest/curl-impersonate) of [curl-impersonate](https://github.com/lwthiker/curl-impersonate):
Open source version of curl_cffi includes versions whose fingerprints differ from previous versions.
If you see a version, e.g. `chrome135`, were skipped, you can simply impersonate it with your own headers and the previous version.
The open source version of `curl_cffi` includes versions when we are adding new capabilities for impersonating.
If you see a version, e.g. `chrome135`, was skipped, it's simply because there's nothing new or we were busy at that time.
You can simply impersonate it with your own headers and the previous browser target.
If you don't want to look up the headers etc, by yourself, consider buying commercial support from [impersonate.pro](https://impersonate.pro),
we have comprehensive browser fingerprints database for almost all the browser versions on various platforms.
For a full list of preset fingerprints, see the [curl-impersonate docs](https://curl-impersonate.readthedocs.io/en/latest/fingerprints.html).
We will no longer put duplicated and outdated info here.
If you are trying to impersonate a target other than a browser, use `ja3=...` and `akamai=...`
If you don't want to look up the headers/etc by yourself, consider buying commercial support from [impersonate.pro](https://impersonate.pro).
We have comprehensive browser tls, http and JavaScript fingerprints database for almost all the browser versions on various platforms.
Since v0.15.1, you can use `curl-cffi update` to retrieve the latest fingerprints, without updating to a new version.
We offer the Safari, Chrome, Firefox updates for free and others as part of the [commercial plan](https://impersonate.pro).
The current number of fingerprints:
![Preset](https://img.shields.io/badge/Preset_Fingerprints-37-blue)
![Free](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.impersonate.pro%2Fv1%2Fcounts&query=%24.free&label=Free%20Fingerprints)
![Pro](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.impersonate.pro%2Fv1%2Fcounts&query=%24.all&label=Pro%20Fingerprints)
To see the current list of fingerprints on your device, use the command line:
```sh
curl-cffi list
```
To update fingerprints from impersonate.pro, use the command line:
```sh
curl-cffi update
```
If you are trying to impersonate a target other than a browser, use `ja3=...`, `akamai=...`, `extra_fp=...`, and `perk=...`
to specify your own customized fingerprints. See the [docs on impersonation](https://curl-cffi.readthedocs.io/en/latest/impersonate/_index.html) for details.
|Browser|Open Source| Pro version|
|---|---|---|
|Chrome|chrome99, chrome100, chrome101, chrome104, chrome107, chrome110, chrome116<sup>[1]</sup>, chrome119<sup>[1]</sup>, chrome120<sup>[1]</sup>, chrome123<sup>[3]</sup>, chrome124<sup>[3]</sup>, chrome131<sup>[4]</sup>, chrome133a<sup>[5][6]</sup>, chrome136<sup>[6]</sup>, chrome142, chrome145<sup>[9]</sup>, chrome146<sup>[9]</sup>|chrome132, chrome134, chrome135|
|Chrome Android| chrome99_android, chrome131_android <sup>[4]</sup>|chrome132_android, chrome133_android, chrome134_android, chrome135_android|
|Chrome iOS|N/A|coming soon|
|Safari <sup>[7]</sup>|safari153 <sup>[2]</sup>, safari155 <sup>[2]</sup>, safari170 <sup>[1]</sup>, safari180 <sup>[4]</sup>, safari184 <sup>[6]</sup>, safari260 <sup>[8]</sup>|coming soon|
|Safari iOS <sup>[7]</sup>| safari172_ios<sup>[1]</sup>, safari180_ios<sup>[4]</sup>, safari184_ios <sup>[6]</sup>, safari260_ios <sup>[8]</sup>|coming soon|
|Firefox|firefox133<sup>[5]</sup>, firefox135<sup>[7]</sup>, firefox144, firefox147<sup>[9]</sup>|coming soon|
|Firefox Android|N/A|firefox135_android|
|Tor|tor145 <sup>[7]</sup>|coming soon|
|Edge|edge99, edge101|edge133, edge135|
|Opera|N/A|coming soon|
|Brave|N/A|coming soon|
Notes:
1. Added in version `0.6.0`.
2. Fixed in version `0.6.0`, previous http2 fingerprints were [not correct](https://github.com/lwthiker/curl-impersonate/issues/215).
3. Added in version `0.7.0`.
4. Added in version `0.8.0`.
5. Added in version `0.9.0`.
6. The version postfix `-a`(e.g. `chrome133a`) means that this is an alternative version, i.e. the fingerprint has not been officially updated by browser, but has been observed because of A/B testing.
5. Added in version `0.10.0`.
6. Added in version `0.11.0`.
7. Since `0.11.0`, the format `safari184_ios` is preferred over `safari18_4_ios`, both are supported, but the latter is quite confusing and hard to parse.
8. Added in `0.12.0`.
9. http3 support included.
### Asyncio
@@ -370,11 +369,13 @@ async with AsyncSession() as session:
print(message)
```
See the WebSocket [docs](https://curl-cffi.readthedocs.io/en/latest/websockets.html) for full details and advanced options.
## Ecosystem
- Integrating with Scrapy: [divtiply/scrapy-curl-cffi](https://github.com/divtiply/scrapy-curl-cffi), [jxlil/scrapy-impersonate](https://github.com/jxlil/scrapy-impersonate) and [tieyongjie/scrapy-fingerprint](https://github.com/tieyongjie/scrapy-fingerprint).
- Integrating with [requests](https://github.com/el1s7/curl-adapter), [httpx](https://github.com/vgavro/httpx-curl-cffi) as adapter.
- Integrating with captcha resolvers: [YesCaptcha](https://yescaptcha.atlassian.net/wiki/spaces/YESCAPTCHA/overview). Please see the head area for promo code and link.
- Integrating with captcha resolvers: [YesCaptcha](https://yescaptcha.com/i/stfnIO).
## Acknowledgement
@@ -0,0 +1,68 @@
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
curl_cffi-0.16.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
curl_cffi-0.16.0.dist-info/METADATA,sha256=2xKv2aRl-HtOcwKKcucrZQ-F51XFo7VpRXQtYLJ0F-c,17463
curl_cffi-0.16.0.dist-info/RECORD,,
curl_cffi-0.16.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
curl_cffi-0.16.0.dist-info/WHEEL,sha256=mLbZYCWBc4fJzW1y2K2PE77e-mZLwFI4W61tEVJsa_c,140
curl_cffi-0.16.0.dist-info/direct_url.json,sha256=foVsUM-Bj-Y-YxcNjF92n-aYB3GyM3Vsbf5rTFvl84g,313
curl_cffi-0.16.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
curl_cffi-0.16.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
curl_cffi-0.16.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
curl_cffi/__init__.py,sha256=6YJwgd_t8GbuHk2mqe7PWNqmkYVTjhX9gCVxzTIyYdc,1986
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
curl_cffi/__pycache__/aio.cpython-314.pyc,,
curl_cffi/__pycache__/const.cpython-314.pyc,,
curl_cffi/__pycache__/curl.cpython-314.pyc,,
curl_cffi/__pycache__/fingerprints.cpython-314.pyc,,
curl_cffi/__pycache__/utils.cpython-314.pyc,,
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
curl_cffi/_wrapper.abi3.so,sha256=SGLe3OukfFgEvCILm0yzeoQ2emxeRX0pgLKMjJGQ88M,13622944
curl_cffi/aio.py,sha256=BxbiWI2ugesL6sHfKk_34A25hzTWAy4mf2G4kRKq29Q,12123
curl_cffi/cli/__init__.py,sha256=6is7D4E46XsOYGYUnfey9maSG4DrARwEk3I4kJv1d-8,7852
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
curl_cffi/cli/__pycache__/pro.cpython-314.pyc,,
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
curl_cffi/cli/doctor.py,sha256=XduqJ51u99yOZ2njyAIIppTSqZJnd_Z8KnsGOXUkip8,1341
curl_cffi/cli/output.py,sha256=IGBa2cN4jdSe7EScutghBXe9mcHcj59RsfIzzPLpx-s,7434
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
curl_cffi/cli/pro.py,sha256=pu2lnZUD91T0oayJMgPs6w6eLYKM6ZFlhHLE-g75UaM,3575
curl_cffi/cli/request.py,sha256=ixFJEhHoymZFukc4dqw_AgNuR7fEtSkd3RZaHZdxOnk,3571
curl_cffi/cli/run.py,sha256=zrxDpPfZPmeY3ADhjR2It2wj3hsOpqaxh88pe11-VMU,7616
curl_cffi/const.py,sha256=gzF7UzT41jfTF7-GGkKAEhG0CIXmeIZtbNAMEZobuig,19101
curl_cffi/curl.py,sha256=GmEF9TW0nYZ8PEcvc-Jsn_7vbymJlqGgmV0P5kzgMoY,30648
curl_cffi/fingerprints.py,sha256=WB25rx-f_ZYdZiEMWF_egnJb8wACZrIncAqu1sHRJf8,20666
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
curl_cffi/requests/__init__.py,sha256=jt8mwSVLOmFgTUOQoxrXEj-qpst8cJbLdVQXVq-U6rw,6449
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cache.cpython-314.pyc,,
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
curl_cffi/requests/__pycache__/streams.cpython-314.pyc,,
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
curl_cffi/requests/cache.py,sha256=sMZB7BA66Ain2VUZ7qWNN-3Fmd2TiPNaxzCybHgnLxY,15629
curl_cffi/requests/cookies.py,sha256=75TLz2wTcIz7NnETCpVVZUk8lvo02y_WFzwOa7A3aZE,13525
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
curl_cffi/requests/exceptions.py,sha256=WM3EAhADLPf_Kh4nzZOtJplTw9Nwv-vCIyeXx7FVT9g,6225
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
curl_cffi/requests/impersonate.py,sha256=MIIBJxlk86nPxFyhfseyEuvY17BZTNYMafKNgGf2eXc,11188
curl_cffi/requests/models.py,sha256=iz5k97OLrOthxAswA9abNQxiWDSQ4lWJrujUAZAMaIo,13173
curl_cffi/requests/session.py,sha256=Mi7Wduep5maFi8p7KBsxTUfa6KB5ArLz5wWGn4j6cXE,66489
curl_cffi/requests/streams.py,sha256=un9rtp5GL-U5A5ivyFIDcfXwxk6MMVrO2CGCbzDdpso,7090
curl_cffi/requests/utils.py,sha256=z2Ee4gBmfUxdv_SOTQrdUtb0uUKaDAxk4kgrcVLqB3Y,37762
curl_cffi/requests/websockets.py,sha256=Hn2Z6Jqf5SKJ-qyQyisr9Iu4vVHQYr-iT9ws_7jPHDI,72783
curl_cffi/utils.py,sha256=xiwsOd-5W3IGNvO6yeDboOkw9wLPD2hx_SYD9i4SqVU,390
@@ -1,5 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Generator: setuptools (83.0.0)
Root-Is-Purelib: false
Generator: delocate 0.13.0
Tag: cp310-abi3-macosx_10_9_universal2
@@ -0,0 +1 @@
{"archive_info": {"hash": "sha256=66b0c44d84b669c513c4798475b5702c2dbd2f061f708bff0b2ee4c9c9563e02", "hashes": {"sha256": "66b0c44d84b669c513c4798475b5702c2dbd2f061f708bff0b2ee4c9c9563e02"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.16.0-cp310-abi3-macosx_10_9_universal2.whl"}
@@ -1,133 +0,0 @@
Metadata-Version: 2.4
Name: importlib_metadata
Version: 8.7.1
Summary: Read metadata from Python packages
Author-email: "Jason R. Coombs" <jaraco@jaraco.com>
License-Expression: Apache-2.0
Project-URL: Source, https://github.com/python/importlib_metadata
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.9
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: zipp>=3.20
Provides-Extra: test
Requires-Dist: pytest!=8.1.*,>=6; extra == "test"
Requires-Dist: packaging; extra == "test"
Requires-Dist: pyfakefs; extra == "test"
Requires-Dist: flufl.flake8; extra == "test"
Requires-Dist: pytest-perf>=0.9.2; extra == "test"
Requires-Dist: jaraco.test>=5.4; extra == "test"
Provides-Extra: doc
Requires-Dist: sphinx>=3.5; extra == "doc"
Requires-Dist: jaraco.packaging>=9.3; extra == "doc"
Requires-Dist: rst.linker>=1.9; extra == "doc"
Requires-Dist: furo; extra == "doc"
Requires-Dist: sphinx-lint; extra == "doc"
Requires-Dist: jaraco.tidelift>=1.4; extra == "doc"
Provides-Extra: perf
Requires-Dist: ipython; extra == "perf"
Provides-Extra: check
Requires-Dist: pytest-checkdocs>=2.4; extra == "check"
Requires-Dist: pytest-ruff>=0.2.1; sys_platform != "cygwin" and extra == "check"
Provides-Extra: cover
Requires-Dist: pytest-cov; extra == "cover"
Provides-Extra: enabler
Requires-Dist: pytest-enabler>=3.4; extra == "enabler"
Provides-Extra: type
Requires-Dist: pytest-mypy>=1.0.1; extra == "type"
Requires-Dist: mypy<1.19; platform_python_implementation == "PyPy" and extra == "type"
Dynamic: license-file
.. image:: https://img.shields.io/pypi/v/importlib_metadata.svg
:target: https://pypi.org/project/importlib_metadata
.. image:: https://img.shields.io/pypi/pyversions/importlib_metadata.svg
.. image:: https://github.com/python/importlib_metadata/actions/workflows/main.yml/badge.svg
:target: https://github.com/python/importlib_metadata/actions?query=workflow%3A%22tests%22
:alt: tests
.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json
:target: https://github.com/astral-sh/ruff
:alt: Ruff
.. image:: https://readthedocs.org/projects/importlib-metadata/badge/?version=latest
:target: https://importlib-metadata.readthedocs.io/en/latest/?badge=latest
.. image:: https://img.shields.io/badge/skeleton-2025-informational
:target: https://blog.jaraco.com/skeleton
.. image:: https://tidelift.com/badges/package/pypi/importlib-metadata
:target: https://tidelift.com/subscription/pkg/pypi-importlib-metadata?utm_source=pypi-importlib-metadata&utm_medium=readme
Library to access the metadata for a Python package.
This package supplies third-party access to the functionality of
`importlib.metadata <https://docs.python.org/3/library/importlib.metadata.html>`_
including improvements added to subsequent Python versions.
Compatibility
=============
New features are introduced in this third-party library and later merged
into CPython. The following table indicates which versions of this library
were contributed to different versions in the standard library:
.. list-table::
:header-rows: 1
* - importlib_metadata
- stdlib
* - 7.0
- 3.13
* - 6.5
- 3.12
* - 4.13
- 3.11
* - 4.6
- 3.10
* - 1.4
- 3.8
Usage
=====
See the `online documentation <https://importlib-metadata.readthedocs.io/>`_
for usage details.
`Finder authors
<https://docs.python.org/3/reference/import.html#finders-and-loaders>`_ can
also add support for custom package installers. See the above documentation
for details.
Caveats
=======
This project primarily supports third-party packages installed by PyPA
tools (or other conforming packages). It does not support:
- Packages in the stdlib.
- Packages installed without metadata.
Project details
===============
* Project home: https://github.com/python/importlib_metadata
* Report bugs at: https://github.com/python/importlib_metadata/issues
* Code hosting: https://github.com/python/importlib_metadata
* Documentation: https://importlib-metadata.readthedocs.io/
For Enterprise
==============
Available as part of the Tidelift Subscription.
This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use.
`Learn more <https://tidelift.com/subscription/pkg/pypi-importlib-metadata?utm_source=pypi-importlib-metadata&utm_medium=referral&utm_campaign=github>`_.
@@ -1,21 +0,0 @@
importlib_metadata-8.7.1.dist-info/INSTALLER,sha256=5hhM4Q4mYTT9z6QB6PGpUAW81PGNFrYrdXMj4oM_6ak,2
importlib_metadata-8.7.1.dist-info/METADATA,sha256=o-OLnuQyYonUhkcE8w4pnudp4jCc6fSnXw3hpQrQo1Y,4670
importlib_metadata-8.7.1.dist-info/RECORD,,
importlib_metadata-8.7.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
importlib_metadata-8.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
importlib_metadata-8.7.1.dist-info/licenses/LICENSE,sha256=RYUC4S2Xu_ZEOGBqIARKqF6wX7CoqAe7NdvsJT_R_AQ,10278
importlib_metadata-8.7.1.dist-info/top_level.txt,sha256=CO3fD9yylANiXkrMo4qHLV_mqXL2sC5JFKgt1yWAT-A,19
importlib_metadata/__init__.py,sha256=u7Ew4-UkpzNY-ka6k-WRkDhQZS1akkLMfWs2eEnUmGo,37734
importlib_metadata/_adapters.py,sha256=r5i8XLrKT6xmrpoREZhZrfczOYDmrVZeJBW5u0HzIGU,3797
importlib_metadata/_collections.py,sha256=CxAhzlF3g1rwu_fMiB53JtRQiUFh0RgiMpoOvmK_ocg,760
importlib_metadata/_compat.py,sha256=VC5ZDLlT-BcshauCShdFJvMNLntJJfZzNK1meGa-enw,1313
importlib_metadata/_functools.py,sha256=0pA2OoiVK6wnsGq8HvVIzgdkvLiZ0nfnfw7IsndjoHk,3510
importlib_metadata/_itertools.py,sha256=nMvp9SfHAQ_JYwK4L2i64lr3GRXGlYlikGTVzWbys_E,5351
importlib_metadata/_meta.py,sha256=EtHyiJ5kGzWFDfKyQ2XQp6Vu113CeadKW1Vf6aGc1B4,1765
importlib_metadata/_text.py,sha256=HCsFksZpJLeTP3NEk_ngrAeXVRRtTrtyh9eOABoRP4A,2166
importlib_metadata/_typing.py,sha256=EQKhhsEgz_Sa-FnePI-faC72rNOOQwopjA1i5pG8FDU,367
importlib_metadata/compat/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
importlib_metadata/compat/py311.py,sha256=uqm-K-uohyj1042TH4a9Er_I5o7667DvulcD-gC_fSA,608
importlib_metadata/compat/py39.py,sha256=J3W7PUVRPNYMmcvT12RF8ndBU9e8_T0Ac4U87Bsrq70,1187
importlib_metadata/diagnose.py,sha256=nkSRMiowlmkhLYhKhvCg9glmt_11Cox-EmLzEbqYTa8,379
importlib_metadata/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -1,5 +0,0 @@
Wheel-Version: 1.0
Generator: setuptools (80.9.0)
Root-Is-Purelib: true
Tag: py3-none-any
@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2025 [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -1,2 +0,0 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam tempor. Ut ullamcorper, ligula eu tempor congue, eros est euismod turpis, id tincidunt sapien risus a quam. Maecenas fermentum consequat mi. Donec fermentum. Pellentesque malesuada nulla a mi. Duis sapien sem, aliquet nec, commodo eget, consequat quis, neque. Aliquam faucibus, elit ut dictum aliquet, felis nisl adipiscing sapien, sed malesuada diam lacus eget erat. Cras mollis scelerisque nunc. Nullam arcu. Aliquam consequat. Curabitur augue lorem, dapibus quis, laoreet et, pretium ac, nisi. Aenean magna nisl, mollis quis, molestie eu, feugiat in, orci. In hac habitasse platea dictumst.
@@ -1,110 +0,0 @@
../../../bin/websockets,sha256=Aec6LJ-SgqOC29VvGIAfJo3DbDp5-znjkFZaF7aEUTM,188
websockets-16.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
websockets-16.0.dist-info/METADATA,sha256=JcDvWo8DVSw5uoDAFbk9N8fJXuRJvnrcLXVBFyBjwN8,6799
websockets-16.0.dist-info/RECORD,,
websockets-16.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets-16.0.dist-info/WHEEL,sha256=MzHA8aA11K4m-mjpjTobbWUqTlhv5RdoytLBFWQb_k4,142
websockets-16.0.dist-info/direct_url.json,sha256=CKQvi9piWBTuZxzuK4J8f71FvL_6alBevkUUKBa4QCg,310
websockets-16.0.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
websockets-16.0.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
websockets-16.0.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
websockets/__init__.py,sha256=AC2Hq92uSc_WOo9_xvITpGshJ7Dy0Md5m2_ywsdSt_Y,7058
websockets/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
websockets/__pycache__/__init__.cpython-314.pyc,,
websockets/__pycache__/__main__.cpython-314.pyc,,
websockets/__pycache__/auth.cpython-314.pyc,,
websockets/__pycache__/cli.cpython-314.pyc,,
websockets/__pycache__/client.cpython-314.pyc,,
websockets/__pycache__/connection.cpython-314.pyc,,
websockets/__pycache__/datastructures.cpython-314.pyc,,
websockets/__pycache__/exceptions.cpython-314.pyc,,
websockets/__pycache__/frames.cpython-314.pyc,,
websockets/__pycache__/headers.cpython-314.pyc,,
websockets/__pycache__/http.cpython-314.pyc,,
websockets/__pycache__/http11.cpython-314.pyc,,
websockets/__pycache__/imports.cpython-314.pyc,,
websockets/__pycache__/protocol.cpython-314.pyc,,
websockets/__pycache__/proxy.cpython-314.pyc,,
websockets/__pycache__/server.cpython-314.pyc,,
websockets/__pycache__/streams.cpython-314.pyc,,
websockets/__pycache__/typing.cpython-314.pyc,,
websockets/__pycache__/uri.cpython-314.pyc,,
websockets/__pycache__/utils.cpython-314.pyc,,
websockets/__pycache__/version.cpython-314.pyc,,
websockets/asyncio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/asyncio/__pycache__/__init__.cpython-314.pyc,,
websockets/asyncio/__pycache__/async_timeout.cpython-314.pyc,,
websockets/asyncio/__pycache__/client.cpython-314.pyc,,
websockets/asyncio/__pycache__/compatibility.cpython-314.pyc,,
websockets/asyncio/__pycache__/connection.cpython-314.pyc,,
websockets/asyncio/__pycache__/messages.cpython-314.pyc,,
websockets/asyncio/__pycache__/router.cpython-314.pyc,,
websockets/asyncio/__pycache__/server.cpython-314.pyc,,
websockets/asyncio/async_timeout.py,sha256=N-6Mubyiaoh66PAXGvCzhgxCM-7V2XiRnH32Xi6J6TE,8971
websockets/asyncio/client.py,sha256=e4xlgtzb3v29M2vN-UDiyoUtThg--d5GqKg3lt2pDdE,30850
websockets/asyncio/compatibility.py,sha256=gkenDDhzNbm6_iXV5Edvbvp6uHZYdrTvGNjt8P_JtyQ,786
websockets/asyncio/connection.py,sha256=87RdVURijJk8V-ShWAWfTEyhW5Z1YUXKV8ezUzxt5L0,49099
websockets/asyncio/messages.py,sha256=u2M5WKY9xPyw8G3nKoXfdO5K41hrTnf4MdizVHzgdM4,11129
websockets/asyncio/router.py,sha256=S-69vszK-SqUCcZbXXPOnux-eH2fTHYC2JNh7tOtmmA,7520
websockets/asyncio/server.py,sha256=wQ9oBc0WBOIzbXKDYJ8UhXRTeoXrSfLu6CWCrUl-vck,37941
websockets/auth.py,sha256=U_Jwmn59ZRQ6EecpOvMizQCG_ZbAvgUf1ik7haZRC3c,568
websockets/cli.py,sha256=YnegH59z93JxSVIGiXiWhR3ktgI6k1_pf_BRLanxKrQ,5336
websockets/client.py,sha256=fljI5k5oQ-Sfm53MCoyTlr2jFtOOIuO13H9bbtpBPes,13789
websockets/connection.py,sha256=OLiMVkNd25_86sB8Q7CrCwBoXy9nA0OCgdgLRA8WUR8,323
websockets/datastructures.py,sha256=Uq2CpjmXak9_pPWcOqh36rzJMo8eCi2lVPTFWDvK5sA,5518
websockets/exceptions.py,sha256=bgaMdqQGGZosAEULeCB30XW2YnwomWa3c8YOrEfeOoY,12859
websockets/extensions/__init__.py,sha256=QkZsxaJVllVSp1uhdD5uPGibdbx_091GrVVfS5LXcpw,98
websockets/extensions/__pycache__/__init__.cpython-314.pyc,,
websockets/extensions/__pycache__/base.cpython-314.pyc,,
websockets/extensions/__pycache__/permessage_deflate.cpython-314.pyc,,
websockets/extensions/base.py,sha256=JNfyk543C7VuPH0QOobiqKoGrzjJILje6sz5ILvOPl4,2903
websockets/extensions/permessage_deflate.py,sha256=AkuhkAKFo5lqJQMXnckbSs9b2KBBrOFsE1DHIcbLL3k,25770
websockets/frames.py,sha256=5IK4GZpl8ukr0bZ_UA_jjjztK09yYQAl9m5NVmGLiK0,12889
websockets/headers.py,sha256=yQnPljVZwV1_V-pOSRKNLG_u827wFC1h72cciojcQ8M,16046
websockets/http.py,sha256=T1tNLmbkFCneXQ6qepBmsVVDXyP9i500IVzTJTeBMR4,659
websockets/http11.py,sha256=T8ai5BcBGkV0n9It63oDeNpmtQMyg8Cpav5rf_yT0r4,15619
websockets/imports.py,sha256=T_B9TUmHoceKMQ-PNphdQQAH2XdxAxwSQNeQEgqILkE,2795
websockets/legacy/__init__.py,sha256=wQ5zRIENGUS_5eKNAX9CRE7x1TwKapKimrQFFWN9Sxs,276
websockets/legacy/__pycache__/__init__.cpython-314.pyc,,
websockets/legacy/__pycache__/auth.cpython-314.pyc,,
websockets/legacy/__pycache__/client.cpython-314.pyc,,
websockets/legacy/__pycache__/exceptions.cpython-314.pyc,,
websockets/legacy/__pycache__/framing.cpython-314.pyc,,
websockets/legacy/__pycache__/handshake.cpython-314.pyc,,
websockets/legacy/__pycache__/http.cpython-314.pyc,,
websockets/legacy/__pycache__/protocol.cpython-314.pyc,,
websockets/legacy/__pycache__/server.cpython-314.pyc,,
websockets/legacy/auth.py,sha256=DcQcCSeVeP93JcH8vFWE0HIJL-X-f23LZ0DsJpav1So,6531
websockets/legacy/client.py,sha256=fV2mbiU9rciXhJfAEKVSm0GztJDUbDpRQ-K5EMbkuQ0,26815
websockets/legacy/exceptions.py,sha256=ViEjpoT09fzx_Zqf0aNGDVtRDNjXaOw0gdCta3LkjFc,1924
websockets/legacy/framing.py,sha256=r9P1wiXv_1XuAVQw8SOPkuE9d4eZ0r_JowAkz9-WV4w,6366
websockets/legacy/handshake.py,sha256=2Nzr5AN2xvDC5EdNP-kB3lOcrAaUNlYuj_-hr_jv7pM,5285
websockets/legacy/http.py,sha256=cOCQmDWhIKQmm8UWGXPW7CDZg03wjogCsb0LP9oetNQ,7061
websockets/legacy/protocol.py,sha256=ajtVXDb-lEm9BN0NF3iEaTI_b1q5fBCKTB9wvUoGOxY,63632
websockets/legacy/server.py,sha256=7mwY-yD0ljNF93oPYumTWD7OIVbCWtaEOw1FFJBhIAM,45251
websockets/protocol.py,sha256=vTqjPIg2HmO-bSxsczuEmWMxPTxPXU1hmVUjqnahV44,27247
websockets/proxy.py,sha256=oFrbEYtasYWv-WDcniObD9nBR5Q5qkHpyCVLngx7WMQ,4969
websockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/server.py,sha256=E4SWBA8WZRmAOpsUm-oCqacBGZre9e0iDmDIrfpV21Q,21790
websockets/speedups.c,sha256=u_dncR4M38EX6He_fzb1TY6D3Hke67ZpoHLLhZZ0hvQ,5920
websockets/speedups.cpython-314-darwin.so,sha256=G3h5TQnbLQ1QpRmDTLznIO5RkWVjFwHMJFy_2-5zXwM,84144
websockets/speedups.pyi,sha256=unjvBNg-uW4c7z-9OW4WiSzZk_QH2bLEcjYAMuoSgBI,102
websockets/streams.py,sha256=pXqga7ttjuF6lChWYiWLSfUlt3FCaQpEX1ae_jvcCeQ,4071
websockets/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/sync/__pycache__/__init__.cpython-314.pyc,,
websockets/sync/__pycache__/client.cpython-314.pyc,,
websockets/sync/__pycache__/connection.cpython-314.pyc,,
websockets/sync/__pycache__/messages.cpython-314.pyc,,
websockets/sync/__pycache__/router.cpython-314.pyc,,
websockets/sync/__pycache__/server.cpython-314.pyc,,
websockets/sync/__pycache__/utils.cpython-314.pyc,,
websockets/sync/client.py,sha256=_2Erytw1f3f9O_u2jLtS1oNV4HsHUi_h3lGvT9ZEaDQ,22108
websockets/sync/connection.py,sha256=1pJYEMRHLWIN7538vJcIeFVnvSXVrD0n1xrfX7wDNSc,41868
websockets/sync/messages.py,sha256=yZV1zhY07ZD0vRF5b1yDa7ug0rbA5UDOCCCQmWwAcds,12858
websockets/sync/router.py,sha256=BqKSAKNZYtRWiOxol9qYeyfgyXRrMNJ6FrTTZLNcXMg,7172
websockets/sync/server.py,sha256=s07HNK_2s1kLN62Uqc77uvND0z7C0YTXGePsCiBtXaE,27655
websockets/sync/utils.py,sha256=TtW-ncYFvJmiSW2gO86ngE2BVsnnBdL-4H88kWNDYbg,1107
websockets/typing.py,sha256=A6xh4m65pRzKAbuOs0kFuGhL4DWIIko-ppS4wvJVc0Q,1946
websockets/uri.py,sha256=2fFMw-AbKJ5HVHNCuw1Rx1MnkCkNWRpogxWhhM30EU4,3125
websockets/utils.py,sha256=AwhS4UmlbKv7meAaR7WNbUqD5JFoStOP1bAyo9sRMus,1197
websockets/version.py,sha256=IhaztWxysdY-pd-0nOubnnPduvySSvdoBwrQdJKtZ2g,3202
@@ -1 +0,0 @@
{"archive_info": {"hash": "sha256=8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", "hashes": {"sha256": "8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/wheels/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl"}
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: websockets
Version: 16.0
Version: 17.0.1
Summary: An implementation of the WebSocket Protocol (RFC 6455 & 7692)
Author-email: Aymeric Augustin <aymeric.augustin@m4x.org>
License-Expression: BSD-3-Clause
@@ -16,12 +16,12 @@ Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.10
Classifier: Programming Language :: Python :: 3.15
Requires-Python: >=3.11
Description-Content-Type: text/x-rst
License-File: LICENSE
Dynamic: description
@@ -32,7 +32,7 @@ Dynamic: license-file
:width: 480px
:alt: websockets
|licence| |version| |pyversions| |tests| |docs| |openssf|
|licence| |version| |pyversions|
.. |licence| image:: https://img.shields.io/pypi/l/websockets.svg
:target: https://pypi.python.org/pypi/websockets
@@ -43,15 +43,6 @@ Dynamic: license-file
.. |pyversions| image:: https://img.shields.io/pypi/pyversions/websockets.svg
:target: https://pypi.python.org/pypi/websockets
.. |tests| image:: https://img.shields.io/github/checks-status/python-websockets/websockets/main?label=tests
:target: https://github.com/python-websockets/websockets/actions/workflows/tests.yml
.. |docs| image:: https://img.shields.io/readthedocs/websockets.svg
:target: https://websockets.readthedocs.io/
.. |openssf| image:: https://bestpractices.coreinfrastructure.org/projects/6475/badge
:target: https://bestpractices.coreinfrastructure.org/projects/6475
What is ``websockets``?
-----------------------
@@ -63,8 +54,8 @@ with a focus on correctness, simplicity, robustness, and performance.
Built on top of ``asyncio``, Python's standard asynchronous I/O framework, the
default implementation provides an elegant coroutine-based API.
An implementation on top of ``threading`` and a Sans-I/O implementation are also
available.
Implementations on top of ``threading`` and ``trio`` are also provided, as well
as a Sans-I/O layer for integration in third-party projects.
`Documentation is available on Read the Docs. <https://websockets.readthedocs.io/>`_
@@ -84,8 +75,8 @@ Here's an echo server with the ``asyncio`` API:
await websocket.send(message)
async def main():
async with serve(echo, "localhost", 8765) as server:
await server.serve_forever()
server = await serve(echo, "localhost", 8765)
await server.serve_forever()
asyncio.run(main())
@@ -0,0 +1,118 @@
../../../bin/websockets,sha256=Aec6LJ-SgqOC29VvGIAfJo3DbDp5-znjkFZaF7aEUTM,188
websockets-17.0.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
websockets-17.0.1.dist-info/METADATA,sha256=Z_btpoffz0ubiHVmim0rYjFMxNdOn_LhaVusK8CZCIA,6342
websockets-17.0.1.dist-info/RECORD,,
websockets-17.0.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets-17.0.1.dist-info/WHEEL,sha256=dgBSXaLX3dDB-DVpHT16BsaK8hj0ZwMOLjJBjWhNK_4,142
websockets-17.0.1.dist-info/direct_url.json,sha256=tKRa4U_R4U5WX4GMQa3dJ7UPcGjl5stIKOYKaN-F0pw,312
websockets-17.0.1.dist-info/entry_points.txt,sha256=Dnhn4dm5EsI4ZMAsHldGF6CwBXZrGXnR7cnK2-XR7zY,51
websockets-17.0.1.dist-info/licenses/LICENSE,sha256=PWoMBQ2L7FL6utUC5F-yW9ArytvXDeo01Ee2oP9Obag,1514
websockets-17.0.1.dist-info/top_level.txt,sha256=CMpdKklxKsvZgCgyltxUWOHibZXZ1uYIVpca9xsQ8Hk,11
websockets/__init__.py,sha256=zmBCJj7EIa6ZJEQDp4dIdznfzAJUA8dW41-j7Em-QDQ,7450
websockets/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
websockets/__pycache__/__init__.cpython-314.pyc,,
websockets/__pycache__/__main__.cpython-314.pyc,,
websockets/__pycache__/auth.cpython-314.pyc,,
websockets/__pycache__/cli.cpython-314.pyc,,
websockets/__pycache__/client.cpython-314.pyc,,
websockets/__pycache__/connection.cpython-314.pyc,,
websockets/__pycache__/datastructures.cpython-314.pyc,,
websockets/__pycache__/exceptions.cpython-314.pyc,,
websockets/__pycache__/frames.cpython-314.pyc,,
websockets/__pycache__/headers.cpython-314.pyc,,
websockets/__pycache__/http11.cpython-314.pyc,,
websockets/__pycache__/imports.cpython-314.pyc,,
websockets/__pycache__/protocol.cpython-314.pyc,,
websockets/__pycache__/proxy.cpython-314.pyc,,
websockets/__pycache__/server.cpython-314.pyc,,
websockets/__pycache__/streams.cpython-314.pyc,,
websockets/__pycache__/typing.cpython-314.pyc,,
websockets/__pycache__/uri.cpython-314.pyc,,
websockets/__pycache__/utils.cpython-314.pyc,,
websockets/__pycache__/version.cpython-314.pyc,,
websockets/asyncio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/asyncio/__pycache__/__init__.cpython-314.pyc,,
websockets/asyncio/__pycache__/client.cpython-314.pyc,,
websockets/asyncio/__pycache__/connection.cpython-314.pyc,,
websockets/asyncio/__pycache__/messages.cpython-314.pyc,,
websockets/asyncio/__pycache__/router.cpython-314.pyc,,
websockets/asyncio/__pycache__/server.cpython-314.pyc,,
websockets/asyncio/client.py,sha256=oAvlgEjRXixmIrttbQZ4AgiA8oetA8EjxmM_36bqoVE,32062
websockets/asyncio/connection.py,sha256=V3L-PrXG8jZ_6IytI-9ftA-JOlmKXlnI9oCS1gWxsIM,49596
websockets/asyncio/messages.py,sha256=vBPd93IuL3LE0OSxeaICIjzvyoht53kmh-_Zho-bGzU,11096
websockets/asyncio/router.py,sha256=gUdYn2dc--kT-kNEFOa_qwo_d5vaX34WyoRNuPOSVrU,7743
websockets/asyncio/server.py,sha256=6Xop-Re8SqhKsBUEtrpc89etFuQiWPPyc9gz7Acrhmo,34111
websockets/auth.py,sha256=U_Jwmn59ZRQ6EecpOvMizQCG_ZbAvgUf1ik7haZRC3c,568
websockets/cli.py,sha256=fYf0YqrWIx_iJDA-RWdczh5vjO9p6ZxZvmE761bUQg0,6892
websockets/client.py,sha256=Dp4j89uLnzXsItZasDs8hV045YfRpX6fYJVuhmuEDA0,14050
websockets/connection.py,sha256=OLiMVkNd25_86sB8Q7CrCwBoXy9nA0OCgdgLRA8WUR8,323
websockets/datastructures.py,sha256=XGTvvgFdZSevNV_T6-Lo-Dxm2VHxJcgKl4l1DNQ6fU8,7238
websockets/exceptions.py,sha256=tp0_EyqQfd7CYNOHmxAieOH8Ed9PSQFt18e-CuReTOY,14308
websockets/extensions/__init__.py,sha256=QkZsxaJVllVSp1uhdD5uPGibdbx_091GrVVfS5LXcpw,98
websockets/extensions/__pycache__/__init__.cpython-314.pyc,,
websockets/extensions/__pycache__/base.cpython-314.pyc,,
websockets/extensions/__pycache__/permessage_deflate.cpython-314.pyc,,
websockets/extensions/base.py,sha256=JNfyk543C7VuPH0QOobiqKoGrzjJILje6sz5ILvOPl4,2903
websockets/extensions/permessage_deflate.py,sha256=IAXdCGY60zfdjatKd6fDfTMizOeQYsnh0zBtL9vPuzA,25840
websockets/frames.py,sha256=Twiqn_buvQiblERugcT-MDmnPZ7kt-FMXESe2DhOT9o,16709
websockets/headers.py,sha256=yQnPljVZwV1_V-pOSRKNLG_u827wFC1h72cciojcQ8M,16046
websockets/http11.py,sha256=tLVuNvNADwt8d1ui5KMFKZCZMTtJ6CqXZveogLaPbf0,18245
websockets/imports.py,sha256=T_B9TUmHoceKMQ-PNphdQQAH2XdxAxwSQNeQEgqILkE,2795
websockets/legacy/__init__.py,sha256=wQ5zRIENGUS_5eKNAX9CRE7x1TwKapKimrQFFWN9Sxs,276
websockets/legacy/__pycache__/__init__.cpython-314.pyc,,
websockets/legacy/__pycache__/auth.cpython-314.pyc,,
websockets/legacy/__pycache__/client.cpython-314.pyc,,
websockets/legacy/__pycache__/exceptions.cpython-314.pyc,,
websockets/legacy/__pycache__/framing.cpython-314.pyc,,
websockets/legacy/__pycache__/handshake.cpython-314.pyc,,
websockets/legacy/__pycache__/http.cpython-314.pyc,,
websockets/legacy/__pycache__/protocol.cpython-314.pyc,,
websockets/legacy/__pycache__/server.cpython-314.pyc,,
websockets/legacy/auth.py,sha256=DcQcCSeVeP93JcH8vFWE0HIJL-X-f23LZ0DsJpav1So,6531
websockets/legacy/client.py,sha256=5oJurUczPddXi8Rde5fA2hD3V4pnv__Z64pRRXP6HBs,27226
websockets/legacy/exceptions.py,sha256=ViEjpoT09fzx_Zqf0aNGDVtRDNjXaOw0gdCta3LkjFc,1924
websockets/legacy/framing.py,sha256=b0wMx83XTIXVZoO60pl3AibKHbQlDb7rBAyhFEkaMCk,6374
websockets/legacy/handshake.py,sha256=2Nzr5AN2xvDC5EdNP-kB3lOcrAaUNlYuj_-hr_jv7pM,5285
websockets/legacy/http.py,sha256=cOCQmDWhIKQmm8UWGXPW7CDZg03wjogCsb0LP9oetNQ,7061
websockets/legacy/protocol.py,sha256=1WUIzDHBew-NZ1ewhmct_WT0ycdTxT4HDdg-Im71z4Y,63456
websockets/legacy/server.py,sha256=hSk4nFTsI3eMRbu5JcI4gXei9Ubw07RSrSjtL4eM2gc,44835
websockets/protocol.py,sha256=3HuCBMLTJMxp9JHd5JMBqtTF84SsY29iuHH0yznuD_Q,27204
websockets/proxy.py,sha256=oFrbEYtasYWv-WDcniObD9nBR5Q5qkHpyCVLngx7WMQ,4969
websockets/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/server.py,sha256=7kEJL1QEFO8pTB90wP4fUbkzYGDcbkTa7jTvdiSZtQk,24679
websockets/speedups.c,sha256=u_dncR4M38EX6He_fzb1TY6D3Hke67ZpoHLLhZZ0hvQ,5920
websockets/speedups.cpython-314-darwin.so,sha256=pJidRkiyZA-9quLsUEgjRjs8b8bpobn1FV25pvq38KM,84144
websockets/speedups.pyi,sha256=unjvBNg-uW4c7z-9OW4WiSzZk_QH2bLEcjYAMuoSgBI,102
websockets/streams.py,sha256=rJrDNSL9hy3EJYc1PFHjYx4rdd1vELCua0CwdFiHN_8,4610
websockets/sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/sync/__pycache__/__init__.cpython-314.pyc,,
websockets/sync/__pycache__/client.cpython-314.pyc,,
websockets/sync/__pycache__/connection.cpython-314.pyc,,
websockets/sync/__pycache__/messages.cpython-314.pyc,,
websockets/sync/__pycache__/router.cpython-314.pyc,,
websockets/sync/__pycache__/server.cpython-314.pyc,,
websockets/sync/__pycache__/utils.cpython-314.pyc,,
websockets/sync/client.py,sha256=j9fvsOwCzJgQnaAyOaVYw6iT_IdPEM70JFLjeqXz2jw,22216
websockets/sync/connection.py,sha256=xJeJZZZnbILJUhcNg_lBWL26Imv-t2jsnnO5y_ecHf4,48298
websockets/sync/messages.py,sha256=q8K1vxdrqVk1bvn3zW1Jx09TlakIWSyYcOHC7ueFQmk,12826
websockets/sync/router.py,sha256=BqKSAKNZYtRWiOxol9qYeyfgyXRrMNJ6FrTTZLNcXMg,7172
websockets/sync/server.py,sha256=fVZZiaWW8ISoqFcJY5ZNa-MBxtLCYCP4ALJAtncgF3A,34346
websockets/sync/utils.py,sha256=TtW-ncYFvJmiSW2gO86ngE2BVsnnBdL-4H88kWNDYbg,1107
websockets/trio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
websockets/trio/__pycache__/__init__.cpython-314.pyc,,
websockets/trio/__pycache__/client.cpython-314.pyc,,
websockets/trio/__pycache__/connection.cpython-314.pyc,,
websockets/trio/__pycache__/messages.cpython-314.pyc,,
websockets/trio/__pycache__/router.cpython-314.pyc,,
websockets/trio/__pycache__/server.cpython-314.pyc,,
websockets/trio/__pycache__/utils.cpython-314.pyc,,
websockets/trio/client.py,sha256=VwpJ9bYECIPMrssX7OQ2rriNVjB3LitDqXSHEfEJwQI,29509
websockets/trio/connection.py,sha256=lalwJYhFNZqYDP7e69oo9KXebFNsbyM8wlmoqsMcf0s,49218
websockets/trio/messages.py,sha256=goDc5rfINExnvyfETWYYDT3-thgRJd7DPr4ujwJD3P8,10269
websockets/trio/router.py,sha256=qZL9-44mi9SOOTGU3CLBPuaCNzafQGqZ-fJl9hVpv-s,7196
websockets/trio/server.py,sha256=jRtrfZf3hHp1aQo6ffNMN7siaWzvZVhqC1iq1_Pj0sA,25805
websockets/trio/utils.py,sha256=A12NYUPOjQaiGusak43oWzPIcCxhikKTkAJLXtZJvcY,956
websockets/typing.py,sha256=zL4Pi_pQHJGVlL4cTyFauqxgMBAFI5B6dfvMVn7SFYs,1710
websockets/uri.py,sha256=2fFMw-AbKJ5HVHNCuw1Rx1MnkCkNWRpogxWhhM30EU4,3125
websockets/utils.py,sha256=Wdtsh2qQiHLrXUBEnp3LatMLJTZjB2CErbeOFvrdXvE,2022
websockets/version.py,sha256=TJPgFOcL4xsH8I2KiQu1V3j8kSHbOd_EWKNVnnOu8SE,3204
@@ -1,5 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (80.9.0)
Generator: setuptools (83.0.0)
Root-Is-Purelib: false
Tag: cp314-cp314-macosx_10_15_universal2
Generator: delocate 0.13.0
@@ -0,0 +1 @@
{"archive_info": {"hash": "sha256=53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", "hashes": {"sha256": "53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/wheels/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl"}

Some files were not shown because too many files have changed in this diff Show More