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
Levi 2d4a8cddc1 fix(ui): keep extension update chip within card header 2026-09-26 11:05:43 +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
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
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 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 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
67 changed files with 2194 additions and 332 deletions

No files matched your search

+7 -3
View File
@@ -7,9 +7,11 @@ import (
)
type args struct {
url string
connections *int
dir *string
url string
connections *int
dir *string
checksum *string
checksumAlgo *string
}
func parse() *args {
@@ -20,6 +22,8 @@ func parse() *args {
var args args
args.connections = flag.Int("C", 16, "Concurrent connections.")
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()
t := flag.Args()
if len(t) > 0 {
+17 -2
View File
@@ -19,6 +19,18 @@ func main() {
var wg sync.WaitGroup
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().
URL(args.url).
Listener(func(event *download.Event) {
@@ -43,8 +55,11 @@ func main() {
}
}).
Create(&base.Options{
Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections},
Path: *args.dir,
Extra: http.OptsExtra{
Connections: *args.connections,
Checksum: checksumOpt,
},
})
if err != nil {
panic(err)
+106 -16
View File
@@ -2,8 +2,13 @@ package http
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
@@ -280,6 +285,12 @@ type Fetcher struct {
primaryReadyOnce sync.Once
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
startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes
@@ -735,12 +746,26 @@ func (f *Fetcher) doStart() error {
// If retrying after error, reset connection states for retry
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()
for _, conn := range f.connections {
// Reset connections that can be retried
if !conn.Completed && conn.State != connCompleted {
// On a normal error, only reset connections that didn't
// 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) {
f.resetConnectionForRestart(conn)
f.resetConnectionForRestart(conn, forceFullReset)
}
conn.State = connNotStarted
conn.failed = false
@@ -970,8 +995,14 @@ func (f *Fetcher) expandConnections() {
}
f.fileMu.Unlock()
f.setState(stateDone)
f.doneCh <- nil
var err error
if chkErr := f.verifyChecksum(); chkErr != nil {
err = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
f.doneCh <- err
return
}
@@ -1107,7 +1138,7 @@ func (f *Fetcher) runConnection(conn *connection) {
// it only when no safe validator exists or If-Range returns a full 200.
f.connMu.Lock()
if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn)
f.resetConnectionForRestart(conn, false)
}
f.connMu.Unlock()
}
@@ -1214,7 +1245,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
resumeProbe := f.canProbeSequentialResumeLocked(conn)
intentionalRestart := sequentialSizeUnknown
if sequentialSizeUnknown || (hasSequentialPrefix && !resumeProbe) {
f.resetConnectionForRestart(conn)
f.resetConnectionForRestart(conn, false)
f.resolveDataPos.Store(0)
f.rangeValidatorPinned = false
intentionalRestart = true
@@ -1659,7 +1690,7 @@ func (f *Fetcher) fallbackToSequentialDownload(conn *connection, ifRange string)
f.rangeReprobeEligible = true
f.rangeValidatorPinned = false
f.ifRange = ifRange
f.resetConnectionForRestart(conn)
f.resetConnectionForRestart(conn, false)
f.resolveDataPos.Store(0)
return nil
}
@@ -1675,7 +1706,7 @@ func (f *Fetcher) restartSequentialDownload(conn *connection, ifRange string) {
f.rangeReprobeEligible = true
f.rangeValidatorPinned = false
f.ifRange = ifRange
f.resetConnectionForRestart(conn)
f.resetConnectionForRestart(conn, false)
f.resolveDataPos.Store(0)
}
@@ -2058,16 +2089,25 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
return true
}
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
if f.meta.Res.Range {
func (f *Fetcher) resetConnectionForRestart(conn *connection, force bool) {
if f.meta.Res.Range && !force {
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,
// so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0)
} else {
} else if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.setEnd(0)
conn.Chunk.Downloaded = 0
@@ -2131,7 +2171,7 @@ func (f *Fetcher) resumeConnections() {
}
}
if !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn)
f.resetConnectionForRestart(conn, false)
}
// Reset the connection state for resume
conn.ctx, conn.cancel = context.WithCancel(f.ctx)
@@ -2238,10 +2278,15 @@ func (f *Fetcher) onDownloadComplete() {
}
f.fileMu.Unlock()
if finalErr != nil {
f.setState(stateError)
if finalErr == nil {
if chkErr := f.verifyChecksum(); chkErr != nil {
finalErr = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
} else {
f.setState(stateDone)
f.setState(stateError)
}
select {
@@ -2250,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 {
// Check if all data has been downloaded
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"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -853,22 +854,34 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
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) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
if r.Header.Get(base.HttpHeaderRange) == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
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)
return
}
rangeRequests.Add(1)
releaseResolve()
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(replacement)
}))
defer server.Close()
defer releaseResolve()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
+1
View File
@@ -1912,6 +1912,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
}
if handled {
d.emit(EventKeyPause, task)
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
}
return nil
}
@@ -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) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
+15 -60
View File
@@ -122,7 +122,7 @@
if (name === "set-cookie") {
for (const value of values) result.push([name, value]);
} else {
result.push([name, values.join(", ")]);
result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
}
}
return result;
@@ -158,49 +158,10 @@
return iterator;
}
function forbiddenRequestHeader(name, value) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true;
if ([
"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) {
function assertMutableHeaders(headers) {
// Match Node/Undici: HTTP syntax and immutability still apply, but browser
// forbidden-header and no-cors safelist rules do not apply to extensions.
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 {
@@ -235,27 +196,21 @@
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value;
if (blockedByGuard(this, name, combinedValue)) return;
assertMutableHeaders(this);
if (values) values.push(value);
else this._values.set(name, [value]);
}
delete(name) {
name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable");
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;
name = normalizeHeaderName(name);
assertMutableHeaders(this);
this._values.delete(name);
}
get(name) {
const values = this._values.get(normalizeHeaderName(name));
return values ? values.join(", ") : null;
name = normalizeHeaderName(name);
const values = this._values.get(name);
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
}
getSetCookie() {
@@ -268,10 +223,10 @@
}
set(name, value) {
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return;
this._values.set(name, [value]);
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
assertMutableHeaders(this);
this._values.set(name, [value]);
}
keys() { return createHeadersIterator(this, "key"); }
@@ -484,7 +439,7 @@
}
this._duplex = "half";
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);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
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
}
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])
}
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
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
construction and body consumption, immutable network response headers, and
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,
browser HTTP cache, navigation, browser authentication UI, or WPT's
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
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
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
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
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
var wptHeaders embed.FS
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
var wptRequestResponse embed.FS
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{
"header-setcookie.any.js",
"headers-basic.any.js",
"headers-casing.any.js",
"headers-combine.any.js",
"headers-errors.any.js",
"headers-forbidden-override.any.js",
"headers-normalize.any.js",
"headers-no-cors.any.js",
"headers-record.any.js",
"headers-structure.any.js",
}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
setup := ""
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)
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
})
}
}
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
}
runtime := engine.NewEngine(nil)
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 {
t.Fatal(err)
}
+3
View File
@@ -23,6 +23,9 @@ type ResolveResult struct {
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 {
ID string `json:"id"`
Protocol string `json:"protocol"`
+1
View File
@@ -21,6 +21,7 @@ type WebhookEvent string
const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
)
// 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)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
+67 -7
View File
@@ -12,6 +12,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -65,12 +66,13 @@ type btRequestExtra struct {
}
type httpTaskExtra struct {
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
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
@@ -129,6 +131,11 @@ func isBitTorrentRequest(rawURL string) bool {
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
// defines protocol-specific task options, so Extra has one concrete schema.
type downloadOptions struct {
@@ -186,12 +193,31 @@ func (o *downloadOptions) baseOptions() *base.Options {
if o == 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{
Name: o.Name,
Path: o.Path,
AsDefaultPath: o.AsDefaultPath,
SelectFiles: o.SelectFiles,
Extra: o.Extra,
Extra: extra,
}
}
@@ -270,6 +296,13 @@ type getTaskStatsOutput struct {
Stats any `json:"stats"`
}
type getDownloadSpeedInput struct{}
type getDownloadSpeedOutput struct {
TotalSpeed int64 `json:"totalSpeed"`
ActiveTaskCount int `json:"activeTaskCount"`
}
type taskActionOutput struct {
ID string `json:"id"`
Success bool `json:"success"`
@@ -416,6 +449,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
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, "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",
"create_task",
"delete_task",
"get_download_speed",
"get_task",
"get_task_stats",
"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"`
}
// 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 {
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
@@ -21,6 +28,8 @@ type OptsExtra struct {
ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"`
// Checksum verifies the completed file against an expected hash after download
Checksum *ChecksumOption `json:"checksum,omitempty"`
}
// 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:
- 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`
- Main content header height: `80px`
- Window drag header height on Windows: `30px`
+6
View File
@@ -85,6 +85,12 @@ Appearance synchronization uses `appearance.changed` and currently contains:
- Theme color
- 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.
## 6. Storage
+49 -12
View File
@@ -11,6 +11,8 @@ class GopeedSiteApi {
static final instance = GopeedSiteApi._();
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 {
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
@@ -26,20 +28,52 @@ class GopeedSiteApi {
StoreExtensionSort sort = StoreExtensionSort.stars,
StoreSortOrder order = StoreSortOrder.desc,
String? query,
bool forceRefresh = false,
}) async {
final json = await _getJson(
'/api/extensions',
queryParameters: {
'page': page.toString(),
'limit': limit.clamp(1, 100).toString(),
'sort': sort.name,
'order': order.name,
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
},
);
final params = {
'page': page.toString(),
'limit': limit.clamp(1, 100).toString(),
'sort': sort.name,
'order': order.name,
'view': 'summary',
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>);
}
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 {
final uri = Uri.https(_host, '/api/extensions/install');
await proxyRequest(
@@ -49,8 +83,11 @@ class GopeedSiteApi {
);
}
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
final uri = Uri.https(_host, path, queryParameters);
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) {
return _getJsonUri(Uri.https(_host, path, queryParameters));
}
Future<dynamic> _getJsonUri(Uri uri) async {
final Response<String> response = await proxyRequest(uri.toString());
if (response.data == null || response.data!.isEmpty) {
throw Exception('Empty response from $uri');
+17
View File
@@ -17,6 +17,21 @@ class Options {
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()
class OptsExtraHttp {
int connections;
@@ -25,6 +40,7 @@ class OptsExtraHttp {
bool? autoExtract;
String archivePassword;
bool deleteAfterExtract;
ChecksumOption? checksum;
OptsExtraHttp({
this.connections = 0,
@@ -33,6 +49,7 @@ class OptsExtraHttp {
this.autoExtract,
this.archivePassword = '',
this.deleteAfterExtract = false,
this.checksum,
});
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,
};
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(
connections: (json['connections'] as num?)?.toInt() ?? 0,
autoTorrent: json['autoTorrent'] as bool?,
@@ -29,6 +39,9 @@ OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHtt
autoExtract: json['autoExtract'] as bool?,
archivePassword: json['archivePassword'] as String? ?? '',
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>{
@@ -38,4 +51,5 @@ Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, d
'autoExtract': ?instance.autoExtract,
'archivePassword': instance.archivePassword,
'deleteAfterExtract': instance.deleteAfterExtract,
'checksum': ?instance.checksum?.toJson(),
};
@@ -64,6 +64,7 @@ class StoreExtension {
final List<String> topics;
final DateTime? createdAt;
final DateTime? updatedAt;
final bool hasDetails;
StoreExtension({
required this.id,
@@ -84,6 +85,7 @@ class StoreExtension {
required this.topics,
this.createdAt,
this.updatedAt,
this.hasDetails = true,
});
factory StoreExtension.fromJson(Map<String, dynamic> json) {
@@ -106,6 +108,7 @@ class StoreExtension {
topics: _parseTopics(json['topics']),
createdAt: _parseDate(json['createdAt']),
updatedAt: _parseDate(json['updatedAt']),
hasDetails: json.containsKey('readme'),
);
}
@@ -3,13 +3,13 @@ import 'dart:collection';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../api/gopeed_site_api.dart';
import '../../../api/model/extension.dart';
import '../../../api/model/install_extension.dart';
import '../../../api/model/store_extension.dart';
import '../../../api/model/switch_extension.dart';
import '../../../api/model/update_extension_settings.dart';
import '../../../core/capabilities/app_capabilities.dart';
import 'store_extension_details_provider.dart';
final extensionsControllerProvider = AsyncNotifierProvider<ExtensionsController, ExtensionsState>(
ExtensionsController.new,
@@ -121,63 +121,81 @@ class ExtensionsState {
class ExtensionsController extends AsyncNotifier<ExtensionsState> {
static const manualInstallBusyKey = '__manual_install__';
int _devModeCount = 0;
int _installedRevision = 0;
int _storeRevision = 0;
Future<void>? _updateCheck;
bool _checkUpdatesAgain = false;
@override
Future<ExtensionsState> build() async {
var next = const ExtensionsState(loadingInstalled: true, loadingStore: true);
Future<ExtensionsState> build() => _loadInitialData();
Future<ExtensionsState> _loadInitialData({bool forceRefresh = false}) async {
final next = _current.copyWith(loadingInstalled: true, loadingStore: true);
state = AsyncValue.data(next);
final installed = await _loadInstalled(refreshUpdates: true, current: next);
next = state.value ?? installed;
await _refreshStore(current: next);
return state.value ?? next;
await Future.wait([
_loadInstalled(refreshUpdates: true, current: next),
_refreshStore(current: _current, forceRefresh: forceRefresh),
]);
return ref.mounted ? _current : next;
}
Future<void> loadInitialData() async {
state = const AsyncValue.loading();
state = AsyncValue.data(await build());
await _loadInitialData(forceRefresh: true);
}
Future<void> loadInstalled({bool refreshUpdates = false}) async {
if (!ref.mounted) return;
await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true));
}
Future<void> refreshStore() async {
await _refreshStore(current: _current.copyWith(loadingStore: true));
await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true);
}
Future<void> loadMoreStore() async {
final current = _current;
final pagination = current.storePagination;
if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return;
final revision = _storeRevision;
state = AsyncValue.data(current.copyWith(loadingMoreStore: true));
try {
final page = await GopeedSiteApi.instance.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: current.storeSort,
query: current.storeQuery,
);
final page = await ref
.read(gopeedSiteApiProvider)
.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: current.storeSort,
query: current.storeQuery,
);
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(
_current.copyWith(
storeExtensions: [..._current.storeExtensions, ...page.data],
// Cached pages can briefly overlap when stars/install counts change.
storeExtensions: {
for (final extension in _current.storeExtensions) extension.id: extension,
for (final extension in page.data) extension.id: extension,
}.values.toList(),
storePagination: page.pagination,
loadingMoreStore: false,
),
);
} catch (_) {
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(_current.copyWith(loadingMoreStore: false));
rethrow;
}
}
Future<void> searchStore(String query) async {
if (query.trim() == _current.storeQuery) return;
state = AsyncValue.data(_current.copyWith(storeQuery: query.trim()));
await refreshStore();
await _refreshStore(current: _current);
}
Future<void> changeSort(StoreExtensionSort sort) async {
if (sort == _current.storeSort) return;
state = AsyncValue.data(_current.copyWith(storeSort: sort));
await refreshStore();
await _refreshStore(current: _current);
}
void changeFilter(ExtensionListFilter filter) {
@@ -185,12 +203,11 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
}
bool canUpdateItem(ExtensionListItem item) {
final current = _current;
if (item.installed == null) return false;
if (current.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return current.updateFlags.containsKey(item.installed!.identity);
final installed = item.installed;
if (installed == null) return false;
// The backend checks GitHub live for every installed extension; the store
// index may lag behind and must not decide update visibility.
return _current.updateFlags.containsKey(installed.identity);
}
Future<void> installFromStore(StoreExtension extension) async {
@@ -240,6 +257,9 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
await _runBusy(extension.identity, () async {
await ref.read(gopeedServiceProvider).updateExtension(extension.identity);
await loadInstalled(refreshUpdates: false);
// Drop the stale flag immediately; the background re-check confirms it.
final flags = Map<String, String>.of(_current.updateFlags)..remove(extension.identity);
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
unawaited(checkUpdate());
_bumpStoreInstallCount(extension.identity);
_reportInstallSafe(extension.identity);
@@ -255,23 +275,43 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
});
}
Future<void> checkUpdate() async {
final flags = <String, String>{};
for (final ext in _current.installedExtensions) {
try {
final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) {
flags[ext.identity] = resp.newVersion;
Future<void> checkUpdate() {
if (!ref.mounted) return Future.value();
_checkUpdatesAgain = true;
// Installation/refresh can request a fresh scan while one is running.
// Coalesce those requests instead of launching overlapping Git operations.
return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null);
}
Future<void> _runUpdateChecks() async {
final service = ref.read(gopeedServiceProvider);
while (ref.mounted && _checkUpdatesAgain) {
_checkUpdatesAgain = false;
final revision = _installedRevision;
final installed = List<Extension>.of(_current.installedExtensions);
final flags = <String, String>{};
for (final ext in installed) {
if (!ref.mounted) return;
if (revision != _installedRevision) break;
try {
final resp = await service.upgradeCheckExtension(ext.identity);
if (!ref.mounted) return;
if (revision != _installedRevision) break;
if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion;
} catch (_) {
// A failed update check must never prevent loading the store.
}
} catch (_) {}
}
if (ref.mounted && revision == _installedRevision) {
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
}
}
state = AsyncValue.data(_current.copyWith(updateFlags: flags));
}
void tryOpenDevMode() {
if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () {
if (_current.devMode) return;
if (!ref.mounted || _current.devMode) return;
_devModeCount = 0;
});
}
@@ -284,26 +324,40 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
ExtensionsState get _current => state.value ?? const ExtensionsState();
Future<ExtensionsState> _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async {
final revision = ++_installedRevision;
state = AsyncValue.data(current.copyWith(loadingInstalled: true));
final installed = await ref.read(gopeedServiceProvider).getExtensions();
if (!ref.mounted) return current;
if (revision != _installedRevision) return _current;
state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false));
if (refreshUpdates) {
await checkUpdate();
unawaited(checkUpdate());
}
return _current;
}
Future<void> _refreshStore({required ExtensionsState current}) async {
state = AsyncValue.data(current.copyWith(loadingStore: true));
final page = await GopeedSiteApi.instance.getExtensions(
page: 1,
limit: 20,
sort: _current.storeSort,
query: _current.storeQuery,
);
state = AsyncValue.data(
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
);
Future<void> _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async {
final revision = ++_storeRevision;
state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false));
try {
final page = await ref
.read(gopeedSiteApiProvider)
.getExtensions(
page: 1,
limit: 20,
sort: current.storeSort,
query: current.storeQuery,
forceRefresh: forceRefresh,
);
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(
_current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false),
);
} catch (_) {
if (!ref.mounted || revision != _storeRevision) return;
state = AsyncValue.data(_current.copyWith(loadingStore: false));
rethrow;
}
}
Future<void> _runBusy(String id, Future<void> Function() action) async {
@@ -312,12 +366,15 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
try {
await action();
} finally {
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
if (ref.mounted) {
final busy = Set<String>.of(_current.busyExtensionIds)..remove(id);
state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy));
}
}
}
void _bumpStoreInstallCount(String id) {
if (!ref.mounted) return;
final current = _current;
final index = current.storeExtensions.indexWhere((e) => e.id == id);
if (index < 0) return;
@@ -342,36 +399,19 @@ class ExtensionsController extends AsyncNotifier<ExtensionsState> {
topics: ext.topics,
createdAt: ext.createdAt,
updatedAt: ext.updatedAt,
hasDetails: ext.hasDetails,
);
state = AsyncValue.data(current.copyWith(storeExtensions: updated));
}
void _reportInstallSafe(String id) {
if (!ref.mounted) return;
final siteApi = ref.read(gopeedSiteApiProvider);
unawaited(() async {
try {
await GopeedSiteApi.instance.reportExtensionInstall(id);
await siteApi.reportExtensionInstall(id);
} catch (_) {}
}());
}
static int _compareVersion(String a, String b) {
final aNums = _toVersionNumbers(a);
final bNums = _toVersionNumbers(b);
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
for (var i = 0; i < maxLen; i++) {
final left = i < aNums.length ? aNums[i] : 0;
final right = i < bNums.length ? bNums[i] : 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
static List<int> _toVersionNumbers(String version) {
return version
.split(RegExp(r'[^0-9]+'))
.where((part) => part.isNotEmpty)
.map((part) => int.tryParse(part) ?? 0)
.toList();
}
}
@@ -0,0 +1,25 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../api/gopeed_site_api.dart';
import '../../../api/model/store_extension.dart';
final gopeedSiteApiProvider = Provider<GopeedSiteApi>((ref) => GopeedSiteApi.instance);
typedef StoreExtensionDetailsKey = ({String id, String version});
// A shared future deduplicates drawer/page requests. Successful details survive
// closing the view, while failures can be retried and new versions get a new key.
final storeExtensionDetailsProvider = FutureProvider.autoDispose.family<StoreExtension, StoreExtensionDetailsKey>((
ref,
key,
) async {
final extension = await ref.watch(gopeedSiteApiProvider).getExtension(key.id, version: key.version);
if (ref.mounted) {
final keepAlive = ref.keepAlive();
final expiry = Timer(const Duration(minutes: 30), keepAlive.close);
ref.onDispose(expiry.cancel);
}
return extension;
});
@@ -8,6 +8,8 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../application/extensions_controller.dart';
import '../widgets/extension_detail_view.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
class ExtensionDetailsPage extends ConsumerWidget {
const ExtensionDetailsPage({super.key, required this.extensionId, this.initialItem});
@@ -24,6 +26,15 @@ class ExtensionDetailsPage extends ConsumerWidget {
return AppDetailPage(
title: item?.title ?? context.l10n.extensions,
onBack: () => context.canPop() ? context.pop() : context.go('/extensions'),
titleTrailing: _canUpdate(stateAsync.value, item)
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update-mobile'),
label: context.l10n.extensionCanUpdate,
onTap: item?.installed == null
? null
: () => showExtensionUpdateDialog(context, item!.installed!),
)
: null,
child: item == null
? Center(
child: stateAsync.isLoading
@@ -33,4 +44,9 @@ class ExtensionDetailsPage extends ConsumerWidget {
: ExtensionDetailView(key: ValueKey(item.id), item: item, mobile: true),
);
}
bool _canUpdate(ExtensionsState? state, ExtensionListItem? item) {
final installed = item?.installed;
return item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
}
}
@@ -28,8 +28,10 @@ import '../widgets/extension_detail_view.dart';
import '../widgets/extension_icon.dart';
import '../widgets/extension_setting_field.dart';
import '../widgets/extension_update_dialog.dart';
import '../widgets/extension_update_status.dart';
const _extensionCardMinWidth = 290.0;
// Keep three columns in the 1024px reference window with the wider primary rail.
const _extensionCardMinWidth = 280.0;
const _extensionGridSpacing = 10.0;
int _extensionGridColumnCount(double width) {
@@ -485,25 +487,7 @@ class _Content extends StatelessWidget {
}
bool _canUpdate(ExtensionsState state, ExtensionListItem item) {
if (item.installed == null) return false;
if (state.listFilter == ExtensionListFilter.market && item.store != null) {
return _compareVersion(item.store!.version, item.installed!.version) > 0;
}
return state.updateFlags.containsKey(item.installed!.identity);
}
int _compareVersion(String a, String b) {
final aNums = a.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final bNums = b.split(RegExp(r'[^0-9]+')).where((part) => part.isNotEmpty).map((part) => int.tryParse(part) ?? 0);
final left = aNums.toList();
final right = bNums.toList();
final maxLen = left.length > right.length ? left.length : right.length;
for (var i = 0; i < maxLen; i++) {
final l = i < left.length ? left[i] : 0;
final r = i < right.length ? right[i] : 0;
if (l != r) return l.compareTo(r);
}
return 0;
return item.installed != null && state.updateFlags.containsKey(item.installed!.identity);
}
}
@@ -994,11 +978,28 @@ class _ExtensionCard extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
Row(
children: [
Flexible(
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w700),
),
),
if (canUpdate && installed != null) ...[
const SizedBox(width: 4),
Flexible(
child: ExtensionUpdateStatus(
key: ValueKey('extension-card-update-status-${item.id}'),
label: context.l10n.extensionCanUpdate,
onTap: busy ? null : () => showExtensionUpdateDialog(context, installed),
compact: true,
),
),
],
],
),
const SizedBox(height: 2),
Text(
@@ -1067,13 +1068,6 @@ class _ExtensionCard extends ConsumerWidget {
key: ValueKey('extension-card-actions-${item.id}'),
mainAxisSize: MainAxisSize.min,
children: [
if (canUpdate && installed != null)
shad.GhostButton(
density: shad.ButtonDensity.icon,
key: ValueKey('update-extension-${installed.identity}'),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: const Icon(Icons.refresh),
),
if (installed == null && item.store != null)
shad.GhostButton(
key: ValueKey('install-store-extension-${item.store!.id}'),
@@ -1127,21 +1121,17 @@ class _ExtensionCard extends ConsumerWidget {
],
),
);
if (item.store == null) return card;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
key: ValueKey('open-extension-details-${item.id}'),
behavior: HitTestBehavior.opaque,
onTap: () {
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
return;
}
onOpenDetails(item);
},
child: card,
),
return GestureDetector(
key: ValueKey('open-extension-details-${item.id}'),
behavior: HitTestBehavior.opaque,
onTap: () {
if (MediaQuery.sizeOf(context).width < Breakpoints.mobile) {
context.push('/extensions/${Uri.encodeComponent(item.id)}', extra: item);
return;
}
onOpenDetails(item);
},
child: card,
);
}
}
@@ -19,22 +19,36 @@ import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/detail/app_detail_surface.dart';
import '../../../../util/util.dart';
import '../../application/extensions_controller.dart';
import '../../application/store_extension_details_provider.dart';
import 'extension_icon.dart';
import 'extension_update_dialog.dart';
import 'extension_update_status.dart';
class ExtensionDetailDrawer extends StatelessWidget {
class ExtensionDetailDrawer extends ConsumerWidget {
const ExtensionDetailDrawer({super.key, required this.item, required this.onClose});
final ExtensionListItem? item;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(extensionsControllerProvider).value;
final installed = item?.installed;
final canUpdate =
item != null && installed != null && (state?.updateFlags.containsKey(installed.identity) ?? false);
final updateChip = canUpdate
? ExtensionUpdateStatus(
key: const ValueKey('extension-details-update'),
label: context.l10n.extensionCanUpdate,
onTap: () => showExtensionUpdateDialog(context, installed),
)
: null;
return AppDetailDrawer(
open: item != null,
title: item?.title ?? '',
onClose: onClose,
drawerKey: const ValueKey('extension-details-drawer'),
titleTrailing: updateChip,
child: item == null ? const SizedBox.shrink() : ExtensionDetailView(item: item!),
);
}
@@ -50,7 +64,13 @@ class ExtensionDetailView extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final palette = AppPalette.of(context);
final state = ref.watch(extensionsControllerProvider).value;
final current = state?.findItem(item.id) ?? item;
final summary = state?.findItem(item.id) ?? item;
final summaryStore = summary.store;
final detailKey = summaryStore == null || summaryStore.hasDetails
? null
: (id: summaryStore.id, version: summaryStore.version);
final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey));
final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore);
final installed = current.installed;
final store = current.store;
final busy = state?.busyExtensionIds.contains(current.id) ?? false;
@@ -103,9 +123,7 @@ class ExtensionDetailView extends ConsumerWidget {
'v${current.version}',
style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600),
),
if (store != null) _ExtensionStats(store: store),
if (installed != null && canUpdate)
_ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true),
if (summaryStore != null) _ExtensionStats(store: summaryStore),
],
),
],
@@ -130,15 +148,6 @@ class ExtensionDetailView extends ConsumerWidget {
_runAction(context, () => ref.read(extensionsControllerProvider.notifier).installFromStore(store)),
child: Text(context.l10n.extensionInstall),
),
if (installed != null && canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-update'),
loading: false,
variant: AppLoadingButtonVariant.primary,
icon: const Icon(Icons.refresh, size: 16),
onPressed: busy ? null : () => showExtensionUpdateDialog(context, installed),
child: Text(context.l10n.newVersionUpdate),
),
if (installed != null && !canUpdate)
AppLoadingButton(
key: const ValueKey('extension-details-installed'),
@@ -162,6 +171,21 @@ class ExtensionDetailView extends ConsumerWidget {
),
],
),
if (details?.isLoading == true)
const Padding(
key: ValueKey('extension-details-loading'),
padding: EdgeInsets.only(top: 24),
child: Center(child: shad.CircularProgressIndicator()),
),
if (details?.hasError == true && detailKey != null)
Padding(
padding: const EdgeInsets.only(top: 24),
child: shad.SecondaryButton(
key: const ValueKey('extension-details-retry'),
onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)),
child: Text(context.l10n.retry),
),
),
if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)],
],
);
@@ -204,6 +228,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
final previous = oldWidget.item.installed;
final current = widget.item.installed;
if (previous?.identity != current?.identity ||
oldWidget.item.id != widget.item.id ||
previous?.devMode != current?.devMode ||
previous?.devPath != current?.devPath ||
oldWidget.item.store?.readme != widget.item.store?.readme) {
@@ -276,35 +301,6 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> {
}
}
class _ExtensionStatus extends StatelessWidget {
const _ExtensionStatus({required this.label, required this.emphasized});
final String label;
final bool emphasized;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final dotColor = emphasized ? palette.brand : palette.success;
final textColor = emphasized ? palette.textPrimary : palette.success;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 5,
height: 5,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(color: textColor, fontSize: 11.5, fontWeight: FontWeight.w600),
),
],
);
}
}
class _LinkButtonContent extends StatelessWidget {
const _LinkButtonContent({required this.icon, required this.label});
@@ -58,7 +58,7 @@ class _ExtensionUpdateDialogState extends ConsumerState<_ExtensionUpdateDialog>
canPop: !_updating,
child: AlertDialog(
key: const ValueKey('extension-update-dialog'),
title: Text(context.l10n.extensionCanUpdate),
title: Text(context.l10n.newVersionAvailable),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -0,0 +1,59 @@
import 'package:flutter/widgets.dart';
import '../../../../shared/theme/app_palette.dart';
/// Green status dot with an optional label, marking an available update.
/// Pass [onTap] to make it open the update prompt.
class ExtensionUpdateStatus extends StatelessWidget {
const ExtensionUpdateStatus({super.key, this.label, this.onTap, this.compact = false});
final String? label;
final VoidCallback? onTap;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final content = Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: compact ? 2 : 3),
decoration: BoxDecoration(
color: palette.brandSoft,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.brand.withValues(alpha: 0.35)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: palette.brand, shape: BoxShape.circle),
),
if (label != null) ...[
const SizedBox(width: 5),
Flexible(
child: Text(
label!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 11.5, fontWeight: FontWeight.w600),
),
),
],
],
),
);
if (onTap == null) return content;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 2, vertical: compact ? 2 : 4),
child: content,
),
),
);
}
}
@@ -2128,7 +2128,7 @@ class _BrowserExtensionLinks extends StatelessWidget {
children: [
const Icon(Icons.open_in_new, size: 15),
const SizedBox(width: AppDesignTokens.space8),
Text(link.label),
Flexible(child: Text(link.label, maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
),
@@ -66,6 +66,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
final _httpHeadersController = AppHttpHeadersController(defaultNames: const ['User-Agent', 'Cookie', 'Referer']);
final _trackersController = TextEditingController();
final _archivePasswordController = TextEditingController();
final _checksumHashController = TextEditingController();
final _formScrollController = ScrollController();
String _httpMethod = 'GET';
@@ -74,6 +75,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
Map<String, String>? _initialLabels;
RequestProxyMode _proxyMode = RequestProxyMode.follow;
String _proxyScheme = 'http';
String _checksumAlgorithm = 'sha256';
bool _skipVerifyCert = false;
bool? _autoTorrent;
bool? _deleteTorrentAfterDownload;
@@ -134,6 +136,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_httpHeadersController.dispose();
_trackersController.dispose();
_archivePasswordController.dispose();
_checksumHashController.dispose();
_formScrollController.dispose();
super.dispose();
}
@@ -243,6 +246,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_autoExtract = extra.autoExtract;
_archivePasswordController.text = extra.archivePassword;
_deleteAfterExtract = extra.deleteAfterExtract;
if (extra.checksum != null) {
if (extra.checksum!.algorithm.isNotEmpty) {
_checksumAlgorithm = extra.checksum!.algorithm;
}
_checksumHashController.text = extra.checksum!.expected;
}
}
}
});
@@ -668,6 +677,32 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
),
),
],
const SizedBox(height: 14),
AppFormPair(
direction: stacked ? Axis.vertical : Axis.horizontal,
firstLabel: 'Checksum Algorithm',
secondLabel: 'Checksum Hash',
firstFlex: 382,
secondFlex: 618,
first: AppChoiceSegmentedControl<String>(
key: const ValueKey('create-task-checksum-algorithm'),
showIcons: false,
value: _checksumAlgorithm,
buttonKeyPrefix: 'create-task-checksum-algorithm',
alignment: WrapAlignment.start,
options: const [
AppChoiceOption(value: 'md5', label: 'MD5', icon: Icons.tag),
AppChoiceOption(value: 'sha1', label: 'SHA-1', icon: Icons.tag),
AppChoiceOption(value: 'sha256', label: 'SHA-256', icon: Icons.tag),
],
onChanged: (value) => setState(() => _checksumAlgorithm = value),
),
second: _WindowTextField(
key: const ValueKey('create-task-checksum-expected'),
controller: _checksumHashController,
hintText: 'Expected hash (optional)',
),
),
] else ...[
AppFormRow(
direction: stacked ? Axis.vertical : Axis.horizontal,
@@ -1024,6 +1059,13 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
api_options.Options _buildOptions({String? name, List<int> selectFiles = const []}) {
final connections = int.tryParse(_connectionsController.text.trim()) ?? 0;
final checksumExpected = _checksumHashController.text.trim();
final checksum = checksumExpected.isNotEmpty
? api_options.ChecksumOption(
algorithm: _checksumAlgorithm,
expected: checksumExpected,
)
: null;
return api_options.Options(
name: name ?? _renameController.text.trim(),
path: _directoryController.text.trim(),
@@ -1036,6 +1078,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
autoExtract: _autoExtract,
archivePassword: _archivePasswordController.text,
deleteAfterExtract: _deleteAfterExtract,
checksum: checksum,
).toJson(),
);
}
@@ -0,0 +1,50 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter/widgets.dart' as flutter;
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/widgets/app_loading_button.dart';
class ResolveTaskActions extends StatelessWidget {
const ResolveTaskActions({super.key, required this.submitting, required this.onCancel, required this.onCreate});
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onCreate;
@override
Widget build(BuildContext context) {
// Size both actions from the wider translated label, including while the
// create button replaces its label with a loader.
return IntrinsicWidth(
child: flutter.Row(
mainAxisSize: MainAxisSize.min,
children: [
flutter.Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
child: SecondaryButton(
key: const ValueKey('resolve-cancel-button'),
onPressed: submitting ? null : onCancel,
child: Text(context.l10n.cancel, maxLines: 1, softWrap: false),
),
),
),
const SizedBox(width: AppDesignTokens.space8),
flutter.Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: AppDesignTokens.dialogActionMinWidth),
child: AppLoadingButton(
key: const ValueKey('resolve-create-button'),
onPressed: onCreate,
loading: submitting,
variant: AppLoadingButtonVariant.primary,
child: Text(context.l10n.createAction, maxLines: 1, softWrap: false),
),
),
),
],
),
);
}
}
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "إضافة",
"edit": "تعديل",
"newVersionTitle": "يتوفر إصدار جديد @version",
"newVersionUpdate": "تحديث الآن",
"newVersionUpdate": "تحديث",
"newVersionLater": "لاحقاً",
"extensions": "الإضافات",
"extensionInstallUrl": "عنوان التثبيت",
+1 -1
View File
@@ -106,7 +106,7 @@
"add": "Əlavə et",
"edit": "Redaktə et",
"newVersionTitle": "Yeni versiya @version tapıldı",
"newVersionUpdate": "İndi Yenilə",
"newVersionUpdate": "Yenilə",
"newVersionLater": "Sonra",
"extensions": "Uzantılar",
"extensionInstallUrl": "Quraşdırma URL-i",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Afegeix",
"edit": "Edita",
"newVersionTitle": "Descobriu la nova versió @version",
"newVersionUpdate": "Actualitza ara",
"newVersionUpdate": "Actualitza",
"newVersionLater": "Més tard",
"extensions": "Extensions",
"extensionInstallUrl": "URL d'instal·lació",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Hinzugefügt",
"edit": "Bearbeiten",
"newVersionTitle": "Entdecken Sie die neue Version @version",
"newVersionUpdate": "Jetzt aktualisieren",
"newVersionUpdate": "Aktualisieren",
"newVersionLater": "Später",
"extensions": "Erweiterungen",
"extensionInstallUrl": "Installations-URL",
+2 -2
View File
@@ -106,7 +106,7 @@
"add": "Add",
"edit": "Edit",
"newVersionTitle": "Discover new version @version",
"newVersionUpdate": "Update Now",
"newVersionUpdate": "Update",
"newVersionLater": "Later",
"extensions": "Extensions",
"extensionInstallUrl": "Install URL",
@@ -137,7 +137,7 @@
"extensionLoadMore": "Load More",
"extensionNoMore": "No more extensions",
"extensionInstalled": "Installed",
"extensionCanUpdate": "Update Available",
"extensionCanUpdate": "Update",
"extensionInstall": "Install",
"extensionLoadLocal": "Install Local Folder",
"extensionFilterAll": "All",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Añadir",
"edit": "Editar",
"newVersionTitle": "Nueva versión @version disponible",
"newVersionUpdate": "Actualizar Ahora",
"newVersionUpdate": "Actualizar",
"newVersionLater": "Más tarde",
"extensions": "Extensiones",
"extensionInstallUrl": "URL de instalación",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "افزودن",
"edit": "ویرایش",
"newVersionTitle": "@version عنوان: کشف نسخه جدید",
"newVersionUpdate": "بروزرسانی",
"newVersionUpdate": "به‌روزرسانی",
"newVersionLater": "بعداً",
"extensions": "افزونه‌ها",
"extensionInstallUrl": "نشانی نصب",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Ajouter",
"edit": "Modifier",
"newVersionTitle": "Nouvelle version @version disponible",
"newVersionUpdate": "Mettre à jour maintenant",
"newVersionUpdate": "Mettre à jour",
"newVersionLater": "Plus tard",
"extensions": "Extensions",
"extensionInstallUrl": "URL d'installation",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Hozzáad",
"edit": "Szerk",
"newVersionTitle": "Új verzió felfedezése @version",
"newVersionUpdate": "Frissítés most",
"newVersionUpdate": "Frissítés",
"newVersionLater": "Később",
"extensions": "Bővítmények",
"extensionInstallUrl": "Telepítési URL",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Tambah",
"edit": "Sunting",
"newVersionTitle": "Temukan versi batu @version",
"newVersionUpdate": "Perbarui Sekarang",
"newVersionUpdate": "Perbarui",
"newVersionLater": "Nanti",
"extensions": "Ekstensi",
"extensionInstallUrl": "URL Instal",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Aggiungi",
"edit": "Modifica",
"newVersionTitle": "Scopri la nuova versione @version",
"newVersionUpdate": "Aggiorna ora",
"newVersionUpdate": "Aggiorna",
"newVersionLater": "Dopo",
"extensions": "Estensioni",
"extensionInstallUrl": "Installa URL",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "追加",
"edit": "編集",
"newVersionTitle": "新しいバージョン @version を発見する",
"newVersionUpdate": "アップデート",
"newVersionUpdate": "更新",
"newVersionLater": "後で",
"extensions": "拡張機能",
"extensionInstallUrl": "インストールURL",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "추가",
"edit": "편집",
"newVersionTitle": "새 버전 @version 발견",
"newVersionUpdate": "지금 업데이트",
"newVersionUpdate": "업데이트",
"newVersionLater": "나중에",
"extensions": "확장 프로그램",
"extensionInstallUrl": "설치 URL",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Dodaj",
"edit": "Edycja",
"newVersionTitle": "Sprawdź aktualizację @version",
"newVersionUpdate": "Aktualizuj teraz",
"newVersionUpdate": "Aktualizuj",
"newVersionLater": "Później",
"extensions": "Wtyczki",
"extensionInstallUrl": "Instaluj z url",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Adicionar",
"edit": "Editar",
"newVersionTitle": "Nova Versão Disponível @version",
"newVersionUpdate": "Atualizar Agora",
"newVersionUpdate": "Atualizar",
"newVersionLater": "Mais Tarde",
"extensions": "Extensões",
"extensionInstallUrl": "Link de Instalação",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "சேர்",
"edit": "திருத்துக",
"newVersionTitle": "புதிய பதிப்பைக் கண்டறியவும் @version",
"newVersionUpdate": "இப்பொழுது புதுப்பி",
"newVersionUpdate": "புதுப்பிப்பு",
"newVersionLater": "பின்னர்",
"extensions": "நீட்டிப்புகள்",
"extensionInstallUrl": "நிறுவப்படும் URL",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Ekle",
"edit": "Düzenle",
"newVersionTitle": "Yeni sürüm @version keşfedildi",
"newVersionUpdate": "Şimdi Güncelle",
"newVersionUpdate": "Güncelle",
"newVersionLater": "Daha Sonra",
"extensions": "Eklentiler",
"extensionInstallUrl": "Kurulum URL'si",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Додати",
"edit": "Редагування",
"newVersionTitle": "Доступна нова версія @Version",
"newVersionUpdate": "Оновити зараз",
"newVersionUpdate": "Оновити",
"newVersionLater": "Пізніше",
"extensions": "Розширення",
"extensionInstallUrl": "URL для встановлення",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "Thêm",
"edit": "Sửa",
"newVersionTitle": "Khám phá phiên bản mới @version",
"newVersionUpdate": "Cập nhật ngay",
"newVersionUpdate": "Cập nhật",
"newVersionLater": "Sau",
"extensions": "Tiện ích mở rộng",
"extensionInstallUrl": "Liên kết cài đặt",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "添加",
"edit": "编辑",
"newVersionTitle": "发现新版本 @version",
"newVersionUpdate": "立即更新",
"newVersionUpdate": "更新",
"newVersionLater": "稍后再说",
"extensions": "扩展",
"extensionInstallUrl": "安装链接",
+1 -1
View File
@@ -99,7 +99,7 @@
"add": "新增",
"edit": "編輯",
"newVersionTitle": "發現新版本 @version",
"newVersionUpdate": "立即更新",
"newVersionUpdate": "更新",
"newVersionLater": "稍後",
"extensions": "擴充功能",
"extensionInstallUrl": "安裝 URL",
@@ -5,7 +5,8 @@ class AppDesignTokens {
static const double windowHeaderHeight = 30;
static const double windowRadius = 12;
static const double railWidth = 68;
// Leave room for native macOS traffic lights while keeping rail widths consistent across platforms.
static const double railWidth = 78;
static const double filterSidebarWidth = 192;
static const double contentHeaderHeight = 48;
static const double taskRowHeight = 72;
@@ -16,6 +16,7 @@ class AppDetailDrawer extends StatelessWidget {
required this.child,
this.drawerKey,
this.width,
this.titleTrailing,
});
final bool open;
@@ -24,6 +25,7 @@ class AppDetailDrawer extends StatelessWidget {
final Widget child;
final Key? drawerKey;
final double? width;
final Widget? titleTrailing;
@override
Widget build(BuildContext context) {
@@ -82,15 +84,25 @@ class AppDetailDrawer extends StatelessWidget {
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w700,
),
child: Row(
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: palette.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
Flexible(child: titleTrailing!),
],
],
),
),
const SizedBox(width: 12),
@@ -13,12 +13,14 @@ class AppDetailPage extends StatelessWidget {
required this.onBack,
required this.child,
this.backgroundColor,
this.titleTrailing,
});
final String title;
final VoidCallback onBack;
final Widget child;
final Color? backgroundColor;
final Widget? titleTrailing;
@override
Widget build(BuildContext context) {
@@ -53,12 +55,22 @@ class AppDetailPage extends StatelessWidget {
),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: palette.textPrimary, fontSize: 17, fontWeight: FontWeight.w700),
),
),
if (titleTrailing != null) ...[
const SizedBox(width: 8),
Flexible(child: titleTrailing!),
],
],
),
),
const SizedBox(width: 44),
@@ -0,0 +1,78 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
void main() {
test('summary requests are shared and cached; explicit refresh requests fresh data', () async {
final pending = Completer<Response<String>>();
final transport = _Transport()..onProxy = (_) => pending.future;
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
final first = site.getExtensions(query: 'cache-test', forceRefresh: true);
final second = site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary');
pending.complete(
_response({
'data': [
{'id': 'test'},
],
}),
);
expect((await first).data.single.hasDetails, isFalse);
await second;
await site.getExtensions(query: 'cache-test');
expect(transport.urls.length, 1);
await site.getExtensions(query: 'cache-test', forceRefresh: true);
expect(transport.urls.length, 2);
});
test('failed lists are not cached', () async {
final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline'));
api.setTransportForTesting(transport);
final site = GopeedSiteApi.instance;
await expectLater(site.getExtensions(query: 'error-test'), throwsStateError);
transport.onProxy = (_) async => _response({
'data': [
{'id': 'recovered'},
],
});
expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered');
expect(transport.urls.length, 2);
});
test('detail IDs are encoded once and a null README is still loaded detail', () async {
final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null});
api.setTransportForTesting(transport);
final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3');
final uri = Uri.parse(transport.urls.single);
expect(uri.pathSegments, ['api', 'extensions', '开发者@name']);
expect(uri.queryParameters['version'], '1.2.3');
expect(result.hasDetails, isTrue);
});
}
Response<String> _response(Map<String, dynamic> json) => Response(
requestOptions: RequestOptions(path: '/test'),
data: jsonEncode(json),
statusCode: 200,
);
class _Transport implements GopeedTransport {
final urls = <String>[];
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
urls.add(uri);
return onProxy(uri);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -9,9 +9,9 @@ void main() {
test('create-task child window has a localized native title', () {
expect(
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
'Gopeed-Create Task',
'Gopeed - Create Task',
);
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务');
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed - 创建任务');
});
test('create-task payload only carries initial task data', () {
@@ -0,0 +1,234 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/api/model/extension.dart';
import 'package:gopeed/api/model/store_extension.dart';
import 'package:gopeed/api/model/update_check_extension_resp.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('store loads while installed data and update checks are still pending', () async {
final installed = Completer<List<Extension>>();
final update = Completer<UpdateCheckExtensionResp>();
var updateCalls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => installed.future)
..bind(GopeedMethods.checkExtensionUpdate, (_) {
updateCalls++;
return update.future;
});
final site = _SiteApi();
final container = _container(registry, site);
final subscription = container.listen(extensionsControllerProvider, (_, _) {});
addTearDown(subscription.close);
await _settle();
expect(site.listCalls, 1);
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
expect(container.read(extensionsControllerProvider).value?.loadingStore, isFalse);
expect(updateCalls, 0);
installed.complete([_installed()]);
await _settle();
expect(updateCalls, 1);
expect(container.read(extensionsControllerProvider).value?.loadingInstalled, isFalse);
expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market');
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
await _settle();
expect(container.read(extensionsControllerProvider).value?.updateFlags, {'gopeed@test': '2.0.0'});
});
test('a stale update scan cannot restore flags after installed data changes', () async {
var installed = [_installed()];
final update = Completer<UpdateCheckExtensionResp>();
var updateCalls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => installed)
..bind(GopeedMethods.checkExtensionUpdate, (_) {
updateCalls++;
return update.future;
});
final container = _container(registry, _SiteApi());
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
final running = controller.checkUpdate();
expect(updateCalls, 1);
installed = [];
await controller.loadInstalled();
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
await running;
expect(container.read(extensionsControllerProvider).value?.installedExtensions, isEmpty);
expect(container.read(extensionsControllerProvider).value?.updateFlags, isEmpty);
});
test('an update scan can finish after the controller is disposed', () async {
final update = Completer<UpdateCheckExtensionResp>();
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => [_installed()])
..bind(GopeedMethods.checkExtensionUpdate, (_) => update.future);
final container = _container(registry, _SiteApi());
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
final running = controller.checkUpdate();
container.dispose();
update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0'));
await running;
});
test('old search results cannot replace a newer search', () async {
final old = Completer<StoreExtensionPage>();
final latest = Completer<StoreExtensionPage>();
final site = _SiteApi()
..onList = (query) => switch (query) {
'old' => old.future,
'new' => latest.future,
_ => Future.value(_page('market')),
};
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
final container = _container(registry, site);
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
final first = controller.searchStore('old');
final second = controller.searchStore('new');
latest.complete(_page('new-result'));
await second;
old.complete(_page('old-result'));
await first;
final state = container.read(extensionsControllerProvider).value!;
expect(state.storeQuery, 'new');
expect(state.storeExtensions.single.id, 'new-result');
expect(state.loadingStore, isFalse);
});
test('overlapping cached pages do not duplicate an extension', () async {
final site = _SiteApi();
site.onList = (_) async => StoreExtensionPage.fromJson({
'data': [
{'id': 'market', 'version': '1.0.0'},
if (site.listCalls > 1) {'id': 'second', 'version': '1.0.0'},
],
'pagination': {'page': site.listCalls, 'limit': 20, 'hasNext': site.listCalls == 1},
});
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
final container = _container(registry, site);
final controller = container.read(extensionsControllerProvider.notifier);
await _settle();
await controller.loadMoreStore();
expect(container.read(extensionsControllerProvider).value?.storeExtensions.map((e) => e.id), ['market', 'second']);
});
test('detail requests are shared, cache empty READMEs, and distinguish versions', () async {
final detail = Completer<StoreExtension>();
final site = _SiteApi()..onDetail = (_, _) => detail.future;
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
addTearDown(container.dispose);
const key = (id: 'market', version: '1.0.0');
final provider = storeExtensionDetailsProvider(key);
final subscription = container.listen(provider, (_, _) {});
final first = container.read(provider.future);
final second = container.read(provider.future);
expect(site.detailCalls, 1);
detail.complete(StoreExtension.fromJson({'id': 'market', 'version': '1.0.0', 'readme': null}));
expect((await first).hasDetails, isTrue);
await second;
subscription.close();
await _settle();
await container.read(provider.future);
expect(site.detailCalls, 1);
await container.read(storeExtensionDetailsProvider((id: 'market', version: '2.0.0')).future);
expect(site.detailCalls, 2);
});
test('failed details can be retried', () async {
final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline'));
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
addTearDown(container.dispose);
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
final subscription = container.listen(provider, (_, _) {});
addTearDown(subscription.close);
await expectLater(container.read(provider.future), throwsStateError);
site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'});
container.invalidate(provider);
expect((await container.read(provider.future)).readme, 'loaded');
expect(site.detailCalls, 2);
});
}
ProviderContainer _container(CapabilityRegistry registry, _SiteApi site) {
final container = ProviderContainer(
overrides: [
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
gopeedSiteApiProvider.overrideWithValue(site),
],
);
addTearDown(container.dispose);
return container;
}
Future<void> _settle() async {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(Duration.zero);
}
}
StoreExtensionPage _page(String id) => StoreExtensionPage.fromJson({
'data': [
{'id': id, 'version': '1.0.0'},
],
'pagination': {'page': 1, 'limit': 20, 'total': 1, 'totalPages': 1},
});
Extension _installed() => Extension(
identity: 'gopeed@test',
name: 'test',
author: 'gopeed',
title: 'Test',
description: '',
icon: '',
version: '1.0.0',
homepage: '',
repository: null,
disabled: false,
devMode: false,
devPath: '',
);
class _SiteApi implements GopeedSiteApi {
var listCalls = 0;
var detailCalls = 0;
Future<StoreExtensionPage> Function(String?) onList = (_) async => _page('market');
Future<StoreExtension> Function(String, String?) onDetail = (id, _) async =>
StoreExtension.fromJson({'id': id, 'readme': null});
@override
Future<StoreExtensionPage> getExtensions({
int page = 1,
int limit = 20,
StoreExtensionSort sort = StoreExtensionSort.stars,
StoreSortOrder order = StoreSortOrder.desc,
String? query,
bool forceRefresh = false,
}) {
listCalls++;
return onList(query);
}
@override
Future<StoreExtension> getExtension(String id, {String? version}) {
detailCalls++;
return onDetail(id, version);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/gopeed_site_api.dart';
import 'package:gopeed/api/model/store_extension.dart';
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart';
import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 1024.0]) {
testWidgets('summary details load and can retry at width $width', (tester) async {
tester.view.physicalSize = Size(width, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final pending = Completer<StoreExtension>();
final site = _DetailApi()..request = pending.future;
await tester.pumpWidget(
ProviderScope(
overrides: [
extensionsControllerProvider.overrideWith(_SummaryController.new),
gopeedSiteApiProvider.overrideWithValue(site),
],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: shad.Scaffold(
child: ExtensionDetailView(
item: ExtensionListItem(store: _summary()),
mobile: width < 600,
),
),
),
),
);
await tester.pump();
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
pending.completeError(StateError('offline'));
await tester.pump();
await tester.pump();
expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget);
site.request = Future.value(
StoreExtension.fromJson({
'id': 'market',
'title': 'Market',
'version': '1.0.0',
'readme': '# Loaded details',
'homepage': 'https://example.com',
}),
);
await tester.tap(find.byKey(const ValueKey('extension-details-retry')));
await tester.pumpAndSettle();
expect(find.text('Loaded details'), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget);
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
}
}
StoreExtension _summary() => StoreExtension.fromJson({
'id': 'market',
'title': 'Market',
'version': '1.0.0',
'repoUrl': 'https://github.com/test/repo',
});
class _SummaryController extends ExtensionsController {
@override
Future<ExtensionsState> build() async => ExtensionsState(storeExtensions: [_summary()]);
}
class _DetailApi implements GopeedSiteApi {
late Future<StoreExtension> request;
@override
Future<StoreExtension> getExtension(String id, {String? version}) => request;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+1 -1
View File
@@ -194,7 +194,7 @@ void main() {
expect(find.text('Faster downloads'), findsOneWidget);
expect(find.text('下载更快'), findsNothing);
expect(find.text('Later'), findsOneWidget);
expect(find.text('Update Now'), findsOneWidget);
expect(find.text('Update'), findsOneWidget);
expect(find.text("What's New"), findsNothing);
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
expect(versionText.style?.decoration, TextDecoration.none);
+36 -8
View File
@@ -50,6 +50,7 @@ import 'package:gopeed/features/home/presentation/widgets/tasks_top_bar.dart';
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
import 'package:gopeed/features/extensions/presentation/pages/extension_details_page.dart';
import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart';
import 'package:gopeed/features/extensions/presentation/widgets/extension_icon.dart';
import 'package:gopeed/features/home/presentation/widgets/primary_rail.dart';
import 'package:gopeed/features/tasks/application/pending_update_task.dart';
import 'package:gopeed/features/tasks/application/task_batch_selection_controller.dart';
@@ -629,9 +630,9 @@ void main() {
tester.view.physicalSize = const Size(1024, 768);
await tester.pumpAndSettle();
expect(gridColumns(), 3);
expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(290));
expect(tester.getSize(find.byKey(const ValueKey('extension-card-extension-0'))).width, greaterThanOrEqualTo(280));
tester.view.physicalSize = const Size(1028, 608);
tester.view.physicalSize = const Size(1024, 608);
await tester.pumpAndSettle();
expect(gridColumns(), 3);
expect(tester.getSize(find.byKey(const ValueKey('extension-search-field-container'))).width, 240);
@@ -643,6 +644,7 @@ void main() {
tester.view.physicalSize = const Size(720, 608);
await tester.pumpAndSettle();
expect(find.byType(PrimaryRail), findsOneWidget);
expect(gridColumns(), 2);
final compactSearchRect = tester.getRect(find.byKey(const ValueKey('extension-search-field-container')));
expect(compactSearchRect.width, lessThan(240));
expect(compactSearchRect.height, closeTo(sortRect.height, 0.01));
@@ -664,7 +666,7 @@ void main() {
});
testWidgets('extension cards compact large counts without crowding their actions', (WidgetTester tester) async {
await _setTestSize(tester, const Size(722, 608));
await _setTestSize(tester, const Size(720, 608));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(CrowdedExtensionCardController.new)],
@@ -682,12 +684,36 @@ void main() {
final cardRect = tester.getRect(find.byKey(const ValueKey('extension-card-crowded-extension')));
final statsRect = tester.getRect(find.byKey(const ValueKey('extension-card-stats-crowded-extension')));
final actionsRect = tester.getRect(find.byKey(const ValueKey('extension-card-actions-crowded-extension')));
expect(cardRect.width, closeTo(290, 0.01));
expect(cardRect.width, closeTo(284, 0.01));
expect(statsRect.right, lessThanOrEqualTo(actionsRect.left - 8));
expect(actionsRect.right, lessThanOrEqualTo(cardRect.right - 14));
expect(tester.takeException(), isNull);
});
for (final width in [320.0, 1100.0]) {
testWidgets('extension update status keeps the card header at icon height at $width', (tester) async {
await _setTestSize(tester, Size(width, 900));
await tester.pumpWidget(
ProviderScope(
overrides: [extensionsControllerProvider.overrideWith(UpdateableExtensionsController.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const ExtensionsPage(),
),
),
);
await tester.pumpAndSettle();
final card = find.byKey(const ValueKey('extension-card-extension-0'));
final icon = find.descendant(of: card, matching: find.byType(ExtensionIcon));
final description = find.descendant(of: card, matching: find.text('Installed extension'));
expect(find.byKey(const ValueKey('extension-card-update-status-extension-0')), findsOneWidget);
expect(tester.getRect(description).top - tester.getRect(icon).bottom, closeTo(10, 0.01));
expect(tester.takeException(), isNull);
});
}
testWidgets('extension details reuse the desktop detail drawer', (WidgetTester tester) async {
await _setTestSize(tester, const Size(1100, 900));
final controller = FakeExtensionsController();
@@ -817,7 +843,9 @@ void main() {
await tester.tap(find.byKey(const ValueKey('extension-card-extension-0')));
await tester.pumpAndSettle();
}
final entry = find.byKey(ValueKey(details ? 'extension-details-update' : 'update-extension-extension-0'));
final entry = find.byKey(
ValueKey(details ? 'extension-details-update' : 'extension-card-update-status-extension-0'),
);
final dialog = find.byKey(const ValueKey('extension-update-dialog'));
final confirm = find.byKey(const ValueKey('confirm-update-extension-button'));
final cancel = find.byKey(const ValueKey('cancel-update-extension-button'));
@@ -2327,7 +2355,7 @@ void main() {
final progress = find.descendant(of: updateButton, matching: find.byType(shad.CircularProgressIndicator));
expect(progress, findsOneWidget);
expect(tester.getSize(progress), const Size.square(14));
expect(find.descendant(of: updateButton, matching: find.text('Update Now')), findsOneWidget);
expect(find.descendant(of: updateButton, matching: find.text('Update')), findsOneWidget);
runtimeController.completeTrackerUpdate();
await tester.pumpAndSettle();
@@ -2649,8 +2677,8 @@ void main() {
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
await tester.pumpAndSettle();
expect(find.text('English'), findsOneWidget);
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
expect(SettingsLanguageSelect.supportedValues, hasLength(23));
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['az', 'zh', 'zh_TW', 'pt']));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
expect(supportedLocaleFromConfig('zh_CN'), isNull);