Compare commits

...
Author SHA1 Message Date
Levi cc96cff2ea Merge PR #1583: optimize extension API loading
# Conflicts:
#	ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart
2026-10-10 17:40:56 +08:00
Levi 15738644b0 perf(extensions): load store summaries without waiting for update checks 2026-10-10 17:32:43 +08:00
Levi 44a5f9eefc Merge PR #1582: widen macOS navigation rail 2026-10-10 14:43:06 +08:00
Levi c90c690836 Merge PR #1581: synchronize extension cookie range coverage
# Conflicts:
#	ui/flutter/test/widget_test.dart
2026-10-10 14:43:04 +08:00
Levi dee1c2515f Merge PR #1535: keep extension update chip within card header 2026-10-10 14:42:53 +08:00
Levi bbf1a9fd7d fix(ui): widen navigation rail for macOS traffic lights 2026-10-10 14:34:11 +08:00
Levi b3f462bb7d test(http): synchronize ignored Range response coverage 2026-10-09 21:15:51 +08:00
Levi 0d08004c49 revert: remove macOS traffic light adjustments 2026-10-09 21:13:55 +08:00
Levi 1589d4cbe7 fix: update macOS traffic lights through window events 2026-10-09 20:58:24 +08:00
Levi b6ce7756fb fix: keep macOS traffic lights within the navigation rail 2026-10-09 20:49:14 +08:00
Levi 342c55fbd6 fix: use server-side fetch header permissions for extensions 2026-10-09 19:59:06 +08:00
Levi fe2f45e40d fix: preserve explicit Cookie headers in extension requests 2026-10-09 19:39:05 +08:00
WuFeng ba84a04a43 fix(settings): use Save as the primary label on settings form dialogs (#1571) 2026-10-08 16:31:16 +08:00
Levi 2d4a8cddc1 fix(ui): keep extension update chip within card header 2026-09-26 11:05:43 +08:00
Levi 224b488087 feat(task): record and display task completion time (#1536) 2026-09-25 19:15:31 +08:00
Levi 7ce4fb7f0e fix(ui): smooth task progress bar updates (#1532) 2026-09-25 19:08:20 +08:00
Levi 76c22c50d8 test: align beta2 integration checks 2026-09-25 16:36:27 +08:00
Levi f400137ead Merge PR #1542 into 2.0.0-beta.2 2026-09-25 16:23:55 +08:00
Levi a5ebbca318 Merge PR #1536 into 2.0.0-beta.2
# Conflicts:
#	ui/flutter/test/widget_test.dart
2026-09-25 16:23:55 +08:00
Levi 8f2af5660e Merge PR #1535 into 2.0.0-beta.2 2026-09-25 16:23:39 +08:00
Levi 932a49c1d1 Merge PR #1532 into 2.0.0-beta.2 2026-09-25 16:23:39 +08:00
Levi 212c735b66 Merge latest main into 2.0.0-beta.2 2026-09-25 16:23:39 +08:00
UltraGeek0102 8d1ae93b03 refactor(ios): clean up continued processing task handling (#1547) 2026-09-25 16:21:09 +08:00
ashish6298 b5e8d61f01 fix: move checksum to HTTP opts, fix mismatch retry, scope get_download_speed to aggregate-only 2026-09-25 12:15:50 +05:30
JiangJiang 363153fa33 fix(http): use Content-Range total size for 206 responses (#1545) 2026-09-23 09:52:59 +08:00
Levi f0dab90e09 feat(extension): expose request mutation methods on task.meta.req (#1538) 2026-09-23 09:33:28 +08:00
doctor-martin-sowa 6e60889ce8 feat(lang): Add Slovak localization (app_sk.arb) (#1541) 2026-09-23 09:32:07 +08:00
ashish6298 929e2d2d7c test(base): add unit tests for ChecksumOption serialization 2026-09-22 16:36:41 +05:30
ashish6298 e93437c95f feat: implement Model Context Protocol (MCP) server and download manager capabilities 2026-09-22 16:16:01 +05:30
Levi 88d150b933 test(bt): isolate cleanup fixtures from part-file promotion (#1531) 2026-09-21 22:30:54 +08:00
Levi 7bc7efed5b feat(task): show a dash for completed tasks without a done time
Tasks completed before this change have no doneAt value; the drawer now
always renders the Completed at row for completed tasks and falls back
to a dash instead of hiding the row.
2026-09-20 09:28:46 +08:00
Levi 1d8a502ac4 fix(task): keep DoneAt once written instead of clearing it
Done tasks never leave the done state (doStart, pause and markTaskError
all guard against Done), so the else branch clearing DoneAt was
unreachable dead code. DoneAt is now written once on completion and
kept for the task lifetime.
2026-09-20 09:21:36 +08:00
Levi 4b231f52c5 feat(task): record and display task completion time
Add DoneAt to the download task model: set when a task finishes and
cleared when it is downloaded again. The value flows through the API
model and task record, and is shown as "Completed at" in the task
drawer info tab next to the creation time.
2026-09-20 08:56:54 +08:00
Levi e956bd1ce5 style(ui): tighten card update chip spacing to 4px 2026-09-20 08:05:00 +08:00
Levi 68402c85d3 revert(ui): keep extension update check silent on failure
A failed check simply means no update chip; the failure toast and its
state field are removed.
2026-09-20 08:02:22 +08:00
Levi a480c5bd14 fix(ui): align extension update dialog title with app upgrade dialog 2026-09-19 22:16:37 +08:00
Levi e779de285d fix(ui): card chip spacing, instant flag clearing, locale-wide label
- Space the update chip off the card title to match the detail header
- Clear the extension's update flag right after a successful upgrade so
  the chip disappears without waiting for the background re-check
- Use the concise Update label across all locales
2026-09-19 21:56:46 +08:00
Levi fac17b0ccc style(ui): shorten update button label to Update
The shared newVersionUpdate label now reads Update on the app upgrade
dialog, the tracker update button and the extension update dialog, so
the extension flow matches the host dialog wording.
2026-09-19 21:47:44 +08:00
Levi 8c728ffda7 style(ui): render the update status as a bordered chip
Pill shape with a brand-green tint, outline and dot so the status
reads as a badge next to the title instead of plain text.
2026-09-19 21:41:56 +08:00
Levi 1b4956b0ec fix(ui): reserve the pointer cursor for the update chip
Extension cards no longer show a pointer cursor on hover; the tappable
update status chip shows it instead, on cards and detail headers alike.
2026-09-19 21:18:57 +08:00
Levi 2e5f8948be fix(ui): restore homepage and GitHub buttons on extension cards 2026-09-19 20:55:22 +08:00
Levi 04ebca60cf refactor(ui): make the status chip the only update entry
Remove the update button from the detail actions; the green chip next
to the extension title opens the update dialog on cards, the desktop
drawer and the mobile details page alike.
2026-09-19 20:49:20 +08:00
Levi 948f6d5487 fix(ui): let the update status chip shrink instead of overflow
Long translations (e.g. Italian, Tamil) could exceed the card title row
on minimum-width cards. The chip is now flex-sized and its label
ellipsizes, so the title and chip degrade gracefully at any width and
locale.
2026-09-19 20:02:28 +08:00
Levi 51b7abed48 refactor(ui): replace update dot with tappable status chip
Card and detail headers now show a green update chip next to the
extension title; on cards it opens the update dialog directly. The red
dot and the red card button are gone, and the status chip no longer
duplicates inside the detail content. Shorten the English label so the
chip fits narrow cards.
2026-09-19 19:47:02 +08:00
Levi d8758267c4 refactor(ui): polish extension update affordance per review
- Move the update dot from the icon corner to beside the card title
- Restyle the card update button to match the row's ghost buttons
- Drop the detail-view dot; the existing update status chip covers it
- Rename the detail update button label to just Update
2026-09-19 19:04:58 +08:00
Levi d94b59b165 refactor(ui): open extension details from every card
Installed extensions missing from the store index previously had no tap
handler on their card, so they kept on-card homepage/GitHub buttons as
the only metadata entry. Make all cards open the detail view and drop
the homepage/GitHub buttons entirely; details remain the single place
for links.
2026-09-19 18:43:57 +08:00
Levi f34cb9c971 refactor(ui): declutter extension card actions
Drop homepage/GitHub buttons from store extension cards (they remain on
the detail page); keep them only for unlisted extensions whose cards do
not open a detail view. With the labeled update button this caps card
actions at update/settings/delete.
2026-09-19 18:07:28 +08:00
Levi 172f4a3650 fix(ui): unify extension update check and improve update affordance
- Update availability is now decided solely by the backend live GitHub
  check (updateFlags); the store index may lag and previously masked
  new versions in the market view.
- Replace the ambiguous refresh icon on extension cards with a labeled
  update button, and add a red dot badge on extension icons (card and
  detail view) when an update is available.
- Surface a toast when the update check fails instead of failing silently.
2026-09-19 17:32:44 +08:00
Levi 694b43e3f9 docs: update beta download link to v2.0.0-beta.3 2026-09-19 08:16:57 +08:00
Levi 085ebf357a fix(ui): smooth task progress bar updates 2026-09-19 00:14:15 +08:00
Levi dae13dec01 Merge latest PR #1527 into 2.0.0-beta.2 2026-09-18 09:43:51 +08:00
Levi a8f682559f Merge latest PR #1523 into 2.0.0-beta.2 2026-09-18 09:43:51 +08:00
Levi 3c0c019bc9 fix(bt): derive cleanup boundaries from torrent storage layout 2026-09-18 09:33:42 +08:00
Levi 69b3e223ad fix(bt): always preserve the torrent root directory 2026-09-18 09:23:46 +08:00
Levi 183616fcc0 fix(webview): suppress GTK fallback error document events 2026-09-18 09:13:40 +08:00
Levi 3415364ab7 test(webview): allow for throttled timers and diagnose selector timeouts 2026-09-18 09:04:39 +08:00
Levi 1b00a16277 fix(webview): update native callback lifetime and test coverage instrumentation 2026-09-18 09:00:50 +08:00
Levi b33c00d90d ci: resolve Linux native crash address in covered test binary 2026-09-18 00:36:41 +08:00
Levi 7e07ea31cc test(webview): cover event stream failures and cleanup 2026-09-18 00:31:06 +08:00
Levi c2da22f371 fix(webview): update GTK lifecycle cleanup and repeat Linux integration tests 2026-09-18 00:21:13 +08:00
Levi 2fd8a96c5d fix(webview): ignore load notifications from browser error documents 2026-09-18 00:07:55 +08:00
Levi 1776b05782 test(webview): trace navigation completion in event contract 2026-09-18 00:03:03 +08:00
Levi ffbae58e6b test(webview): trigger navigation failure consistently through proxies 2026-09-17 23:58:30 +08:00
Levi 2c7a86b21c build(deps): use webview_go with Go 1.17 minimum 2026-09-17 23:52:58 +08:00
Levi 4157e0a5bc fix(deps): update webview_go with legacy Go compatibility fix 2026-09-17 23:51:26 +08:00
Levi dfe8e9a3fd feat(extension): expose host version and WebView page events 2026-09-17 23:43:39 +08:00
Levi 7c33d978c4 fix(bt): clean partial files and empty parent directories 2026-09-17 23:35:40 +08:00
Levi 534fbbb3a2 Merge latest PR #1524 into 2.0.0-beta.2 2026-09-17 23:03:45 +08:00
Levi fdb2768008 Merge latest PR #1523 into 2.0.0-beta.2 2026-09-17 23:03:45 +08:00
Levi 27202cf99e Merge latest PR #1518 into 2.0.0-beta.2 2026-09-17 23:03:45 +08:00
Levi a05eea3268 fix(ui): restore tray menu dismissal and use upstream plugin 2026-09-17 23:02:20 +08:00
Levi 7496331ffd fix(flutter): align notification file actions with task cards 2026-09-17 23:02:14 +08:00
Levi 967ef25408 fix(bt): remove unselected files from the correct download path 2026-09-17 22:47:07 +08:00
Levi d1b2ae42af fix: finish migration to main WebView profile library 2026-09-17 21:48:41 +08:00
Levi adeb7a2895 Merge PR #1526 into 2.0.0-beta.2 2026-09-17 21:47:44 +08:00
Levi edfecefc16 Merge PR #1524 into 2.0.0-beta.2 2026-09-17 21:47:44 +08:00
Levi f7dfc4226b Merge PR #1523 into 2.0.0-beta.2 2026-09-17 21:47:44 +08:00
Levi edb8d3cbdc Merge PR #1520 into 2.0.0-beta.2 2026-09-17 21:47:43 +08:00
Levi c6dd58848d Merge latest PR #1518 into 2.0.0-beta.2 2026-09-17 21:47:43 +08:00
Levi 88dd35cc46 Merge latest main into 2.0.0-beta.2
# Conflicts:
#	internal/webview/README.md
#	ui/flutter/lib/app/rpc/webview_rpc_service.dart
#	ui/flutter/pubspec.lock
#	ui/flutter/pubspec.yaml
2026-09-17 21:47:43 +08:00
Levi 051d4267a5 fix(ui): restore path placeholders and window shortcut 2026-09-17 21:08:30 +08:00
sysy 022fbf633b fix: sanitize archive entry paths before extraction to prevent path traversal
Move path sanitization before directory handling in extractFile so
directory entries with "../" prefixes are rejected the same way file
entries already are. Add the same sanitization to extractSevenZipMultiPart
and both zip extraction loops in the Windows updater, which had no path
validation at all.
2026-09-16 23:31:35 -04:00
Levi 014221a1a7 fix(flutter): drop action buttons from error notifications
A failed download has no usable output file, so error notifications now
carry no buttons at all; only the done event offers open file / open
folder actions.
2026-09-17 09:55:07 +08:00
Levi 6f4635e2eb feat(flutter): add action buttons to desktop download notifications
Terminal task notifications now carry actionable buttons and respond to
clicks instead of being inert:

- Done notifications offer Open File and Open Folder for single-file
  tasks, and Open Folder only for folder tasks (e.g. multi-file
  torrents), following the same rule as the task details page. Error
  notifications offer Open Folder only, since the output is rarely
  usable.
- Clicking the notification body brings the app window back to the
  front (show + focus), covering the close-to-tray case.
- The click target is snapshotted into the notification (Windows: action
  arguments; macOS/Linux: notification payload) so buttons keep working
  after the task record is deleted, and a missing file is logged instead
  of surfacing an OS error dialog.
- macOS resolves buttons through three pre-registered notification
  categories (done single-file / done folder / error); Windows uses the
  actions API of the pinned notification plugin fork; Linux uses
  freedesktop notification actions.
- New openFolder label localized in all 22 ARB catalogs.

Cold-start activation is intentionally out of scope: when the process is
not running, OS activation opens the main window without dispatching the
button action.
2026-09-17 09:49:20 +08:00
Levi a83e3a2b84 Merge remote-tracking branch 'origin/main' into codex/fix-bt-file-selection
# Conflicts:
#	ui/flutter/test/features/tasks/domain/task_record_test.dart
2026-09-17 00:03:36 +08:00
Levi 5bb040eaaf fix(bt): honor selected files when creating resolved tasks 2026-09-17 00:03:03 +08:00
Levi e27c7dddb4 fix(http): close prefetch response when temporary-file setup fails 2026-09-16 23:43:43 +08:00
Levi 5342be16d9 Merge main into codex/tempdir-media-spooling 2026-09-16 23:20:29 +08:00
Levi e0ced260e2 feat: spool media inputs to disk and report producer progress 2026-09-16 23:16:19 +08:00
Levi d41e27234d Merge PR #1518 into 2.0.0-beta.2 2026-09-15 23:06:45 +08:00
Levi c703c52cbc fix(ui): refine narrow layouts and enable tab swipes 2026-09-15 23:01:48 +08:00
Levi b88144c864 Merge PR #1517 into 2.0.0-beta.2
# Conflicts:
#	go.mod
2026-09-15 22:51:56 +08:00
Levi 273850ae3f Merge PR #1516 into 2.0.0-beta.2 2026-09-15 22:51:41 +08:00
Levi 07356033f9 Merge PR #1515 into 2.0.0-beta.2 2026-09-15 22:51:41 +08:00
Levi 166aee81be Merge latest PR #1512 into 2.0.0-beta.2 2026-09-15 22:51:40 +08:00
Levi 45d7b6f5bc Merge latest main into 2.0.0-beta.2 2026-09-15 22:51:31 +08:00
Levi 5845e9ea5f Merge branch 'main' into main 2026-09-15 22:49:37 +08:00
sun f9410bbcc7 fix(hls): snapshot state in Store and publish fully initialized runs
Address the completion review findings on top of the upstream review
fixes:

- Store runs on the engine's checkpoint goroutine without the task
  lock and serializes the returned value afterwards: take the fetcher
  mutex and return a deep copy instead of the live state pointer,
  matching the HTTP protocol's Store pattern
- Load the journal and per-run base counters before publishing the run
  as active, so Stats/Progress never observe half-initialized fields
- Bind the journal fingerprint to the key IV as well: a rotated IV on
  an otherwise identical plan would silently decrypt to garbage
- Keep every EXT-X-MEDIA member per group instead of only the first
  one, so a group with a URI on a later member is still detected
- After a merge that raced with a pause, keep the staged segments and
  exit silently instead of cleaning them up; drop a dead context
  check and an unused helper, and make the MAP identity and merge
  part-file names unambiguous
2026-09-15 16:31:35 +08:00
sun f1f17948c6 fix(hls): enforce supported HLS subset with explicit errors
The merge pipeline concatenates stored bytes, so features it cannot
reproduce faithfully are now detected and rejected with a clear error
instead of silently succeeding with a broken output:

- EXT-X-GAP (a hole in the merged file) and EXT-X-DISCONTINUITY
  (a broken timeline)
- separate audio renditions referenced by the selected variant: the
  master EXT-X-MEDIA groups are parsed and an AUDIO group with its own
  URI fails the resolve; unreferenced groups are ignored
- changing init sections: a repeated EXT-X-MAP is compared by all of
  its attributes (URI and BYTERANGE), not just the URI
- encrypted EXT-X-MAP without an explicit IV: the spec does not define
  a sequence-derived IV for init sections
- non-identity AES-128 KEYFORMAT values

The supported matrix is documented in the package doc.

Add regression tests for every rejection plus real media validation:
ffmpeg-generated TS segments and fMP4 init/fragments are committed as
fixtures, downloaded through the fetcher (including an AES-128 key
rotation variant), and the merged outputs are validated structurally -
TS on the 188 byte grid with continuity counter rules, PAT/PMT and PES
presence, fMP4 by walking the ISO-BMFF box hierarchy.
2026-09-15 11:42:06 +08:00
sun 675028c64a fix(hls): isolate staging per task and finalize output atomically
- Give every task a random staging identity persisted in the saved
  state: two tasks downloading the same playlist into the same
  directory get separate segment folders and journals instead of
  deleting each other's progress
- Bind the journal to a fingerprint of the full download plan (media
  URL, ordered segments, ranges, keys); foreign or unreadable journals
  are discarded rather than applied to the wrong segments, and the
  ledger is rewritten through a temp file + rename
- Legacy states without a staging identity migrate to a fresh folder
  instead of reusing the shared URL-hash folder
- Merge into a unique temporary file next to the target, check write
  and close errors, then remove-then-rename over the final name: a
  failed merge preserves the staged segments for a retry and re-merging
  after a crash stays idempotent
- Drop the staging folder when a URL patch invalidates the plan
2026-09-15 11:27:26 +08:00
sun b6d18a267b fix(hls): rebuild request extras on restore and adopt merge semantics in Patch
- Parse the request extra in Setup so restored fetchers resend Referer,
  Cookie and other configured headers on resumed segment/key requests;
  the store/restore regression test round-trips the state and the task
  meta through JSON exactly like the engine storage does
- Rebuild the resolved plan on Start when the resource survived but the
  plan was lost to a crash between the task save and the first
  checkpoint, keeping the existing output name
- Patch now merges partial updates instead of replacing the request and
  options wholesale: URL-only patches keep the output path and renamed
  file, header-only patches keep the resolved plan and take effect on
  the next run, Labels merge per key, Proxy swaps rebuild the client,
  and nil arguments are no longer a panic. Editing a running task is
  rejected, consistent with the ed2k protocol
- Cover retry-after-failure counter resets, running-task patch
  rejection and the missing-state self-heal with regression tests
2026-09-15 11:18:40 +08:00
sun 40cfb167f6 fix(hls): isolate per-run lifecycle and use final-output byte accounting
- Give every Start its own run context, WaitGroup (including the queue
  producer), supervisor done channel and snapshots of the plan, request
  headers, HTTP client and merge target; Pause/Close wait for all run
  goroutines, including a merge in flight, before returning
- Build the HTTP client in Resolve/Start before workers launch instead of
  lazily inside do(), which raced between concurrent segment downloads
- Reject task edits while a run is active instead of mutating shared state
- Reset counters per run and seed them from the completed-segment journal;
  count only stored output bytes as progress, never discarded retry bytes
- Report Res.Size only when every segment is unencrypted with a known
  length; ciphertext sums overstate the final output, indeterminate plans
  rely on the engine's TotalDownloaded backfill at completion
2026-09-15 11:13:38 +08:00
Levi 4fd1939732 fix(ffmpeg): reserve capacity before creating media inputs 2026-09-15 10:08:29 +08:00
Levi 3ed54ba93a fix(macos): resign WebView input focus before disposal 2026-09-15 09:15:27 +08:00
Levi fe64a328ac fix(webview): synchronize Windows browser shutdown before reopening profiles 2026-09-15 09:06:56 +08:00
Levi 7e49b678f2 fix(webview): release retained macOS views before profile removal 2026-09-15 09:01:58 +08:00
Levi 311e002e55 Return persistent cookie expiration through the existing RPC contract 2026-09-15 00:56:50 +08:00
Levi 119fad9a94 Use explicit WebView2 shutdown and verify persistent cookie expiration 2026-09-15 00:54:12 +08:00
Levi e2601ea9fa Keep Linux WebViews on one persistent GTK UI thread 2026-09-15 00:52:05 +08:00
Levi a5eaa6b528 Wait for cookie propagation and identify profile assertion stages 2026-09-15 00:51:07 +08:00
Levi bed624bfce Assert HTTPS proxy routing independently of native error-page behavior 2026-09-15 00:44:20 +08:00
Levi eec53c15b2 Extend existing provider contracts and remove duplicate WebView CI 2026-09-15 00:41:47 +08:00
Levi d58135db9f Wait for Android native views to close before clearing profiles 2026-09-14 10:13:08 +08:00
Levi d642ae5018 Remove extension WebView data during uninstall 2026-09-14 10:11:53 +08:00
Levi e358123b22 fix(flutter): keep waiting task progress static 2026-09-14 10:02:19 +08:00
sun ee9b16794e Merge branch 'GopeedLab:main' into main 2026-09-14 09:55:31 +08:00
sun 4f74101b3a feat(hls): add native HLS (m3u8) download protocol
New hls protocol in internal/protocol/hls (registered ahead of http):
- master playlist variant picking (highest bandwidth), RFC3986 segment
  URL resolution, fMP4 init segments, EXT-X-BYTERANGE, EXT-X-GAP skip
- AES-128 decryption with mid-playlist key rotation and sequence-derived
  IVs; explicit errors for live streams, SAMPLE-AES and multi-period fMP4
- concurrent segment downloads with retry, journal-based resume across
  pause/app restarts, automatic in-order merge into a single .ts/.mp4
- best-effort Content-Length probing for determinate progress
- anti-hotlink Referer/Cookie/UA via task extra headers (Go transport
  does not filter them)

Core: fix SchemeFilter extension match for URLs with query strings
(also benefits .torrent links); Flutter: Protocol.hls enum, hls protocol
config model (prevents config round-trip wipe) and HLS settings block
with localized labels in all 22 locales.

Tests: parser/crypto unit tests, httptest e2e (Referer-enforced CDN,
encrypted streams, byterange, pause/resume, store/restore) and an
opt-in live-site acceptance gate (HLS_LIVE_URL).
2026-09-14 09:55:31 +08:00
Levi c47b735fc4 Preserve existing profile connections when opening more pages 2026-09-14 00:43:50 +08:00
Levi ca98806dcc Pin verified native backend and complete proxy regression checks 2026-09-14 00:34:27 +08:00
Levi a0e84a75b1 Isolate extension WebView profiles and follow host proxy settings 2026-09-14 00:30:52 +08:00
Levi 14b22cf485 test: align failed task speed assertion with PR #1510 behavior 2026-09-13 22:27:32 +08:00
Levi a3f6ec5885 Merge latest PR #1512 into 2.0.0-beta.2 2026-09-13 22:25:20 +08:00
Levi f839c3ac93 Merge latest PR #1511 into 2.0.0-beta.2 2026-09-13 22:25:20 +08:00
Levi 67d4aa9554 Merge latest PR #1510 into 2.0.0-beta.2
# Conflicts:
#	ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
#	ui/flutter/test/widget_test.dart
2026-09-13 22:25:20 +08:00
Levi c355f9ff5c fix: retain zero download speed for known-size tasks 2026-09-13 18:56:37 +08:00
Levi 59a7d40bfe fix: select downloading tab after mobile task creation 2026-09-13 18:46:19 +08:00
Levi 5d83e91ce5 fix(stream): bound FFmpeg memory buffers and propagate Blob backpressure 2026-09-13 18:24:32 +08:00
Levi 637004c8a8 fix(ffmpeg): use app-writable temporary directory for stream buffers 2026-09-13 18:00:10 +08:00
Levi e79b37d08c fix: remove mobile reveal-to-open-file fallback 2026-09-13 17:39:54 +08:00
Levi d393d27ec7 fix: hide completed task folder action on mobile and web 2026-09-13 17:38:34 +08:00
Levi 2e8672273c feat: confirm extension updates with progress and retry 2026-09-13 17:25:31 +08:00
Levi 4802802828 fix: limit URL install loading to its submit button 2026-09-13 17:20:08 +08:00
Levi 3d372f98de fix: preserve install URL paste focus and limit local installs to desktop 2026-09-13 17:18:13 +08:00
Levi d578fe96bc test(ui): cover minimum extension card width 2026-09-13 15:39:04 +08:00
Levi 1262844363 fix(ui): keep three extension columns at default width 2026-09-13 15:36:36 +08:00
Levi 91bea83692 Merge PR #1512 into 2.0.0-beta.2 2026-09-13 15:30:10 +08:00
Levi e21bc5eeae Merge PR #1511 into 2.0.0-beta.2 2026-09-13 15:30:10 +08:00
Levi 6331060171 Merge PR #1510 into 2.0.0-beta.2 2026-09-13 15:30:10 +08:00
Levi f3ec6b8ee9 Merge latest main into 2.0.0-beta.2 2026-09-13 15:30:00 +08:00
Levi 9124976b5c feat(extension): add streaming FFmpeg WASM merge runtime 2026-09-13 15:23:29 +08:00
Levi 9820e58de9 Merge branch 'main' of github.com:GopeedLab/gopeed into codex/fix-task-detail-text-selection 2026-09-13 15:07:02 +08:00
Levi e890e593d9 feat(ui): compact number display in extension stats 2026-09-13 15:06:42 +08:00
Levi 6572e9e899 fix: automatically submit scheme extension installs 2026-09-13 15:01:50 +08:00
Levi 1c57b07575 fix: prefill scheme extension installs and forward iOS deep links 2026-09-13 14:31:36 +08:00
Levi 6f12eb1f07 Merge Android cold-start back fix and bottom exit prompt into beta 2026-09-12 20:40:25 +08:00
Levi bd03d3b31c fix(android): intercept back during cold startup and move exit toast down 2026-09-12 20:40:07 +08:00
Levi d330d8bea8 Merge mobile back navigation and exit confirmation into beta 2026-09-12 19:03:24 +08:00
Levi ee6b073bc1 fix(ui): preserve mobile home route and confirm Android exit 2026-09-12 18:56:52 +08:00
Levi a41c6c145c Merge latest PR #1505: compact creation button and inline empty-list guidance 2026-09-12 17:49:31 +08:00
Levi 86c8383584 feat(ui): simplify empty-list guidance with inline create action 2026-09-12 17:48:14 +08:00
Levi 75462fe2be fix(ui): make mobile task creation button compact 2026-09-12 17:28:42 +08:00
Levi f35111e072 fix(ui): make mobile task creation button compact 2026-09-12 17:27:44 +08:00
Levi 6aabe69f15 test(ui): align window title expectations with main 2026-09-12 16:56:06 +08:00
Levi 527539f3a1 Merge PR #1506: fix history search text alignment and clipping 2026-09-12 16:55:20 +08:00
Levi 2d239495a0 Merge PR #1505: emphasize task creation and empty-list actions 2026-09-12 16:55:20 +08:00
Levi ddce649c8b Merge latest main into 2.0.0-beta.2 2026-09-12 16:55:20 +08:00
Levi 29675858f2 fix(ui): align history search placeholder and prevent clipping 2026-09-12 10:03:08 +08:00
Levi 1fc0ff6738 feat: emphasize task creation and add empty-list actions 2026-09-12 09:57:02 +08:00
Levi 0851e27c6d Merge PR #1496: fix script settings path picker 2026-09-10 23:10:58 +08:00
Levi 3abc8d42fa Merge PR #1498: fix macOS download notifications 2026-09-10 23:10:53 +08:00
Levi 71d7d96cc0 Merge PR #1499: fix Windows notification icon
# Conflicts:
#	ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart
2026-09-10 23:10:49 +08:00
Levi f254a82d9c refactor(windows): use bundled notification icon path directly 2026-09-10 23:07:14 +08:00
Levi c4ae3330b8 fix(windows): persist PNG icon for download notifications 2026-09-10 23:00:24 +08:00
Levi a45b6455be fix(macos): initialize file picker for non-sandboxed windows 2026-09-10 14:04:43 +08:00
Levi 979988a7f4 fix(macos): request permission for download notifications 2026-09-10 13:03:51 +08:00
Levi 9efe7ff4c9 Merge PR #1488 latest head: create task panel mobile UI 2026-09-10 00:26:07 +08:00
Levi 77734c69bc Merge PR #1494 latest head: unify platform window titles 2026-09-10 00:26:03 +08:00
Levi 22db46c68a Merge PR #1495 latest head: infer extension for opaque URLs 2026-09-10 00:26:00 +08:00
Levi 99a62357ab Merge PR #1496 latest head: reuse shared file path picker 2026-09-10 00:25:56 +08:00
Levi 9558042513 Merge PR #1497 latest head: fix(android) restore notification permission prompt 2026-09-10 00:25:50 +08:00
Levi 39912718c4 fix(android): request notification permission on startup 2026-09-10 00:19:53 +08:00
Levi 3b6b134f24 ci: warn on incomplete localization catalogs 2026-09-10 00:02:31 +08:00
Levi f0aa44455c Merge branch 'main' into codex/fix-settings-script-path-picker 2026-09-09 23:41:17 +08:00
Levi 99ed770017 fix(ui): reuse shared file path picker for script settings 2026-09-09 23:39:44 +08:00
Levi f506b8f92f Merge branch 'main' into fix/create-task-panel-mobile-ui 2026-09-09 23:30:41 +08:00
Levi 9b9fd12884 Merge branch 'main' into fix/create-task-panel-mobile-ui 2026-09-09 23:27:11 +08:00
dajiaohuang cea912b414 fix(http): preserve MIME extension for opaque URLs 2026-09-09 03:45:15 +08:00
Levi 7c3d7269fe fix: unify platform window titles as Gopeed 2026-09-08 23:15:55 +08:00
Levi 1554791e17 merge: PR #1493 refresh repeated Android download intents
# Conflicts:
#	ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart
2026-09-08 22:14:48 +08:00
Levi b0aecdcfc6 merge: latest PR #1488 mobile resolve action layout 2026-09-08 22:14:14 +08:00
Levi d2eb7b2a67 fix(android): refresh download URL when create page is already open 2026-09-08 22:05:45 +08:00
Levi 7112845c82 fix: keep resolve dialog actions equal in width 2026-09-08 21:53:25 +08:00
Levi 39820707be merge: PR #1488 extension toolbar alignment fix 2026-09-08 21:28:33 +08:00
Levi 47db539d91 fix: align extension search with compact toolbar controls 2026-09-08 21:27:52 +08:00
Levi f32f7583e4 fix: align extension search with compact toolbar controls 2026-09-08 21:25:05 +08:00
Levi e3ae2865d6 fix: reconcile Azerbaijani locale with merged create action 2026-09-08 20:26:38 +08:00
Levi 1a8d73fbe6 merge: PR #1489 Android browser takeover handling 2026-09-08 20:24:00 +08:00
Levi 5728caf7db merge: PR #1488 create task mobile UI 2026-09-08 20:23:52 +08:00
Levi 77d95971ca merge: PR #1476 async native API callbacks 2026-09-08 20:23:52 +08:00
Levi 2c81520029 merge: minimal macOS window reopen fix 2026-09-08 20:23:52 +08:00
Levi 8d45eb4fef test: remove native window regression tests 2026-09-08 20:03:57 +08:00
Levi 31fa0e5bc9 fix: restore only main macOS window and select active downloads 2026-09-08 18:14:16 +08:00
Levi 0f60f40e3e Merge branch 'main' into codex/async-ffi-worker-callbacks 2026-09-07 09:23:59 +08:00
Levi b17c0fd7c8 fix(android): preserve browser takeover handling 2026-09-07 09:11:17 +08:00
Levi a834ca0d74 fix(ui): unify text field appearance 2026-09-06 22:26:35 +08:00
Levi ecfd07ccb0 fix(ui): refine create task panel behavior 2026-09-06 22:17:26 +08:00
Levi d51eeb3f2c fix: prevent native requests from blocking shutdown 2026-09-06 21:49:11 +08:00
Levi 1bb3f74c9f Merge branch 'main' into codex/async-ffi-worker-callbacks 2026-09-06 20:37:14 +08:00
Levi f7be97667f Merge remote-tracking branch 'origin/main' into codex/async-ffi-worker-callbacks 2026-09-06 13:00:59 +08:00
Levi 607d1729e6 refactor: share async invoke bridge across native platforms 2026-09-06 11:59:11 +08:00
Levi d12122ea86 feat: make desktop FFI invocation asynchronous 2026-09-06 11:23:27 +08:00
101 changed files with 3774 additions and 421 deletions

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter. Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) - [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel. Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
+1 -1
View File
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。 Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) - [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。 機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
+1 -1
View File
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề. Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) - [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta. Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
+1 -1
View File
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。 Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) - [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。 当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
+1 -1
View File
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。 Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2) - [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。 當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
+4
View File
@@ -10,6 +10,8 @@ type args struct {
url string url string
connections *int connections *int
dir *string dir *string
checksum *string
checksumAlgo *string
} }
func parse() *args { func parse() *args {
@@ -20,6 +22,8 @@ func parse() *args {
var args args var args args
args.connections = flag.Int("C", 16, "Concurrent connections.") args.connections = flag.Int("C", 16, "Concurrent connections.")
args.dir = flag.String("D", dir, "Store directory.") args.dir = flag.String("D", dir, "Store directory.")
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
flag.Parse() flag.Parse()
t := flag.Args() t := flag.Args()
if len(t) > 0 { if len(t) > 0 {
+16 -1
View File
@@ -19,6 +19,18 @@ func main() {
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
var checksumOpt *http.ChecksumOption
if args.checksum != nil && *args.checksum != "" {
algo := "md5"
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
algo = *args.checksumAlgo
}
checksumOpt = &http.ChecksumOption{
Algorithm: algo,
Expected: *args.checksum,
}
}
_, err := download.Boot(). _, err := download.Boot().
URL(args.url). URL(args.url).
Listener(func(event *download.Event) { Listener(func(event *download.Event) {
@@ -44,7 +56,10 @@ func main() {
}). }).
Create(&base.Options{ Create(&base.Options{
Path: *args.dir, Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections}, Extra: http.OptsExtra{
Connections: *args.connections,
Checksum: checksumOpt,
},
}) })
if err != nil { if err != nil {
panic(err) panic(err)
+7 -2
View File
@@ -83,10 +83,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
{Length: 4, Path: []string{"keep.txt"}}, {Length: 4, Path: []string{"keep.txt"}},
{Length: 4, Path: []string{"nested", "skip.txt"}}, {Length: 4, Path: []string{"nested", "skip.txt"}},
}} }}
store := storage.NewFileOpts(storage.NewFileClientOpts{ storageOpts := storage.NewFileClientOpts{
ClientBaseDir: dir, ClientBaseDir: dir,
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir }, TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
}) }
// VerifyDataContext can return before storage finishes marking the piece
// complete. Disable automatic part-file promotion so it cannot rename
// the .part fixtures that this test creates to exercise cleanup.
storageOpts.UsePartFiles.Set(false)
store := storage.NewFileOpts(storageOpts)
defer store.Close() defer store.Close()
config := torrent.TestingConfig(t) config := torrent.TestingConfig(t)
config.DisableTCP = true config.DisableTCP = true
+147 -28
View File
@@ -2,8 +2,13 @@ package http
import ( import (
"context" "context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"hash"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -280,6 +285,12 @@ type Fetcher struct {
primaryReadyOnce sync.Once primaryReadyOnce sync.Once
primaryReadyCh chan struct{} primaryReadyCh chan struct{}
// checksumFailed marks that the last completed download failed
// checksum verification, so the next retry must force a full
// re-download instead of skipping connections that already
// reported Completed.
checksumFailed atomic.Bool
// Start pending mechanism // Start pending mechanism
startPending atomic.Bool startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes resolvedCh chan struct{} // Signal when resolve completes
@@ -417,12 +428,26 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
res.Range = true res.Range = true
} }
// Get content length from Content-Length header // A partial response describes one segment, not the whole resource.
reuseResolveBody := true
if resp.StatusCode == base.HttpCodePartialContent {
start, _, total, err := parseRangeResponse(resp)
if err != nil {
resp.Body.Close()
f.setState(stateError)
return err
}
res.Size = total
res.Range = true
// Prefetch is a contiguous prefix copied at offset zero. A segment
// starting elsewhere must be fetched again through the Range path.
reuseResolveBody = start == 0
} else {
contentLength := resp.Header.Get(base.HttpHeaderContentLength) contentLength := resp.Header.Get(base.HttpHeaderContentLength)
if contentLength != "" { if contentLength != "" {
parse, err := strconv.ParseInt(contentLength, 10, 64) if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
if err == nil { res.Size = size
res.Size = parse }
} }
} }
@@ -467,7 +492,12 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
// IMPORTANT: Keep the response body open for downloading in Start phase // IMPORTANT: Keep the response body open for downloading in Start phase
// This is crucial for one-time URLs that can only be accessed once // This is crucial for one-time URLs that can only be accessed once
f.resolveRespLock.Lock() f.resolveRespLock.Lock()
if reuseResolveBody {
f.resolveResp = resp f.resolveResp = resp
} else {
resp.Body.Close()
f.prefetchDone.Store(true)
}
f.resolveRespLock.Unlock() f.resolveRespLock.Unlock()
f.setState(stateResolved) f.setState(stateResolved)
@@ -479,7 +509,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
// Start async prefetch in background (only for range-supported resources) // Start async prefetch in background (only for range-supported resources)
// For non-range resources, the response will be used directly in Start // For non-range resources, the response will be used directly in Start
if res.Range { if res.Range && reuseResolveBody {
f.prefetchStopCh = make(chan struct{}) f.prefetchStopCh = make(chan struct{})
f.resolveFallback.Store(true) f.resolveFallback.Store(true)
go f.asyncPrefetch() go f.asyncPrefetch()
@@ -716,12 +746,26 @@ func (f *Fetcher) doStart() error {
// If retrying after error, reset connection states for retry // If retrying after error, reset connection states for retry
if state == stateError { if state == stateError {
forceFullReset := f.checksumFailed.Swap(false)
if forceFullReset {
// Checksum mismatch: the file content itself is wrong even
// though every connection reported Completed. Truncate the
// corrupt file so stale bytes cannot linger if the retry is
// interrupted before every connection re-writes its chunk.
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
}
}
f.connMu.Lock() f.connMu.Lock()
for _, conn := range f.connections { for _, conn := range f.connections {
// Reset connections that can be retried // On a normal error, only reset connections that didn't
if !conn.Completed && conn.State != connCompleted { // finish. On a checksum mismatch, reset EVERY connection —
// "Completed" only means the download loop finished, not
// that the bytes were verified correct.
shouldReset := forceFullReset || (!conn.Completed && conn.State != connCompleted)
if shouldReset {
if !f.hasSequentialPrefixLocked(conn) { if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn, forceFullReset)
} }
conn.State = connNotStarted conn.State = connNotStarted
conn.failed = false conn.failed = false
@@ -951,8 +995,14 @@ func (f *Fetcher) expandConnections() {
} }
f.fileMu.Unlock() f.fileMu.Unlock()
var err error
if chkErr := f.verifyChecksum(); chkErr != nil {
err = chkErr
f.setState(stateError)
} else {
f.setState(stateDone) f.setState(stateDone)
f.doneCh <- nil }
f.doneCh <- err
return return
} }
@@ -1088,7 +1138,7 @@ func (f *Fetcher) runConnection(conn *connection) {
// it only when no safe validator exists or If-Range returns a full 200. // it only when no safe validator exists or If-Range returns a full 200.
f.connMu.Lock() f.connMu.Lock()
if !f.hasSequentialPrefixLocked(conn) { if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn, false)
} }
f.connMu.Unlock() f.connMu.Unlock()
} }
@@ -1195,7 +1245,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
resumeProbe := f.canProbeSequentialResumeLocked(conn) resumeProbe := f.canProbeSequentialResumeLocked(conn)
intentionalRestart := sequentialSizeUnknown intentionalRestart := sequentialSizeUnknown
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) { if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn, false)
f.resolveDataPos.Store(0) f.resolveDataPos.Store(0)
f.rangeValidatorPinned = false f.rangeValidatorPinned = false
intentionalRestart = true intentionalRestart = true
@@ -1544,27 +1594,41 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
return true return true
} }
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) { // parseRangeResponse validates a single byte range with a known complete length.
// Unknown totals are rejected: a segment length cannot stand in for resource size.
func parseRangeResponse(resp *http.Response) (int64, int64, int64, error) {
contentRange := resp.Header.Get(base.HttpHeaderContentRange) contentRange := resp.Header.Get(base.HttpHeaderContentRange)
fields := strings.Fields(contentRange) fields := strings.Fields(contentRange)
if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) { if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
} }
rangeAndTotal := strings.Split(fields[1], "/") rangeAndTotal := strings.Split(fields[1], "/")
if len(rangeAndTotal) != 2 { if len(rangeAndTotal) != 2 {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
} }
bounds := strings.Split(rangeAndTotal[0], "-") bounds := strings.Split(rangeAndTotal[0], "-")
if len(bounds) != 2 { if len(bounds) != 2 {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
} }
start, startErr := strconv.ParseInt(bounds[0], 10, 64) start, startErr := strconv.ParseInt(bounds[0], 10, 64)
end, endErr := strconv.ParseInt(bounds[1], 10, 64) end, endErr := strconv.ParseInt(bounds[1], 10, 64)
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64) total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end { if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange) return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
}
expectedLength := end - start + 1
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
return 0, 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
}
return start, end, total, nil
}
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
start, end, total, err := parseRangeResponse(resp)
if err != nil {
return 0, 0, err
} }
if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) { if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) {
return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end) return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end)
@@ -1573,11 +1637,7 @@ func validateRangeResponse(resp *http.Response, requestedStart int64, requestedE
return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize) return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize)
} }
expectedLength := end - start + 1 return end - start + 1, total, nil
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
return 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
}
return expectedLength, total, nil
} }
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error { func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
@@ -1630,7 +1690,7 @@ func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string)
f.rangeReprobeEligible = true f.rangeReprobeEligible = true
f.rangeValidatorPinned = false f.rangeValidatorPinned = false
f.ifRange = ifRange f.ifRange = ifRange
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn, false)
f.resolveDataPos.Store(0) f.resolveDataPos.Store(0)
return nil return nil
} }
@@ -1646,7 +1706,7 @@ func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
f.rangeReprobeEligible = true f.rangeReprobeEligible = true
f.rangeValidatorPinned = false f.rangeValidatorPinned = false
f.ifRange = ifRange f.ifRange = ifRange
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn, false)
f.resolveDataPos.Store(0) f.resolveDataPos.Store(0)
} }
@@ -2029,16 +2089,25 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
return true return true
} }
func (f *Fetcher) resetConnectionForRestart(conn *connection) { func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
if f.meta.Res.Range { if f.meta.Res.Range && !force {
return return
} }
if f.meta.Res.Range && force {
// Checksum mismatch: the on-disk content for this chunk is
// confirmed wrong, so its bytes must be re-fetched from the
// origin server even though range support would normally let
// us just resume from the existing Downloaded position.
if conn.Chunk != nil {
conn.Chunk.Downloaded = 0
}
}
// Without range support a new request always starts from byte 0, // Without range support a new request always starts from byte 0,
// so pause/retry must restart instead of continuing from the old offset. // so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil { if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0) conn.Chunk = newChunk(0, 0)
} else { } else if !f.meta.Res.Range {
conn.Chunk.Begin = 0 conn.Chunk.Begin = 0
conn.Chunk.setEnd(0) conn.Chunk.setEnd(0)
conn.Chunk.Downloaded = 0 conn.Chunk.Downloaded = 0
@@ -2102,7 +2171,7 @@ func (f *Fetcher) resumeConnections() {
} }
} }
if !f.hasSequentialPrefixLocked(conn) { if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn) f.resetConnectionForRestart(conn, false)
} }
// Reset the connection state for resume // Reset the connection state for resume
conn.ctx, conn.cancel = context.WithCancel(f.ctx) conn.ctx, conn.cancel = context.WithCancel(f.ctx)
@@ -2209,11 +2278,16 @@ func (f *Fetcher) onDownloadComplete() {
} }
f.fileMu.Unlock() f.fileMu.Unlock()
if finalErr != nil { if finalErr == nil {
if chkErr := f.verifyChecksum(); chkErr != nil {
finalErr = chkErr
f.setState(stateError) f.setState(stateError)
} else { } else {
f.setState(stateDone) f.setState(stateDone)
} }
} else {
f.setState(stateError)
}
select { select {
case f.doneCh <- finalErr: case f.doneCh <- finalErr:
@@ -2221,6 +2295,51 @@ func (f *Fetcher) onDownloadComplete() {
} }
} }
func (f *Fetcher) verifyChecksum() error {
if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil {
return nil
}
extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra)
if !ok || extra.Checksum == nil {
return nil
}
chk := extra.Checksum
if chk.Algorithm == "" && chk.Expected == "" {
return nil
}
var h hash.Hash
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
case "md5":
h = md5.New()
case "sha1", "sha-1":
h = sha1.New()
case "sha256", "sha-256":
h = sha256.New()
default:
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
}
filePath := f.meta.SingleFilepath()
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file for checksum verification failed: %w", err)
}
defer file.Close()
buf := make([]byte, 64*1024)
if _, err := io.CopyBuffer(h, file, buf); err != nil {
return fmt.Errorf("calculate checksum failed: %w", err)
}
actual := hex.EncodeToString(h.Sum(nil))
expected := strings.ToLower(strings.TrimSpace(chk.Expected))
if !strings.EqualFold(actual, expected) {
f.checksumFailed.Store(true)
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
}
return nil
}
func (f *Fetcher) checkCompletion() bool { func (f *Fetcher) checkCompletion() bool {
// Check if all data has been downloaded // Check if all data has been downloaded
f.connMu.Lock() f.connMu.Lock()
@@ -0,0 +1,122 @@
package http
import (
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data!")
correctContent := []byte("Correct Retried Download Data!")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var reqCount atomic.Int32
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
count := reqCount.Add(1)
var served []byte
if count == 1 {
// First request: serve corrupted content
served = corruptContent
} else {
// Second / subsequent request: serve correct content
served = correctContent
}
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Initial attempt -> will download corruptContent, failing checksum verification
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
// Ensure the reusable resolve response has been fully prefetched so the
// first attempt deterministically verifies the corrupt initial payload.
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for !f.prefetchDone.Load() {
select {
case <-deadline.C:
t.Fatal("timed out waiting for checksum test prefetch")
case <-ticker.C:
}
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// 3. Verify on-disk file content matches the re-downloaded bytes
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
@@ -0,0 +1,307 @@
package http
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_ChecksumVerification(t *testing.T) {
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
md5Hasher := md5.New()
md5Hasher.Write(content)
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
sha256Hasher := sha256.New()
sha256Hasher.Write(content)
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
wrongHash := "0123456789abcdef0123456789abcdef"
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(content)
}))
defer server.Close()
t.Run("Valid MD5", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: correctMd5,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Valid SHA256", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_sha256.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_wrong_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: wrongHash,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("Wait expected checksum mismatch error, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
})
t.Run("Unsupported Algorithm", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_unsupported.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "invalid-algo",
Expected: wrongHash,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("Wait expected error for unsupported algorithm, got nil")
}
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
}
if f.checksumFailed.Load() {
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
}
})
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_case_insensitive.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: strings.ToUpper(correctSha256),
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
}
})
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_empty_expected.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "",
Expected: "",
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected skip and success): %v", err)
}
})
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
largeData := make([]byte, 128*1024)
for i := range largeData {
largeData[i] = byte(i % 256)
}
largeSha256Hasher := sha256.New()
largeSha256Hasher.Write(largeData)
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(largeData)
}))
defer largeServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_large_file.bin",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: largeSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected large file checksum success): %v", err)
}
})
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: test.DownloadName,
Extra: &http.OptsExtra{
Connections: 1,
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
t.Fatalf("Resolve error: %v", err)
}
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error: %v", err)
}
})
}
func TestResetConnectionForRestart_ForceOnRangeDownload(t *testing.T) {
f := buildFetcher()
f.meta = &fetcher.FetcherMeta{
Res: &base.Resource{
Range: true,
},
}
conn := &connection{
Chunk: newChunk(0, 1024),
Downloaded: 512,
Completed: true,
}
conn.Chunk.Downloaded = 512
// Non-forced reset on range-supported download does not reset Chunk.Downloaded
f.resetConnectionForRestart(conn, false)
if conn.Chunk.Downloaded != 512 {
t.Fatalf("expected Chunk.Downloaded to remain 512 when force is false, got %d", conn.Chunk.Downloaded)
}
// Forced reset on range-supported download resets Chunk.Downloaded to 0
f.resetConnectionForRestart(conn, true)
if conn.Chunk.Downloaded != 0 {
t.Fatalf("expected Chunk.Downloaded to be reset to 0 when force is true, got %d", conn.Chunk.Downloaded)
}
if conn.Completed {
t.Fatal("expected Completed to be false after reset")
}
}
+13
View File
@@ -13,6 +13,7 @@ import (
"net/url" "net/url"
"os" "os"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024) resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
replacement := bytes.Repeat([]byte("replacement"), 24*1024) replacement := bytes.Repeat([]byte("replacement"), 24*1024)
var rangeRequests atomic.Int32 var rangeRequests atomic.Int32
rangeStarted := make(chan struct{})
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes) w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
if r.Header.Get(base.HttpHeaderRange) == "" { if r.Header.Get(base.HttpHeaderRange) == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload))) w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
w.WriteHeader(gohttp.StatusOK) w.WriteHeader(gohttp.StatusOK)
w.(gohttp.Flusher).Flush()
// Keep Resolve prefetch incomplete until the Range request starts.
// Otherwise Start may reuse the completed body and skip this scenario.
select {
case <-rangeStarted:
case <-r.Context().Done():
return
}
_, _ = w.Write(resolvePayload) _, _ = w.Write(resolvePayload)
return return
} }
rangeRequests.Add(1) rangeRequests.Add(1)
releaseResolve()
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement))) w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
w.WriteHeader(gohttp.StatusOK) w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(replacement) _, _ = w.Write(replacement)
})) }))
defer server.Close() defer server.Close()
defer releaseResolve()
f := buildFetcher() f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{ if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
@@ -0,0 +1,119 @@
package http
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestResolveResponseSize(t *testing.T) {
for _, tc := range []struct {
name string
status int
length, contentRange string
size int64
invalid bool
}{
{"full", 200, "801571015", "", 801571015, false},
{"partial_prefix", 206, "472321339", "bytes 0-472321338/801571015", 801571015, false},
{"partial_nonzero", 206, "100000", "bytes 100000-199999/801571015", 801571015, false},
{"second_report", 206, "335134409", "bytes 0-335134408/552140234", 552140234, false},
{"length_mismatch", 206, "10", "bytes 0-19/100", 0, true},
{"unknown_total", 206, "472321339", "bytes 0-472321338/*", 0, true},
{"missing_range", 206, "10", "", 0, true},
{"malformed_range", 206, "10", "bytes invalid", 0, true},
{"invalid_bounds", 206, "10", "bytes 20-10/100", 0, true},
{"invalid_total", 206, "10", "bytes 0-9/9", 0, true},
{"overflow", 206, "10", "bytes 0-9/9223372036854775808", 0, true},
{"chunked_partial", 206, "", "bytes 0-9/100", 100, false},
{"unknown_full", 200, "", "", 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.length != "" {
w.Header().Set("Content-Length", tc.length)
}
if tc.contentRange != "" {
w.Header().Set("Content-Range", tc.contentRange)
}
w.WriteHeader(tc.status)
w.(http.Flusher).Flush()
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir()})
if tc.invalid {
if !errors.Is(err, errInvalidRangeResponse) {
t.Fatalf("Resolve error = %v, want invalid range", err)
}
if f.meta.Res != nil || f.resolveResp != nil || f.getState() != stateError {
t.Fatal("invalid response was retained or published")
}
return
}
if err != nil {
t.Fatal(err)
}
if f.meta.Res.Size != tc.size || f.meta.Res.Files[0].Size != tc.size {
t.Fatalf("resource/file size = %d/%d, want %d", f.meta.Res.Size, f.meta.Res.Files[0].Size, tc.size)
}
if tc.status == 206 && !f.meta.Res.Range {
t.Fatal("partial response must enable Range downloads")
}
})
}
}
func TestDownloadPartialResolve(t *testing.T) {
payload := make([]byte, 256*1024)
for i := range payload {
payload[i] = byte(i*31 + i/251)
}
for _, start := range []int{0, 100000} {
for _, connections := range []int{1, 4} {
t.Run(fmt.Sprintf("start_%d_connections_%d", start, connections), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
begin, end := start, start+9999
if h := r.Header.Get("Range"); h != "" {
if _, err := fmt.Sscanf(h, "bytes=%d-%d", &begin, &end); err != nil {
t.Errorf("invalid request range %q: %v", h, err)
w.WriteHeader(400)
return
}
}
w.Header().Set("Content-Length", fmt.Sprint(end-begin+1))
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end, len(payload)))
w.WriteHeader(206)
w.Write(payload[begin : end+1])
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
if err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(f.meta.SingleFilepath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("download content mismatch: got %d bytes, want %d", len(got), len(payload))
}
})
}
}
}
-18
View File
@@ -25,24 +25,6 @@ type Request struct {
SkipVerifyCert bool `json:"skipVerifyCert"` SkipVerifyCert bool `json:"skipVerifyCert"`
} }
// SetLabels replaces all labels on the request.
func (r *Request) SetLabels(labels map[string]string) {
r.Labels = labels
}
// PutLabel sets a label on the request.
func (r *Request) PutLabel(key, value string) {
if r.Labels == nil {
r.Labels = make(map[string]string)
}
r.Labels[key] = value
}
// DelLabel deletes a label from the request.
func (r *Request) DelLabel(key string) {
delete(r.Labels, key)
}
func (r *Request) Validate() error { func (r *Request) Validate() error {
if r.URL == "" { if r.URL == "" {
return fmt.Errorf("invalid request url") return fmt.Errorf("invalid request url")
+1
View File
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
} }
if handled { if handled {
d.emit(EventKeyPause, task) d.emit(EventKeyPause, task)
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
} }
return nil return nil
} }
+26 -6
View File
@@ -45,6 +45,32 @@ type generationTestManager struct {
func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) { func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
taskType := reflect.TypeOf((*Task)(nil)) taskType := reflect.TypeOf((*Task)(nil))
for _, typ := range []reflect.Type{taskType, reflect.TypeOf((*ExtensionTask)(nil)), reflect.TypeOf((*OnErrorExtensionTask)(nil))} {
for _, name := range []string{"SetUrl", "SetExtra", "PatchExtra", "PatchHeaders", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
if _, ok := typ.MethodByName(name); ok {
t.Fatalf("%v unexpectedly exposes %s", typ, name)
}
}
}
requestType := reflect.TypeOf((*ExtensionTaskRequest)(nil))
for _, name := range []string{"SetUrl", "SetLabels", "PutLabel", "DelLabel", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
if _, ok := requestType.MethodByName(name); !ok {
t.Fatalf("request wrapper does not expose %s", name)
}
}
for _, typ := range []reflect.Type{reflect.TypeOf(ExtensionTask{}), reflect.TypeOf(OnErrorExtensionTask{})} {
if _, ok := typ.FieldByName("Req"); ok {
t.Fatalf("%v exposes the removed task.req entry", typ)
}
field, ok := typ.FieldByName("Meta")
if !ok || field.Type != reflect.TypeOf((*ExtensionTaskMeta)(nil)) {
t.Fatalf("%v has no metadata wrapper", typ)
}
}
field, ok := reflect.TypeOf(ExtensionTaskMeta{}).FieldByName("Req")
if !ok || field.Type != requestType {
t.Fatal("meta.req has no request wrapper")
}
if _, ok := taskType.MethodByName("Continue"); ok { if _, ok := taskType.MethodByName("Continue"); ok {
t.Fatal("regular task unexpectedly exposes Continue") t.Fatal("regular task unexpectedly exposes Continue")
} }
@@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
} }
extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil)) extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil))
if _, ok := extensionTaskType.MethodByName("SetUrl"); !ok {
t.Fatal("extension task does not expose SetUrl")
}
if _, ok := extensionTaskType.MethodByName("Continue"); ok { if _, ok := extensionTaskType.MethodByName("Continue"); ok {
t.Fatal("non-error extension task unexpectedly exposes Continue") t.Fatal("non-error extension task unexpectedly exposes Continue")
} }
errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil)) errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil))
if _, ok := errorTaskType.MethodByName("SetUrl"); !ok {
t.Fatal("onError task does not expose SetUrl")
}
if _, ok := errorTaskType.MethodByName("Continue"); !ok { if _, ok := errorTaskType.MethodByName("Continue"); !ok {
t.Fatal("onError task does not expose Continue") t.Fatal("onError task does not expose Continue")
} }
@@ -0,0 +1,222 @@
package engine_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/download/engine"
)
// Exercise server-side header permissions, including formerly forbidden names,
// download authentication, metadata and non-safelisted no-cors values.
var extensionRequestHeaders = [][2]string{
{"Accept-Charset", "utf-8"}, {"Accept-Encoding", "identity"},
{"Access-Control-Request-Headers", "authorization, x-download"},
{"Access-Control-Request-Method", "POST"}, {"Connection", "close"},
{"Connection", "keep-alive"},
{"Content-Length", "4"}, {"Cookie", "sid=manual"}, {"Cookie2", "legacy=1"},
{"Date", "Fri, 09 Oct 2026 00:00:00 GMT"}, {"DNT", "1"},
{"Host", "download.example:1234"}, {"Origin", "https://download.example"},
{"Permissions-Policy", "camera=()"}, {"Referer", "https://download.example/file"},
{"TE", "trailers"}, {"Trailer", "X-Checksum"}, {"Set-Cookie", "sid=request"},
{"Set-Cookie2", "legacy=request"}, {"Via", "1.1 proxy"},
{"Proxy-Authorization", "Basic dGVzdA=="}, {"Proxy-", "custom"},
{"Sec-Fetch-Site", "same-origin"}, {"Sec-Fetch-Mode", "custom"},
{"Sec-CH-UA", `"Download client"`}, {"Sec-", "custom"},
{"X-HTTP-Method", "TRACE"}, {"X-HTTP-Method-Override", "CONNECT"},
{"X-Method-Override", "TRACK"}, {"Authorization", "Bearer token"},
{"User-Agent", "Gopeed extension"}, {"Content-Type", "application/json"},
{"Range", "bytes=0-1,3-4"}, {"X-Download-Token", "custom"},
{"Accept-Language", strings.Repeat("x", 129)},
}
func TestExtensionRequestsSendNodeHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := request.URL.Query().Get("header")
value := request.Header.Get(name)
if name == "Host" {
value = request.Host
}
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
clients := []struct{ name, script string }{
{"fetch record", `const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); return (await response.text()).trim();`},
{"fetch no-cors Headers", `const response = await fetch(url, {method: "POST", body: "test", mode: "no-cors", headers: new Headers([[name.toLowerCase(), value]])}); return (await response.text()).trim();`},
{"fetch Request clone", `const request = new Request(url, {method: "POST", body: "test", mode: "same-origin", headers: [[name.toUpperCase(), value]]}); const response = await fetch(request.clone()); return (await response.text()).trim();`},
}
for _, credentials := range []bool{false, true} {
clients = append(clients, struct{ name, script string }{
fmt.Sprintf("XHR credentials=%t", credentials),
fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = %t;
xhr.setRequestHeader(name.toLowerCase(), value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});`, credentials),
})
}
for _, header := range extensionRequestHeaders {
for _, client := range clients {
t.Run(header[0]+"/"+client.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
%s
})()`, header[0], header[1], server.URL, client.script))
if err != nil {
t.Fatal(err)
}
want, _ := json.Marshal([]string{header[1], "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionHeaderMutationsMatchNode(t *testing.T) {
headers := append([][2]string(nil), extensionRequestHeaders...)
// Node stores these in Headers/Request, but rejects them during fetch.
headers = append(headers, [2]string{"Expect", "100-continue"}, [2]string{"Keep-Alive", "timeout=5"}, [2]string{"Transfer-Encoding", "chunked"}, [2]string{"Upgrade", "websocket"})
data, _ := json.Marshal(headers)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(fmt.Sprintf(`(() => {
for (const mode of ["cors", "same-origin", "no-cors"]) {
for (const [name, value] of %s) {
const request = new Request("http://example.com", {mode, headers: {[name]: value}});
const headers = request.headers;
if (headers.get(name) !== value) throw new Error("Filtered init " + name + " in " + mode);
headers.delete(name.toUpperCase());
if (headers.has(name)) throw new Error("Cannot delete " + name);
headers.append(name, value);
if (headers.get(name) !== value) throw new Error("Cannot append " + name);
headers.set(name, value);
if (request.clone().headers.get(name) !== value) throw new Error("Cannot clone " + name);
}
}
return true;
})()`, data))
if err != nil {
t.Fatal(err)
}
}
func TestExtensionRequestsIgnoreUnsupportedTransportHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
value := request.Header.Get(request.URL.Query().Get("header"))
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
for _, header := range [][2]string{
{"Expect", "100-continue"}, {"Keep-Alive", "timeout=5"},
{"Transfer-Encoding", "chunked"}, {"Upgrade", "websocket"},
{"Connection", "upgrade"}, {"Content-Length", "3"},
{"Content-Length", "5"}, {"Content-Length", "invalid"},
{"Content-Length", "-1"}, {"Content-Length", ""},
} {
for _, xhr := range []bool{false, true} {
t.Run(fmt.Sprintf("%s=%s/XHR=%t", header[0], header[1], xhr), func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
if (%t) return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader(name, value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});
const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}});
return (await response.text()).trim();
})()`, header[0], header[1], server.URL, xhr))
if err != nil {
t.Fatal(err)
}
wireValue := ""
if header[0] == "Content-Length" {
wireValue = "4"
}
want, _ := json.Marshal([]string{wireValue, "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionResponseExposesSetCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Set-Cookie", "sid=one; HttpOnly")
w.Header().Add("Set-Cookie", "token=two")
w.Header().Set("Set-Cookie2", "legacy=three")
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const response = await fetch(%q);
const clone = response.clone();
await response.text(); await clone.text();
let immutable = false;
try { response.headers.set("Set-Cookie", "changed=1"); } catch (error) { immutable = error instanceof TypeError; }
const synthetic = new Response(null, {headers: {"Set-Cookie": "initial=1"}});
synthetic.headers.append("Set-Cookie", "appended=2");
return JSON.stringify({cookies: clone.headers.getSetCookie(), legacy: response.headers.get("set-cookie2"), immutable, synthetic: synthetic.headers.getSetCookie()});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"cookies":["sid=one; HttpOnly","token=two"],"legacy":"legacy=three","immutable":true,"synthetic":["initial=1","appended=2"]}`
if value != want {
t.Fatalf("unexpected response headers: got %#v want %s", value, want)
}
}
func TestExtensionHeadersRejectInvalidSyntax(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(`(() => {
for (const [name, value] of [["Bad Header", "ok"], ["", "ok"], ["X:Header", "ok"],
["X-Test", "a\nb"], ["X-Test", "a\rb"], ["X-Test", "\0"], ["X-Test", "\u0100"]]) {
for (const create of [() => new Headers([[name, value]]),
() => new Request("http://example.com", {mode: "no-cors", headers: {[name]: value}}),
() => { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://example.com"); xhr.setRequestHeader(name, value); }]) {
let rejected = false;
try { create(); } catch (error) { rejected = error instanceof TypeError; }
if (!rejected) throw new Error("Accepted invalid HTTP header syntax");
}
}
return true;
})()`)
if err != nil {
t.Fatal(err)
}
}
@@ -105,6 +105,105 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) {
} }
} }
func TestExtensionRequestsSendExplicitCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
type requestTest struct {
name string
script string
}
tests := []requestTest{
{"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`},
{"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`},
{"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`},
{"fetch repeated Cookie", `const headers = new Headers([["Cookie", "sid=manual"], ["cookie", "token=provided"]]); if (headers.get("cookie") !== cookie) throw new Error("Incorrect Cookie separator"); const response = await fetch(url, {headers}); return response.text();`},
{"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`},
{"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`},
}
for _, mode := range []string{"cors", "same-origin", "no-cors"} {
for _, credentials := range []string{"omit", "same-origin", "include"} {
tests = append(tests, requestTest{
name: "fetch " + mode + " " + credentials,
script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials),
})
}
}
for _, withCredentials := range []bool{false, true} {
tests = append(tests, requestTest{
name: fmt.Sprintf("XHR withCredentials=%t", withCredentials),
script: fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.withCredentials = %t;
xhr.setRequestHeader("cOoKiE", cookie);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`, withCredentials),
})
}
tests = append(tests, requestTest{"XHR repeated Cookie", `return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.setRequestHeader("Cookie", "sid=manual");
xhr.setRequestHeader("cookie", "token=provided");
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`})
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
const cookie = "sid=manual; token=provided"
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
const cookie = %q;
%s
})()`, server.URL, cookie, test.script))
if err != nil {
t.Fatal(err)
}
if value != cookie {
t.Fatalf("server received Cookie %q, want %q", value, cookie)
}
})
}
}
func TestFetchExplicitCookieWithCookieJar(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/seed" {
http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"})
}
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
await (await fetch(url + "/seed")).text();
const automatic = await (await fetch(url)).text();
const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text();
const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text();
const empty = await (await fetch(url, {credentials: "omit"})).text();
return JSON.stringify({automatic, combined, omitted, empty});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}`
if value != want {
t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want)
}
}
func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) { func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost { if request.Method == http.MethodPost {
+11 -56
View File
@@ -122,7 +122,7 @@
if (name === "set-cookie") { if (name === "set-cookie") {
for (const value of values) result.push([name, value]); for (const value of values) result.push([name, value]);
} else { } else {
result.push([name, values.join(", ")]); result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
} }
} }
return result; return result;
@@ -158,49 +158,10 @@
return iterator; return iterator;
} }
function forbiddenRequestHeader(name, value) { function assertMutableHeaders(headers) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true; // Match Node/Undici: HTTP syntax and immutability still apply, but browser
if ([ // forbidden-header and no-cors safelist rules do not apply to extensions.
"accept-charset", "accept-encoding", "access-control-request-headers",
"access-control-request-method", "connection", "content-length", "cookie",
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
"set-cookie", "upgrade", "via",
].includes(name)) return true;
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
return String(value).split(",").some((method) => {
method = method.trim().toUpperCase();
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
});
}
return false;
}
function noCORSSafelistedRequestHeader(name, value) {
if (value.length > 128) return false;
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
if (name === "accept") return !hasUnsafeByte;
if (name === "accept-language" || name === "content-language") {
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
}
if (name === "content-type") {
if (hasUnsafeByte) return false;
const essence = value.split(";", 1)[0].trim().toLowerCase();
return essence === "application/x-www-form-urlencoded" ||
essence === "multipart/form-data" || essence === "text/plain";
}
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
return false;
}
function blockedByGuard(headers, name, value) {
if (headers._guard === "immutable") throw new TypeError("Headers are immutable"); if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
if (headers._guard === "request-no-cors") {
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
}
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
return false;
} }
class Headers { class Headers {
@@ -235,27 +196,21 @@
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
value = normalizeHeaderValue(value); value = normalizeHeaderValue(value);
const values = this._values.get(name); const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value; assertMutableHeaders(this);
if (blockedByGuard(this, name, combinedValue)) return;
if (values) values.push(value); if (values) values.push(value);
else this._values.set(name, [value]); else this._values.set(name, [value]);
} }
delete(name) { delete(name) {
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable"); assertMutableHeaders(this);
if (this._guard === "request-no-cors") {
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
this._values.delete(name);
return;
}
if (blockedByGuard(this, name, "")) return;
this._values.delete(name); this._values.delete(name);
} }
get(name) { get(name) {
const values = this._values.get(normalizeHeaderName(name)); name = normalizeHeaderName(name);
return values ? values.join(", ") : null; const values = this._values.get(name);
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
} }
getSetCookie() { getSetCookie() {
@@ -270,7 +225,7 @@
set(name, value) { set(name, value) {
name = normalizeHeaderName(name); name = normalizeHeaderName(name);
value = normalizeHeaderValue(value); value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return; assertMutableHeaders(this);
this._values.set(name, [value]); this._values.set(name, [value]);
} }
@@ -484,7 +439,7 @@
} }
this._duplex = "half"; this._duplex = "half";
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined); const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request"); this._headers = new Headers(headerInit, "request");
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null); let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body); if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream; const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
@@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u
return nil, err return nil, err
} }
for _, header := range metadata.Headers { for _, header := range metadata.Headers {
// Ignore transport controls unsupported by Node/Undici rather than
// applying a browser forbidden-header list or failing the whole request.
switch strings.ToLower(header[0]) {
case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length":
// Content-Length is generated from the buffered request body below.
continue
case "connection":
if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" {
continue
}
case "trailer":
// Fetch supplies a declaration, not a Go Request.Trailer payload.
// Keep its lower-case wire name so Go's writer does not omit it as
// the canonical Trailer field it normally generates itself.
httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1])
continue
}
httpRequest.Header.Add(header[0], header[1]) httpRequest.Header.Add(header[0], header[1])
} }
if host := httpRequest.Header.Get("Host"); host != "" { if host := httpRequest.Header.Get("Host"); host != "" {
+26 -2
View File
@@ -9,7 +9,7 @@ The files under `fetch/` are copied from the official
Gopeed executes applicable worker-style Fetch API tests at the extension Gopeed executes applicable worker-style Fetch API tests at the extension
JavaScript boundary. The checked-in runner currently executes unchanged WPT JavaScript boundary. The checked-in runner currently executes unchanged WPT
tests for Headers, no-CORS header guards, Request construction and body tests for Headers syntax and iteration, Request construction and body
consumption, ReadableStream bodies, keepalive, Request errors, Response consumption, ReadableStream bodies, keepalive, Request errors, Response
construction and body consumption, immutable network response headers, and construction and body consumption, immutable network response headers, and
the Response static constructors. the Response static constructors.
@@ -19,10 +19,34 @@ a claim that Gopeed is a browser. Browser-only suites that require a document,
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers, CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
browser HTTP cache, navigation, browser authentication UI, or WPT's browser HTTP cache, navigation, browser authentication UI, or WPT's
multi-origin server infrastructure are intentionally excluded. Gopeed also multi-origin server infrastructure are intentionally excluded. Gopeed also
keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP
redirect response so download extensions can inspect `Location`, instead of redirect response so download extensions can inspect `Location`, instead of
returning a browser `opaqueredirect` response. returning a browser `opaqueredirect` response.
Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not
apply browser forbidden request/response header lists or no-CORS safelists.
Extensions may supply authentication, referrer, origin, host, proxy and fetch
metadata headers, and read `Set-Cookie` response headers. Network response
headers remain immutable; invalid header syntax still raises an error.
Repeated `Cookie` values are combined with `; `, as in Node/Undici.
The runner excludes the browser-only `headers-no-cors.any.js` and
`headers-forbidden-override.any.js` suites, the filtering assertions within
`request-headers.any.js`, and the forbidden-response-cookie assertion within
`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those
restrictions with integration tests that check headers actually received by
an HTTP server through fetch and XMLHttpRequest, plus header mutations,
cloning and response cookies. The upstream test files remain unchanged.
Gopeed deliberately ignores transport controls that Node/Undici rejects:
`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values
other than `close` or `keep-alive`. `Content-Length` is generated from the
actual request body, ignoring a supplied length. Explicit `Host` and
`Sec-Fetch-Mode` values are preserved rather than replaced by defaults.
These transport behaviors are also covered by integration tests.
`credentials: "omit"` disables automatic CookieJar cookies but preserves
explicit headers; Gopeed retains its extension CookieJar support.
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied `wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
normalize a final newline. normalize a final newline.
+25 -17
View File
@@ -20,9 +20,6 @@ var wptFetchUtils string
//go:embed testdata/wpt/fetch/api/request/request-error.js //go:embed testdata/wpt/fetch/api/request/request-error.js
var wptRequestError string var wptRequestError string
//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json
var wptNoCORSSafelistedHeaders []byte
//go:embed testdata/wpt/fetch/api/headers/*.any.js //go:embed testdata/wpt/fetch/api/headers/*.any.js
var wptHeaders embed.FS var wptHeaders embed.FS
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
var wptRequestResponse embed.FS var wptRequestResponse embed.FS
func TestWPTHeaders(t *testing.T) { func TestWPTHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(wptNoCORSSafelistedHeaders)
}))
t.Cleanup(server.Close)
tests := []string{ tests := []string{
"header-setcookie.any.js", "header-setcookie.any.js",
"headers-basic.any.js", "headers-basic.any.js",
"headers-casing.any.js", "headers-casing.any.js",
"headers-combine.any.js", "headers-combine.any.js",
"headers-errors.any.js", "headers-errors.any.js",
"headers-forbidden-override.any.js",
"headers-normalize.any.js", "headers-normalize.any.js",
"headers-no-cors.any.js",
"headers-record.any.js", "headers-record.any.js",
"headers-structure.any.js", "headers-structure.any.js",
} }
for _, name := range tests { for _, name := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
setup := "" runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
if name == "headers-no-cors.any.js" {
setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js")
}
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup)
}) })
} }
} }
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
} }
runtime := engine.NewEngine(nil) runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close) t.Cleanup(runtime.Close)
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();") adaptation := ""
if name == "testdata/wpt/fetch/api/request/request-headers.any.js" {
// Node-style header permissions are covered by extension integration tests.
// Keep upstream tests unchanged and exclude browser-only restrictions.
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name.startsWith('Adding invalid request header ') ||
name.startsWith('Adding invalid no-cors request header ') ||
name.startsWith('Check that request constructor is filtering headers ') ||
name.startsWith('Check that no-cors request constructor is filtering headers ')) return;
upstreamTest(callback, name);
};
`
} else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" {
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name === 'Set-Cookie is a forbidden response header') return;
upstreamTest(callback, name);
};
`
}
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+17 -6
View File
@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download/engine" "github.com/GopeedLab/gopeed/pkg/download/engine"
@@ -733,6 +734,14 @@ type OnDoneContext struct {
// only some fields can be modified, such as request info. // only some fields can be modified, such as request info.
type ExtensionTask struct { type ExtensionTask struct {
*Task *Task
Meta *ExtensionTaskMeta `json:"meta"`
}
// ExtensionTaskMeta preserves metadata fields while exposing request operations
// at the existing meta.req path.
type ExtensionTaskMeta struct {
*fetcher.FetcherMeta
Req *ExtensionTaskRequest `json:"req"`
} }
// OnErrorExtensionTask adds error-recovery controls to ExtensionTask. // OnErrorExtensionTask adds error-recovery controls to ExtensionTask.
@@ -750,7 +759,14 @@ func cloneExtensionTask(task *Task) *Task {
} }
func newExtensionTask(task *Task) *ExtensionTask { func newExtensionTask(task *Task) *ExtensionTask {
return &ExtensionTask{Task: cloneExtensionTask(task)} view := cloneExtensionTask(task)
return &ExtensionTask{
Task: view,
Meta: &ExtensionTaskMeta{
FetcherMeta: view.Meta,
Req: &ExtensionTaskRequest{Request: view.Meta.Req, task: view},
},
}
} }
func newOnStartExtensionTask(task *Task) *ExtensionTask { func newOnStartExtensionTask(task *Task) *ExtensionTask {
@@ -768,11 +784,6 @@ func newOnDoneExtensionTask(task *Task) *Task {
return cloneExtensionTask(task) return cloneExtensionTask(task)
} }
// SetUrl replaces the task request URL.
func (t *ExtensionTask) SetUrl(url string) {
t.Meta.Req.URL = url
}
func (t *OnErrorExtensionTask) Continue() error { func (t *OnErrorExtensionTask) Continue() error {
return t.download.Continue(&TaskFilter{ return t.download.Continue(&TaskFilter{
IDs: []string{t.ID}, IDs: []string{t.ID},
@@ -0,0 +1,188 @@
package download
import (
"fmt"
"reflect"
"testing"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/dop251/goja"
)
func TestExtensionProtocolMethods(t *testing.T) {
for _, extra := range []any{nil, map[string]any{"header": map[string]any{"Keep": "yes"}}, &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{Extra: extra}
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
t.Fatal(err)
}
_, err := vm.RunString(`task.meta.req.setMethod('POST'); task.meta.req.setBody('payload'); task.meta.req.putHeader('Authorization','Bearer token'); task.meta.req.putHeader('Remove','remove'); task.meta.req.delHeader('REMOVE');`)
if err != nil {
t.Fatal(err)
}
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
t.Fatal(err)
}
got := req.Extra.(*httpProtocol.ReqExtra)
if got.Method != "POST" || got.Body != "payload" || got.Header["Authorization"] != "Bearer token" {
t.Fatalf("mutation not applied: %#v", got)
}
if _, ok := got.Header["Remove"]; ok {
t.Fatal("header deletion failed")
}
if extra != nil && got.Header["Keep"] != "yes" {
t.Fatal("existing header lost")
}
}
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{Extra: &btProtocol.ReqExtra{Trackers: []string{"old"}}}
task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
t.Fatal(err)
}
if _, err := vm.RunString(`task.meta.req.setTrackers(['new','another']);`); err != nil {
t.Fatal(err)
}
got := req.Extra.(*btProtocol.ReqExtra).Trackers
if len(got) != 2 || got[0] != "new" || got[1] != "another" {
t.Fatalf("trackers = %#v", got)
}
}
func TestExtensionProtocolMethodsRejectInvalidMutation(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
original := &httpProtocol.ReqExtra{Method: "POST", Header: map[string]string{"authorization": "old", "Keep": "yes"}}
req := &base.Request{Extra: original}
task := newOnStartExtensionTask(&Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}})
if err := vm.Set("task", task); err != nil {
t.Fatal(err)
}
for _, script := range []string{`task.meta.req.setHeaders({Keep:null})`, `task.meta.req.setHeaders({Keep:123})`, `task.meta.req.setHeaders({a:'1',A:'2'})`} {
if _, err := vm.RunString(script); err == nil {
t.Fatalf("accepted invalid mutation: %s", script)
}
if req.Extra != original || original.Header["Keep"] != "yes" {
t.Fatal("invalid operation changed original extra")
}
}
if _, err := vm.RunString(`task.meta.req.putHeader('Authorization','new');`); err != nil {
t.Fatal(err)
}
got := req.Extra.(*httpProtocol.ReqExtra)
if len(got.Header) != 2 || got.Header["Authorization"] != "new" || original.Header["authorization"] != "old" {
t.Fatalf("header update = %#v", got.Header)
}
if _, err := vm.RunString(`task.meta.req.setHeaders({Only:'one'});`); err != nil {
t.Fatal(err)
}
got = req.Extra.(*httpProtocol.ReqExtra)
if got.Method != "POST" || len(got.Header) != 1 {
t.Fatalf("setHeaders = %#v", got)
}
req.Extra = map[string]any{"header": 123}
if _, err := vm.RunString(`task.meta.req.setBody('new')`); err == nil {
t.Fatal("invalid protocol extra accepted")
}
if req.Extra.(map[string]any)["header"] != 123 {
t.Fatal("conversion failure mutated extra")
}
}
func TestExtensionProtocolMethodsOtherProtocolsAreNoOp(t *testing.T) {
for _, protocol := range []string{"http", "bt", "hls"} {
for _, onError := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/onError=%v", protocol, onError), func(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
// An invalid extra proves that unrelated methods skip conversion entirely.
original := map[string]any{"unchanged": true, "header": 123}
req := &base.Request{Extra: original}
task := &Task{Protocol: protocol, Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
var wrapper any = newOnStartExtensionTask(task)
if onError {
wrapper = newOnErrorExtensionTask(nil, task)
}
if err := vm.Set("task", wrapper); err != nil {
t.Fatal(err)
}
if protocol != "http" {
if _, err := vm.RunString(`task.meta.req.setMethod('POST');task.meta.req.setBody('body');task.meta.req.setHeaders({a:null});task.meta.req.putHeader('A','value');task.meta.req.delHeader('A');`); err != nil {
t.Fatal(err)
}
}
if protocol != "bt" {
if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil {
t.Fatal(err)
}
}
if !reflect.DeepEqual(req.Extra, original) {
t.Fatalf("extra changed: %#v", req.Extra)
}
original["same-object"] = true
if req.Extra.(map[string]any)["same-object"] != true {
t.Fatal("no-op replaced extra")
}
})
}
}
}
func TestExtensionOnErrorRequestWritesToOriginalTask(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{URL: "https://example.com", Extra: &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}}
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnErrorExtensionTask(nil, task)); err != nil {
t.Fatal(err)
}
if _, err := vm.RunString(`
if (typeof task.continue !== 'function') throw new Error('missing continue');
if (typeof task.putHeader !== 'undefined') throw new Error('task still exposes protocol methods');
if (typeof task.req !== 'undefined') throw new Error('legacy task.req');
const request = task.meta.req;
if (request.url !== 'https://example.com' || request.extra.header.Keep !== 'yes') throw new Error('request data missing');
if (!task.meta.opts) throw new Error('metadata fields missing');
request.setHeaders({Keep:'new'});
request.putHeader('Authorization','Bearer new');
request.setMethod('POST');
if (typeof task.setUrl !== 'undefined' || typeof task.extra !== 'undefined') throw new Error('legacy task API');
request.setUrl('https://example.com/new');
request.setLabels({keep:'yes',remove:'yes'});
request.putLabel('new','value');
request.delLabel('remove');
if (request.url !== 'https://example.com/new' || request.labels.new !== 'value' || request.extra.method !== 'POST' || request.extra.header.Authorization !== 'Bearer new') throw new Error('request data did not refresh');
`); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(req.Labels, map[string]string{"keep": "yes", "new": "value"}) {
t.Fatalf("labels = %#v", req.Labels)
}
got := req.Extra.(*httpProtocol.ReqExtra)
if got.Method != "POST" || got.Header["Keep"] != "new" || got.Header["Authorization"] != "Bearer new" || req.URL != "https://example.com/new" {
t.Fatalf("original request not updated: %#v, %#v", req, got)
}
}
func TestExtensionBTRequestInitializesNilExtra(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{}
task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
t.Fatal(err)
}
if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil {
t.Fatal(err)
}
got, ok := req.Extra.(*btProtocol.ReqExtra)
if !ok || !reflect.DeepEqual(got.Trackers, []string{"udp://example.com"}) {
t.Fatalf("extra = %#v", req.Extra)
}
}
+104
View File
@@ -0,0 +1,104 @@
package download
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestExtensionExtraMutationHTTP(t *testing.T) {
for _, resolved := range []bool{false, true} {
for _, invalid := range []bool{false, true} {
t.Run(fmt.Sprintf("resolved=%v/invalid=%v", resolved, invalid), func(t *testing.T) {
var updatedRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-New") == "new" {
updatedRequests.Add(1)
if r.Header.Get("X-Old") != "" {
t.Error("extra mutation retained the old header")
}
}
http.ServeContent(w, r, "test.txt", time.Time{}, strings.NewReader("extra mutation payload"))
}))
defer server.Close()
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
extDir := t.TempDir()
manifest := `{"name":"http-methods","title":"HTTP methods test","version":"0.0.1","scripts":[{"event":"onStart","match":{"labels":["test"]},"entry":"index.js"}]}`
mutation := `await ctx.task.meta.req.setMethod('GET');
await ctx.task.meta.req.setBody('');
await ctx.task.meta.req.delHeader('x-old');
await ctx.task.meta.req.putHeader('X-New', 'new');`
if invalid {
mutation = `try { await ctx.task.meta.req.setHeaders({'X-New':123}); }
catch (err) { await ctx.task.meta.req.putLabel('rejected','true'); } ` + mutation
}
script := `gopeed.events.onStart(async ctx => { ` + mutation + ` });`
for name, content := range map[string]string{"manifest.json": manifest, "index.js": script} {
if err := os.WriteFile(filepath.Join(extDir, name), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
if _, err := d.InstallExtensionByFolder(extDir, false); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
d.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
done <- event.Err
}
})
req := &base.Request{URL: server.URL + "/test.txt", Labels: map[string]string{"test": "true"}, Extra: &httpProtocol.ReqExtra{Header: map[string]string{"X-Old": "old"}}}
opts := &base.Options{Path: t.TempDir()}
var id string
var err error
if resolved {
rr, resolveErr := d.Resolve(req, opts)
if resolveErr != nil {
t.Fatal(resolveErr)
}
id, err = d.Create(rr.ID)
} else {
id, err = d.CreateDirect(req, opts)
}
if err != nil {
t.Fatal(err)
}
select {
case err = <-done:
case <-time.After(10 * time.Second):
t.Fatal("download timed out")
}
if invalid && d.GetTask(id).Meta.Req.Labels["rejected"] != "true" {
t.Fatal("invalid header was not rejected")
}
if err != nil {
t.Fatal(err)
}
got, ok := d.GetTask(id).Meta.Req.Extra.(*httpProtocol.ReqExtra)
if !ok || got.Header["X-New"] != "new" || len(got.Header) != 1 {
t.Fatalf("unexpected protocol extra: %#v", d.GetTask(id).Meta.Req.Extra)
}
if !resolved && updatedRequests.Load() == 0 {
t.Fatal("download did not use the extension headers")
}
data, err := os.ReadFile(filepath.Join(opts.Path, "test.txt"))
if err != nil || string(data) != "extra mutation payload" {
t.Fatalf("download content = %q, error = %v", data, err)
}
})
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package download
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/GopeedLab/gopeed/pkg/base"
btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/dop251/goja"
)
// ExtensionTaskRequest exposes request operations without exposing the backing task.
// It always reads the current request so a retained handle sees subsequent updates.
type ExtensionTaskRequest struct {
*base.Request
task *Task
}
// SetUrl replaces the task request URL.
func (r *ExtensionTaskRequest) SetUrl(url string) { r.task.Meta.Req.URL = url }
// SetLabels replaces all labels on the request.
func (r *ExtensionTaskRequest) SetLabels(labels map[string]string) {
r.Labels = labels
}
// PutLabel sets a label on the request.
func (r *ExtensionTaskRequest) PutLabel(key, value string) {
if r.Labels == nil {
r.Labels = make(map[string]string)
}
r.Labels[key] = value
}
// DelLabel deletes a label from the request.
func (r *ExtensionTaskRequest) DelLabel(key string) {
delete(r.Labels, key)
}
// mutateTaskExtra converts into an independent protocol value before mutation.
// Other protocols are a no-op. Failed conversion or mutation must not change the live request.
func mutateTaskExtra[E any](task *Task, protocol string, mutate func(*E) error) error {
if task.Protocol != protocol {
return nil
}
if task.Meta == nil || task.Meta.Req == nil {
return fmt.Errorf("task request is missing")
}
var extra E
if err := util.MapToStruct(task.Meta.Req.Extra, &extra); err != nil {
return err
}
if err := mutate(&extra); err != nil {
return err
}
task.Meta.Req.Extra = &extra
return nil
}
func (t *ExtensionTaskRequest) SetMethod(method string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Method = method; return nil })
}
func (t *ExtensionTaskRequest) SetBody(body string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Body = body; return nil })
}
// decodeHeaders omits undefined properties and rejects non-string values.
func decodeHeaders(value *goja.Object) (map[string]string, error) {
if value == nil {
return nil, fmt.Errorf("headers must be an object")
}
data, err := value.MarshalJSON()
if err != nil {
return nil, err
}
var input map[string]*string
if err := json.Unmarshal(data, &input); err != nil {
return nil, err
}
if input == nil {
return nil, fmt.Errorf("headers must be an object")
}
headers := make(map[string]string, len(input))
seen := make(map[string]bool, len(input))
for name, value := range input {
if value == nil {
return nil, fmt.Errorf("header %q must be a string", name)
}
lower := strings.ToLower(name)
if seen[lower] {
return nil, fmt.Errorf("duplicate header name %q", name)
}
seen[lower] = true
headers[http.CanonicalHeaderKey(name)] = *value
}
return headers, nil
}
func (t *ExtensionTaskRequest) SetHeaders(headers *goja.Object) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error {
values, err := decodeHeaders(headers)
if err != nil {
return err
}
extra.Header = values
return nil
})
}
func (t *ExtensionTaskRequest) PutHeader(name, value string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error {
if extra.Header == nil {
extra.Header = make(map[string]string)
}
deleteHeader(extra.Header, name)
extra.Header[http.CanonicalHeaderKey(name)] = value
return nil
})
}
func (t *ExtensionTaskRequest) DelHeader(name string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { deleteHeader(extra.Header, name); return nil })
}
func deleteHeader(headers map[string]string, name string) {
for key := range headers {
if strings.EqualFold(key, name) {
delete(headers, key)
}
}
}
func (t *ExtensionTaskRequest) SetTrackers(trackers []string) error {
return mutateTaskExtra(t.task, "bt", func(extra *btProtocol.ReqExtra) error { extra.Trackers = append([]string{}, trackers...); return nil })
}
+36 -3
View File
@@ -14,10 +14,12 @@ import (
"testing" "testing"
"time" "time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/dop251/goja" "github.com/dop251/goja"
) )
@@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
t.Fatal("resolve error") t.Fatal("resolve error")
} }
if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" { if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" {
t.Fatalf("request label methods did not update labels: %#v", req.Labels) t.Fatalf("declarative labels did not update: %#v", req.Labels)
} }
if _, ok := req.Labels["original"]; ok { if _, ok := req.Labels["original"]; ok {
t.Fatalf("setLabels did not replace existing labels: %#v", req.Labels) t.Fatalf("label assignment did not replace existing labels: %#v", req.Labels)
} }
if _, ok := req.Labels["removed"]; ok { if _, ok := req.Labels["removed"]; ok {
t.Fatalf("delLabel did not remove label: %#v", req.Labels) t.Fatalf("declarative label deletion failed: %#v", req.Labels)
} }
}) })
} }
@@ -1188,6 +1190,37 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
}) })
} }
func TestDownloader_Extension_OnStartExtraMutation(t *testing.T) {
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
if _, err := d.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
t.Fatal(err)
}
for _, extra := range []any{nil, &httpProtocol.ReqExtra{
Method: "POST", Body: "old body", Header: map[string]string{"Old": "value"},
}} {
req := &base.Request{URL: "https://github.com/test", Extra: extra}
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
d.triggerOnStart(task)
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
t.Fatal(err)
}
got, ok := req.Extra.(*httpProtocol.ReqExtra)
if !ok || got.Header["X-Gopeed-Test"] != "on-start" {
t.Fatalf("setHeaders did not update the original request: %#v", req.Extra)
}
if len(got.Header) != 1 {
t.Fatalf("headers not replaced: %#v", got)
}
if original, ok := extra.(*httpProtocol.ReqExtra); ok && (got.Method != original.Method || got.Body != original.Body) {
t.Fatalf("setHeaders changed unrelated fields: %#v", got)
}
}
}
func TestDownloader_Extension_OnStart(t *testing.T) { func TestDownloader_Extension_OnStart(t *testing.T) {
downloadAndCheck := func(req *base.Request) { downloadAndCheck := func(req *base.Request) {
setupDownloader(func(downloader *Downloader) { setupDownloader(func(downloader *Downloader) {
+13
View File
@@ -23,6 +23,9 @@ type ResolveResult struct {
Res *base.Resource `json:"res"` Res *base.Resource `json:"res"`
} }
// Task represents a download unit.
// Options flow path:
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
@@ -32,6 +35,10 @@ type Task struct {
Progress *Progress `json:"progress"` Progress *Progress `json:"progress"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
// DoneAt is the time when the task completed; nil if it has never
// finished. A done task never leaves the done state, so the value is
// written once and kept.
DoneAt *time.Time `json:"doneAt"`
producer production.Source producer production.Source
producerReceived int64 producerReceived int64
@@ -134,6 +141,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
func (t *Task) updateStatus(status base.Status) { func (t *Task) updateStatus(status base.Status) {
t.UpdatedAt = time.Now() t.UpdatedAt = time.Now()
t.Status = status t.Status = status
// A done task never leaves the done state, so there is no branch to clear
// DoneAt on other transitions.
if status == base.DownloadStatusDone {
doneAt := time.Now()
t.DoneAt = &doneAt
}
} }
func (t *Task) clone() *Task { func (t *Task) clone() *Task {
+6 -3
View File
@@ -1,7 +1,10 @@
gopeed.events.onResolve(async function (ctx) { gopeed.events.onResolve(async function (ctx) {
await ctx.req.setLabels({ replaced: "true", removed: "true" }); for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) {
await ctx.req.putLabel("modified", "true"); if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`);
await ctx.req.delLabel("removed"); }
ctx.req.labels = { replaced: "true", removed: "true" };
ctx.req.labels.modified = "true";
delete ctx.req.labels.removed;
ctx.res = { ctx.res = {
name: "test", name: "test",
files: Array(2).fill(true).map((_, i) => ({ files: Array(2).fill(true).map((_, i) => ({
@@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) {
return; return;
} }
await ctx.task.setUrl(await createPayloadURL(source, false)); await ctx.task.meta.req.setUrl(await createPayloadURL(source, false));
await req.putLabel('started', 'true'); await req.putLabel('started', 'true');
await req.putLabel('rebuilt', 'true'); await req.putLabel('rebuilt', 'true');
await ctx.task.continue(); await ctx.task.continue();
+1 -1
View File
@@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) {
} }
await req.putLabel("recovered", "true"); await req.putLabel("recovered", "true");
await ctx.task.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" }))); await ctx.task.meta.req.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
await ctx.task.continue(); await ctx.task.continue();
}); });
+1 -1
View File
@@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) {
} }
try { try {
await ctx.task.setUrl(await createRangeBlobUrl(req.rawUrl)); await ctx.task.meta.req.setUrl(await createRangeBlobUrl(req.rawUrl));
await req.putLabel('started', 'true'); await req.putLabel('started', 'true');
await req.putLabel('rebuilt', 'true'); await req.putLabel('rebuilt', 'true');
await ctx.task.continue(); await ctx.task.continue();
+1 -1
View File
@@ -1,6 +1,6 @@
gopeed.events.onError(async function (ctx) { gopeed.events.onError(async function (ctx) {
gopeed.logger.info("url", ctx.task.meta.req.url); gopeed.logger.info("url", ctx.task.meta.req.url);
gopeed.logger.info("error", ctx.error); gopeed.logger.info("error", ctx.error);
await ctx.task.setUrl("https://github.com"); await ctx.task.meta.req.setUrl("https://github.com");
await ctx.task.continue(); await ctx.task.continue();
}); });
+2 -1
View File
@@ -1,5 +1,6 @@
gopeed.events.onStart(async function (ctx) { gopeed.events.onStart(async function (ctx) {
gopeed.logger.info("url", ctx.task.meta.req.url); gopeed.logger.info("url", ctx.task.meta.req.url);
await ctx.task.setUrl("https://github.com"); await ctx.task.meta.req.setUrl("https://github.com");
await ctx.task.meta.req.setHeaders({ "X-Gopeed-Test": "on-start" });
await ctx.task.meta.req.putLabel('modified', 'true'); await ctx.task.meta.req.putLabel('modified', 'true');
}); });
+1
View File
@@ -21,6 +21,7 @@ type WebhookEvent string
const ( const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE" WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR" WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
) )
// WebhookData is the data sent to webhook URLs // WebhookData is the data sent to webhook URLs
+43
View File
@@ -814,6 +814,49 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
}) })
} }
func TestWebhook_TriggerOnPause(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadPause {
t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
} else if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) { func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup() defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage" defaultDownloader.cfg.StorageDir = ".test_storage"
+61 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download" "github.com/GopeedLab/gopeed/pkg/download"
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/google/jsonschema-go/jsonschema" "github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@@ -71,6 +72,7 @@ type httpTaskExtra struct {
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"` AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"` ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"` DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
} }
// downloadRequest is the MCP-facing form of base.Request. The REST model uses // downloadRequest is the MCP-facing form of base.Request. The REST model uses
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,") strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
} }
type checksumOption struct {
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
}
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently // downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
// defines protocol-specific task options, so Extra has one concrete schema. // defines protocol-specific task options, so Extra has one concrete schema.
type downloadOptions struct { type downloadOptions struct {
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
if o == nil { if o == nil {
return nil return nil
} }
var extra any
if o.Extra != nil {
var checksum *phttp.ChecksumOption
if o.Extra.Checksum != nil {
checksum = &phttp.ChecksumOption{
Algorithm: o.Extra.Checksum.Algorithm,
Expected: o.Extra.Checksum.Expected,
}
}
extra = &phttp.OptsExtra{
Connections: o.Extra.Connections,
AutoTorrent: o.Extra.AutoTorrent,
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
AutoExtract: o.Extra.AutoExtract,
ArchivePassword: o.Extra.ArchivePassword,
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
Checksum: checksum,
}
}
return &base.Options{ return &base.Options{
Name: o.Name, Name: o.Name,
Path: o.Path, Path: o.Path,
AsDefaultPath: o.AsDefaultPath, AsDefaultPath: o.AsDefaultPath,
SelectFiles: o.SelectFiles, SelectFiles: o.SelectFiles,
Extra: o.Extra, Extra: extra,
} }
} }
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
Stats any `json:"stats"` Stats any `json:"stats"`
} }
type getDownloadSpeedInput struct{}
type getDownloadSpeedOutput struct {
TotalSpeed int64 `json:"totalSpeed"`
ActiveTaskCount int `json:"activeTaskCount"`
}
type taskActionOutput struct { type taskActionOutput struct {
ID string `json:"id"` ID string `json:"id"`
Success bool `json:"success"` Success bool `json:"success"`
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
return nil, &getTaskStatsOutput{Stats: stats}, nil return nil, &getTaskStatsOutput{Stats: stats}, nil
}) })
mcp.AddTool(server, &mcp.Tool{
Name: "get_download_speed",
Title: "Get download speed",
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
allTasks := downloader.GetTasks()
var totalSpeed int64
activeCount := 0
for _, task := range allTasks {
status, err := downloader.RuntimeStatus(task.ID)
if err != nil {
continue
}
if status.Status == base.DownloadStatusRunning {
activeCount++
totalSpeed += status.Speed
}
}
return nil, &getDownloadSpeedOutput{
TotalSpeed: totalSpeed,
ActiveTaskCount: activeCount,
}, nil
})
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause) registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue) registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
+1
View File
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
"continue_task", "continue_task",
"create_task", "create_task",
"delete_task", "delete_task",
"get_download_speed",
"get_task", "get_task",
"get_task_stats", "get_task_stats",
"get_task_status", "get_task_status",
+103
View File
@@ -0,0 +1,103 @@
package mcpserver
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestGetDownloadSpeedTool(t *testing.T) {
// Serve a test payload that takes a little bit of time or streams bytes
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1048576")
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 8192)
for i := 0; i < 128; i++ {
_, _ = w.Write(chunk)
time.Sleep(10 * time.Millisecond)
}
}))
defer testServer.Close()
downloader := download.NewDownloader(&download.DownloaderConfig{
Storage: download.NewMemStorage(),
StorageDir: t.TempDir(),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Close()
httpServer := httptest.NewServer(NewHandler(downloader))
defer httpServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
if err != nil {
t.Fatal(err)
}
defer session.Close()
// 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount)
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 {
t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"])
}
if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 {
t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"])
}
// 2. Start a real download task
taskID, err := downloader.CreateDirect(&base.Request{
URL: testServer.URL,
}, &base.Options{
Path: t.TempDir(),
})
if err != nil {
t.Fatal(err)
}
defer func() {
_ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true)
}()
// Query get_download_speed while downloading
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok = result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if _, ok := structured["activeTaskCount"].(float64); !ok {
t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"])
}
if _, ok := structured["totalSpeed"].(float64); !ok {
t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"])
}
}
+9
View File
@@ -6,6 +6,13 @@ type ReqExtra struct {
Body string `json:"body"` Body string `json:"body"`
} }
// ChecksumOption specifies an expected checksum to verify against
// the completed download. Only supported for HTTP downloads.
type ChecksumOption struct {
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
Expected string `json:"expected"` // hex-encoded expected hash
}
type OptsExtra struct { type OptsExtra struct {
Connections int `json:"connections"` Connections int `json:"connections"`
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file // AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
@@ -21,6 +28,8 @@ type OptsExtra struct {
ArchivePassword string `json:"archivePassword"` ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction // DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"` DeleteAfterExtract bool `json:"deleteAfterExtract"`
// Checksum verifies the completed file against an expected hash after download
Checksum *ChecksumOption `json:"checksum,omitempty"`
} }
// Stats for download // Stats for download
+15
View File
@@ -0,0 +1,15 @@
package http
import "testing"
func TestOptsExtra_Checksum(t *testing.T) {
extra := &OptsExtra{
Checksum: &ChecksumOption{
Algorithm: "sha256",
Expected: "abcdef1234567890",
},
}
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "abcdef1234567890" {
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ Guidelines:
Use the following desktop reference metrics unless a specific screen requires a documented exception: Use the following desktop reference metrics unless a specific screen requires a documented exception:
- Reference window: `1024 x 768` - Reference window: `1024 x 768`
- Primary rail width: `64px` - Primary rail width: `78px` (reserves space for native macOS traffic lights)
- Secondary filter sidebar width: `256px` - Secondary filter sidebar width: `256px`
- Main content header height: `80px` - Main content header height: `80px`
- Window drag header height on Windows: `30px` - Window drag header height on Windows: `30px`
+6
View File
@@ -85,6 +85,12 @@ Appearance synchronization uses `appearance.changed` and currently contains:
- Theme color - Theme color
- Locale - Locale
After successful task creation, a child calls the typed
`NavigationMethods.showDownloadingTasks` capability before closing. The host
routes to the task list and resets its filter to downloading, including any
search or selection on an already-mounted list. This does not show or focus a
hidden main window. Cancellation and failed creation do not request navigation.
State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points. State broadcasting must observe the owning Riverpod state centrally. Do not broadcast directly from a settings button callback, because updates can originate from configuration loading, system changes, or future entry points.
## 6. Storage ## 6. Storage
@@ -18,6 +18,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; }; 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; };
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B30000000000000000000002 /* GopeedTaskEvent.swift */; };
6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; }; 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; };
6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; }; 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
@@ -102,6 +103,7 @@
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = "<group>"; }; 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = "<group>"; };
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = "<group>"; }; 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = "<group>"; };
B30000000000000000000002 /* GopeedTaskEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedTaskEvent.swift; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
@@ -286,6 +288,7 @@
09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */, 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */,
EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */, EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */,
3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */, 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */,
B30000000000000000000002 /* GopeedTaskEvent.swift */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -607,6 +610,7 @@
1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */, 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */,
032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */, 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */,
5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */, 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */,
B30000000000000000000001 /* GopeedTaskEvent.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -22,8 +22,8 @@ final class GopeedContinuedProcessingManager: NSObject {
// MARK: - BGCPT state // MARK: - BGCPT state
private var registeredIdentifiers = Set<String>()
private var pendingTaskIDs = Set<String>() private var pendingTaskIDs = Set<String>()
private var foregroundStartTokens: [String: UUID] = [:]
private struct ExpiringTaskState { private struct ExpiringTaskState {
let identifier: String let identifier: String
let generation: UUID let generation: UUID
@@ -167,16 +167,11 @@ final class GopeedContinuedProcessingManager: NSObject {
// Pre-mark task.start so the Objective-C forwarder can // Pre-mark task.start so the Objective-C forwarder can
// immediately suppress the custom ActivityKit Live Activity. // immediately suppress the custom ActivityKit Live Activity.
if if
let data = payload.data(using: .utf8), let event = GopeedTaskEvent.decode(payload),
let json = try? JSONSerialization.jsonObject( event.type == .start
with: data
) as? [String: Any],
let type = json["type"] as? String,
type == "task.start",
let taskID = json["taskId"] as? String
{ {
setTaskOwnership( setTaskOwnership(
taskID: taskID, taskID: event.taskID,
handled: true handled: true
) )
} }
@@ -198,33 +193,19 @@ final class GopeedContinuedProcessingManager: NSObject {
return return
} }
guard guard let event = GopeedTaskEvent.decode(payload) else {
let data = payload.data(
using: .utf8
),
let json =
try? JSONSerialization
.jsonObject(
with: data
) as? [String: Any],
let type =
json["type"] as? String,
let taskID =
json["taskId"] as? String
else {
print( print(
"ContinuedProcessing: invalid event" "ContinuedProcessing: invalid event"
) )
return return
} }
let name = let taskID = event.taskID
json["name"] as? String let name = event.name
?? "Download"
switch type { switch event.type {
case "task.start": case .start:
progressRecoveryTokens.removeValue( progressRecoveryTokens.removeValue(
forKey: taskID forKey: taskID
) )
@@ -234,7 +215,7 @@ final class GopeedContinuedProcessingManager: NSObject {
name: name name: name
) )
case "task.progress": case .progress:
if let generation = if let generation =
taskGenerations[taskID] { taskGenerations[taskID] {
updateProgress( updateProgress(
@@ -249,7 +230,7 @@ final class GopeedContinuedProcessingManager: NSObject {
) )
} }
case "task.pause": case .pause:
if let expiring = if let expiring =
expiringTasks[taskID] { expiringTasks[taskID] {
finishExpirationCleanup( finishExpirationCleanup(
@@ -265,7 +246,7 @@ final class GopeedContinuedProcessingManager: NSObject {
) )
} }
case "task.done": case .done:
finishTask( finishTask(
taskID: taskID, taskID: taskID,
success: true, success: true,
@@ -273,7 +254,7 @@ final class GopeedContinuedProcessingManager: NSObject {
"Download complete" "Download complete"
) )
case "task.error": case .error:
finishTask( finishTask(
taskID: taskID, taskID: taskID,
success: false, success: false,
@@ -281,7 +262,7 @@ final class GopeedContinuedProcessingManager: NSObject {
"Download failed" "Download failed"
) )
case "task.delete": case .delete:
finishTask( finishTask(
taskID: taskID, taskID: taskID,
success: true, success: true,
@@ -289,12 +270,11 @@ final class GopeedContinuedProcessingManager: NSObject {
"Download removed" "Download removed"
) )
default: case .other:
break break
} }
} }
// MARK: - Recover active task after setting changes // MARK: - Recover active task after setting changes
private func recoverTaskFromProgress( private func recoverTaskFromProgress(
@@ -307,6 +287,7 @@ final class GopeedContinuedProcessingManager: NSObject {
!pendingTaskIDs.contains(taskID), !pendingTaskIDs.contains(taskID),
activeTasks[taskID] == nil, activeTasks[taskID] == nil,
expiringTasks[taskID] == nil, expiringTasks[taskID] == nil,
foregroundStartTokens[taskID] == nil,
progressRecoveryTokens[taskID] == nil progressRecoveryTokens[taskID] == nil
else { else {
return return
@@ -395,7 +376,8 @@ final class GopeedContinuedProcessingManager: NSObject {
guard guard
!pendingTaskIDs.contains(taskID), !pendingTaskIDs.contains(taskID),
activeTasks[taskID] == nil activeTasks[taskID] == nil,
foregroundStartTokens[taskID] == nil
else { else {
setTaskOwnership( setTaskOwnership(
taskID: taskID, taskID: taskID,
@@ -404,20 +386,57 @@ final class GopeedContinuedProcessingManager: NSObject {
return return
} }
// BGCPT should originate from a foreground user action. // BGCPT must originate from a foreground user action.
// This main-thread check happens only once per task start. // Never synchronously hop from workerQueue to the main queue:
let appIsActive: Bool // doing so can deadlock if the main thread is waiting on work
// that eventually needs workerQueue.
let foregroundToken = UUID()
foregroundStartTokens[taskID] =
foregroundToken
if Thread.isMainThread { DispatchQueue.main.async { [weak self] in
appIsActive = let appIsActive =
UIApplication.shared
.applicationState == .active
} else {
appIsActive =
DispatchQueue.main.sync {
UIApplication.shared UIApplication.shared
.applicationState == .active .applicationState == .active
guard let self else {
return
} }
self.workerQueue.async {
self.continueBeginTaskAfterForegroundCheck(
taskID: taskID,
name: name,
foregroundToken: foregroundToken,
appIsActive: appIsActive
)
}
}
}
private func continueBeginTaskAfterForegroundCheck(
taskID: String,
name: String,
foregroundToken: UUID,
appIsActive: Bool
) {
guard
foregroundStartTokens[taskID]
== foregroundToken
else {
return
}
foregroundStartTokens.removeValue(
forKey: taskID
)
guard currentEnabledSnapshot() else {
setTaskOwnership(
taskID: taskID,
handled: false
)
return
} }
guard appIsActive else { guard appIsActive else {
@@ -434,6 +453,20 @@ final class GopeedContinuedProcessingManager: NSObject {
return return
} }
// Re-check after the asynchronous main-thread hop. A terminal
// event, disable, or another valid start may have changed the
// lifecycle while the foreground state was being read.
guard
!pendingTaskIDs.contains(taskID),
activeTasks[taskID] == nil
else {
setTaskOwnership(
taskID: taskID,
handled: true
)
return
}
let generation = UUID() let generation = UUID()
taskGenerations[taskID] = generation taskGenerations[taskID] = generation
@@ -498,8 +531,6 @@ final class GopeedContinuedProcessingManager: NSObject {
return return
} }
registeredIdentifiers.insert(identifier)
let request = let request =
BGContinuedProcessingTaskRequest( BGContinuedProcessingTaskRequest(
identifier: identifier, identifier: identifier,
@@ -558,7 +589,6 @@ final class GopeedContinuedProcessingManager: NSObject {
} }
} }
// MARK: - System launched task // MARK: - System launched task
private func activateTask( private func activateTask(
@@ -901,6 +931,10 @@ final class GopeedContinuedProcessingManager: NSObject {
success: Bool, success: Bool,
finalSubtitle: String finalSubtitle: String
) { ) {
foregroundStartTokens.removeValue(
forKey: taskID
)
progressRecoveryTokens.removeValue( progressRecoveryTokens.removeValue(
forKey: taskID forKey: taskID
) )
@@ -1115,6 +1149,7 @@ final class GopeedContinuedProcessingManager: NSObject {
Set( Set(
handledTaskIDsSnapshot() handledTaskIDsSnapshot()
+ Array(taskIdentifiers.keys) + Array(taskIdentifiers.keys)
+ Array(foregroundStartTokens.keys)
+ Array(pendingTaskIDs) + Array(pendingTaskIDs)
+ Array(activeTasks.keys) + Array(activeTasks.keys)
+ Array(expiringTasks.keys) + Array(expiringTasks.keys)
@@ -1140,6 +1175,7 @@ final class GopeedContinuedProcessingManager: NSObject {
} }
activeTasks.removeAll() activeTasks.removeAll()
foregroundStartTokens.removeAll()
pendingTaskIDs.removeAll() pendingTaskIDs.removeAll()
expiringTasks.removeAll() expiringTasks.removeAll()
taskGenerations.removeAll() taskGenerations.removeAll()
@@ -248,26 +248,22 @@ final class GopeedLiveActivityManager: NSObject {
return return
} }
guard guard let event = GopeedTaskEvent.decode(payload) else {
let data = payload.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data)
as? [String: Any],
let type = json["type"] as? String,
let taskID = json["taskId"] as? String
else {
print("LiveActivity: invalid task event payload") print("LiveActivity: invalid task event payload")
return return
} }
let taskID = event.taskID
guard !isTaskSuppressed(taskID) else { guard !isTaskSuppressed(taskID) else {
return return
} }
let name = json["name"] as? String ?? "Download" let name = event.name
switch type { switch event.type {
case "task.start": case .start:
let generation = let generation =
startGeneration( startGeneration(
taskID: taskID taskID: taskID
@@ -282,7 +278,7 @@ final class GopeedLiveActivityManager: NSObject {
) )
} }
case "task.progress": case .progress:
guard guard
let generation = let generation =
generationForProgress( generationForProgress(
@@ -298,7 +294,7 @@ final class GopeedLiveActivityManager: NSObject {
generation: generation generation: generation
) )
case "task.pause": case .pause:
if let generation = if let generation =
invalidateGeneration( invalidateGeneration(
taskID: taskID taskID: taskID
@@ -312,7 +308,7 @@ final class GopeedLiveActivityManager: NSObject {
} }
} }
case "task.done": case .done:
if let generation = if let generation =
invalidateGeneration( invalidateGeneration(
taskID: taskID taskID: taskID
@@ -325,9 +321,9 @@ final class GopeedLiveActivityManager: NSObject {
} }
} }
case "task.error": case .error:
let error = let error =
json["error"] as? String event.error
?? "Download failed" ?? "Download failed"
if let generation = if let generation =
@@ -343,7 +339,7 @@ final class GopeedLiveActivityManager: NSObject {
} }
} }
case "task.delete": case .delete:
if let generation = if let generation =
invalidateGeneration( invalidateGeneration(
taskID: taskID taskID: taskID
@@ -357,7 +353,7 @@ final class GopeedLiveActivityManager: NSObject {
} }
} }
default: case .other:
break break
} }
} }
@@ -0,0 +1,69 @@
import Foundation
enum GopeedTaskEventType: Equatable {
case start
case progress
case pause
case done
case error
case delete
case other(String)
init(rawValue: String) {
switch rawValue {
case "task.start":
self = .start
case "task.progress":
self = .progress
case "task.pause":
self = .pause
case "task.done":
self = .done
case "task.error":
self = .error
case "task.delete":
self = .delete
default:
self = .other(rawValue)
}
}
}
struct GopeedTaskEvent {
let type: GopeedTaskEventType
let taskID: String
let name: String
let error: String?
private struct Payload: Decodable {
let type: String
let taskId: String
let name: String?
let error: String?
}
static func decode(
_ payload: String
) -> GopeedTaskEvent? {
guard
let data = payload.data(using: .utf8),
let decoded =
try? JSONDecoder().decode(
Payload.self,
from: data
)
else {
return nil
}
return GopeedTaskEvent(
type:
GopeedTaskEventType(
rawValue: decoded.type
),
taskID: decoded.taskId,
name: decoded.name ?? "Download",
error: decoded.error
)
}
}
+44 -7
View File
@@ -11,6 +11,8 @@ class GopeedSiteApi {
static final instance = GopeedSiteApi._(); static final instance = GopeedSiteApi._();
static const _host = 'gopeed.com'; static const _host = 'gopeed.com';
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
final _storeRequests = <String, Future<StoreExtensionPage>>{};
Future<List<dynamic>> getReleases({int perPage = 10}) async { Future<List<dynamic>> getReleases({int perPage = 10}) async {
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()}); final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
@@ -26,20 +28,52 @@ class GopeedSiteApi {
StoreExtensionSort sort = StoreExtensionSort.stars, StoreExtensionSort sort = StoreExtensionSort.stars,
StoreSortOrder order = StoreSortOrder.desc, StoreSortOrder order = StoreSortOrder.desc,
String? query, String? query,
bool forceRefresh = false,
}) async { }) async {
final json = await _getJson( final params = {
'/api/extensions',
queryParameters: {
'page': page.toString(), 'page': page.toString(),
'limit': limit.clamp(1, 100).toString(), 'limit': limit.clamp(1, 100).toString(),
'sort': sort.name, 'sort': sort.name,
'order': order.name, 'order': order.name,
'view': 'summary',
if (query != null && query.trim().isNotEmpty) 'q': query.trim(), if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
}, };
); final key = Uri.https(_host, '/api/extensions', params).toString();
final now = DateTime.now();
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
final cached = _storePages[key];
if (!forceRefresh && cached != null) return cached.page;
final pending = _storeRequests[key];
if (pending != null) return pending;
final request = _fetchStorePage(params);
_storeRequests[key] = request;
try {
final result = await request;
if (_storePages.length >= 32) _storePages.remove(_storePages.keys.first);
_storePages[key] = (page: result, expires: DateTime.now().add(const Duration(minutes: 2)));
return result;
} finally {
_storeRequests.remove(key);
}
}
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
final json = await _getJson('/api/extensions', queryParameters: params);
return StoreExtensionPage.fromJson(json as Map<String, dynamic>); return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
} }
Future<StoreExtension> getExtension(String id, {String? version}) async {
final json = await _getJsonUri(
Uri(
scheme: 'https',
host: _host,
pathSegments: ['api', 'extensions', id],
queryParameters: {if (version != null && version.isNotEmpty) 'version': version},
),
);
return StoreExtension.fromJson(json as Map<String, dynamic>);
}
Future<void> reportExtensionInstall(String id) async { Future<void> reportExtensionInstall(String id) async {
final uri = Uri.https(_host, '/api/extensions/install'); final uri = Uri.https(_host, '/api/extensions/install');
await proxyRequest( await proxyRequest(
@@ -49,8 +83,11 @@ class GopeedSiteApi {
); );
} }
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async { Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
final uri = Uri.https(_host, path, queryParameters); return _getJsonUri(Uri.https(_host, path, queryParameters));
}
Future<dynamic> _getJsonUri(Uri uri) async {
final Response<String> response = await proxyRequest(uri.toString()); final Response<String> response = await proxyRequest(uri.toString());
if (response.data == null || response.data!.isEmpty) { if (response.data == null || response.data!.isEmpty) {
throw Exception('Empty response from $uri'); throw Exception('Empty response from $uri');
+17
View File
@@ -17,6 +17,21 @@ class Options {
Map<String, dynamic> toJson() => _$OptionsToJson(this); Map<String, dynamic> toJson() => _$OptionsToJson(this);
} }
@JsonSerializable()
class ChecksumOption {
String algorithm;
String expected;
ChecksumOption({
this.algorithm = '',
this.expected = '',
});
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
}
@JsonSerializable() @JsonSerializable()
class OptsExtraHttp { class OptsExtraHttp {
int connections; int connections;
@@ -25,6 +40,7 @@ class OptsExtraHttp {
bool? autoExtract; bool? autoExtract;
String archivePassword; String archivePassword;
bool deleteAfterExtract; bool deleteAfterExtract;
ChecksumOption? checksum;
OptsExtraHttp({ OptsExtraHttp({
this.connections = 0, this.connections = 0,
@@ -33,6 +49,7 @@ class OptsExtraHttp {
this.autoExtract, this.autoExtract,
this.archivePassword = '', this.archivePassword = '',
this.deleteAfterExtract = false, this.deleteAfterExtract = false,
this.checksum,
}); });
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json); factory OptsExtraHttp.fromJson(Map<String, dynamic> json) => _$OptsExtraHttpFromJson(json);
+14
View File
@@ -22,6 +22,16 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
'extra': ?instance.extra, 'extra': ?instance.extra,
}; };
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
algorithm: json['algorithm'] as String? ?? '',
expected: json['expected'] as String? ?? '',
);
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
'algorithm': instance.algorithm,
'expected': instance.expected,
};
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp( OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
connections: (json['connections'] as num?)?.toInt() ?? 0, connections: (json['connections'] as num?)?.toInt() ?? 0,
autoTorrent: json['autoTorrent'] as bool?, autoTorrent: json['autoTorrent'] as bool?,
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
autoExtract: json['autoExtract'] as bool?, autoExtract: json['autoExtract'] as bool?,
archivePassword: json['archivePassword'] as String? ?? '', archivePassword: json['archivePassword'] as String? ?? '',
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false, deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
checksum: json['checksum'] == null
? null
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
'autoExtract': ?instance.autoExtract, 'autoExtract': ?instance.autoExtract,
'archivePassword': instance.archivePassword, 'archivePassword': instance.archivePassword,
'deleteAfterExtract': instance.deleteAfterExtract, 'deleteAfterExtract': instance.deleteAfterExtract,
'checksum': ?instance.checksum?.toJson(),
}; };
Loaded 50 of 101 files, more files were not shown because too many files have changed in this diff. Show more