Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andmonkeyWie 0bd306773d Replace redundant chunked tests with targeted non-range pause/resume test
Remove TestFetcher_DownloadChunkedContinue (redundant with existing tests).
Replace TestFetcher_DownloadChunkedNotExceedSize with
TestFetcher_DownloadNoRangePauseResume which specifically targets the bug:
- Documents the root cause (non-range resume writes at accumulated offset
  instead of byte 0)
- Asserts downloaded file size does not exceed source file size (the exact
  bug symptom from the issue)
- Verifies file integrity via MD5

Co-authored-by: monkeyWie <[email protected]>
2026-03-12 01:22:06 +00:00
copilot-swe-agent[bot]andmonkeyWie 8b02844ec3 Fix tests: use StartTestCustomServer (no Content-Length) to match real bug scenario
Remove StartTestNoRangeServer and its tests since the real bug is about
servers that don't return Content-Length at all (like GitHub's zip downloads),
not servers with Content-Length but no Range support.

The chunked tests (TestFetcher_DownloadChunkedContinue and
TestFetcher_DownloadChunkedNotExceedSize) correctly simulate the real-world
bug using StartTestCustomServer which serves files without Content-Length.

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 10:53:52 +00:00
copilot-swe-agent[bot]andmonkeyWie b8e517b35b Fix data race in expandConnections: remove orphaned waitForCompletion goroutine
The go f.waitForCompletion() goroutine in expandConnections was redundant
since the downloadLoop's slow start loop already handles stateSteady by
calling waitForCompletion directly. The orphaned goroutine could race with
doStart() when writing f.ctx during pause/resume.

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 02:59:41 +00:00
copilot-swe-agent[bot]andmonkeyWie 2daa9c45df Fix non-range download bug: downloaded bytes exceeding total file size
When HTTP server doesn't return Content-Length or doesn't support Range
requests, the download can accumulate extra bytes on pause/resume because:
1. Each retry/resume re-downloads from the beginning but writes at the
   accumulated offset
2. No size limit enforcement for non-range downloads
3. onDownloadComplete incorrectly uses chunk.remain() for non-range
   downloads, which gives negative values for chunk(0,0)

Fixes:
- Reset chunk state in downloadChunkOnce and runConnectionFallback for
  non-range downloads since server will send from beginning
- Add size limit checks in download loops when Size is known
- Fix onDownloadComplete to use conn.Completed for non-range downloads
  instead of unreliable chunk.remain()
- Add test cases for non-range download, pause/resume, and size limit

Co-authored-by: monkeyWie <[email protected]>
2026-03-11 02:50:48 +00:00
copilot-swe-agent[bot] 27c0f81a1e Initial plan 2026-03-11 02:30:03 +00:00
Jaime Muñoz Martín 1907f404ac feat(lang): Catalan language added (#1303) 2026-03-11 10:26:55 +08:00
Tuna Dinçer 72c5ba7d20 feat(lang): update Turkish translation (#1304) 2026-03-10 13:38:02 +08:00
5 changed files with 386 additions and 12 deletions

No files matched your search

+89 -11
View File
@@ -872,9 +872,9 @@ func (f *Fetcher) expandConnections() {
f.connMu.Unlock()
if len(newConns) == 0 {
// No new connections could be created, stop expansion
// No new connections could be created, stop expansion.
// The downloadLoop will check stateSteady and call waitForCompletion.
f.setState(stateSteady)
go f.waitForCompletion()
return
}
@@ -987,6 +987,14 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
f.connMu.Unlock()
return nil
}
// For non-range downloads, reset chunk state since server will send from beginning.
// Without Range support, each new request restarts the entire transfer.
if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
conn.Downloaded = 0
}
rangeStart := conn.Chunk.Begin + conn.Chunk.Downloaded
rangeEnd := conn.Chunk.End
f.connMu.Unlock()
@@ -1080,6 +1088,17 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
n = int(remain)
finished = true
}
} else if f.meta.Res.Size > 0 {
// For non-range downloads with known size, enforce size limit
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
f.connMu.Unlock()
return nil
}
if remaining < int64(n) {
n = int(remaining)
finished = true
}
}
writeOffset = conn.Chunk.Begin + conn.Chunk.Downloaded
f.connMu.Unlock()
@@ -1174,6 +1193,22 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
n, err := reader.Read(buf)
if n > 0 {
// For non-range downloads with known size, enforce size limit
if !f.meta.Res.Range && f.meta.Res.Size > 0 {
f.connMu.Lock()
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
if remaining < int64(n) {
n = int(remaining)
}
f.connMu.Unlock()
}
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)
@@ -1191,8 +1226,18 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
}
f.fileMu.Unlock()
f.connMu.Lock()
conn.Chunk.Downloaded += int64(n)
conn.Downloaded += int64(n)
// Check if size limit reached after updating counters
if !f.meta.Res.Range && f.meta.Res.Size > 0 && conn.Downloaded >= f.meta.Res.Size {
conn.Completed = true
conn.State = connCompleted
f.connMu.Unlock()
return
}
f.connMu.Unlock()
}
if err != nil {
if err == io.EOF {
@@ -1232,6 +1277,14 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
f.connMu.Lock()
conn.State = connConnecting
// For non-range downloads, reset chunk state since server will send from beginning.
// Without Range support, each new request restarts the entire transfer.
if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
conn.Downloaded = 0
}
f.connMu.Unlock()
err := func() error {
@@ -1276,6 +1329,17 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
n, err := reader.Read(buf)
if n > 0 {
// For non-range downloads with known size, enforce size limit
if !f.meta.Res.Range && f.meta.Res.Size > 0 {
remaining := f.meta.Res.Size - conn.Downloaded
if remaining <= 0 {
return nil
}
if remaining < int64(n) {
n = int(remaining)
}
}
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], conn.Chunk.Downloaded)
@@ -1475,17 +1539,31 @@ func (f *Fetcher) onDownloadComplete() {
// Check if all chunks are complete (no remaining bytes)
allChunksComplete := true
for _, conn := range f.connections {
if conn.Chunk != nil && conn.Chunk.remain() > 0 && !conn.Completed && conn.State != connCompleted {
// This connection has remaining work and isn't done
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
// 403 is server connection limit, check if other connections will complete this chunk
continue
if !conn.Completed && conn.State != connCompleted {
if f.meta.Res.Range {
// For range downloads, use chunk remain() to check completion
if conn.Chunk != nil && conn.Chunk.remain() > 0 {
// This connection has remaining work and isn't done
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
}
allChunksComplete = false
break
}
} else {
// For non-range downloads, completion is determined solely by EOF (conn.Completed).
// chunk.remain() is unreliable for non-range chunks (Begin=0, End=0).
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
continue
}
}
allChunksComplete = false
break
}
allChunksComplete = false
break
}
}
+52
View File
@@ -307,6 +307,58 @@ func TestFetcher_DownloadChunked(t *testing.T) {
downloadNormal(listener, 2, t)
}
// TestFetcher_DownloadNoRangePauseResume targets a specific bug where pausing
// and resuming a download from a server that doesn't return Content-Length
// (and doesn't support Range) causes the downloaded file to grow beyond the
// actual file size. Root cause: on resume the server resends from byte 0, but
// the old code wrote at the previously accumulated offset, appending a second
// copy instead of overwriting. The fix resets the write offset on each non-range
// retry so data is always written from the beginning.
func TestFetcher_DownloadNoRangePauseResume(t *testing.T) {
listener := test.StartTestCustomServer()
defer listener.Close()
fetcher := downloadReady(listener, 1, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
}
// Let it download some data
time.Sleep(time.Millisecond * 50)
if err := fetcher.Pause(); err != nil {
t.Fatal(err)
}
time.Sleep(time.Millisecond * 50)
if err := fetcher.Start(); err != nil {
t.Fatal(err)
}
err = fetcher.Wait()
if err != nil {
t.Fatal(err)
}
// The critical assertion: the downloaded file must not be larger than
// the source file. Before the fix, each resume appended extra data,
// causing the file to exceed the actual size (the bug shown in the issue).
downloadInfo, err := os.Stat(test.DownloadFile)
if err != nil {
t.Fatal(err)
}
sourceInfo, err := os.Stat(test.BuildFile)
if err != nil {
t.Fatal(err)
}
if downloadInfo.Size() > sourceInfo.Size() {
t.Errorf("Downloaded file size %d exceeds source file size %d", downloadInfo.Size(), sourceInfo.Size())
}
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
if want != got {
t.Errorf("Download() got = %v, want %v", got, want)
}
}
func TestFetcher_DownloadPost(t *testing.T) {
listener := test.StartTestPostServer()
defer listener.Close()
+221
View File
@@ -0,0 +1,221 @@
const caES = {
'ca_ES': {
'label': 'Català',
'error': 'Error',
'tip': 'Consell',
'confirm': 'Confirma',
'confirmDelete': 'Esteu segur que voleu suprimir-lo?',
'cancel': 'Cancel·la',
'on': 'Activat',
'off': 'Desactivat',
'selectAll': 'Selecciona-ho tot',
'select': 'Selecciona',
'task': 'Tasques',
'downloading': 's\'està baixant',
'downloaded': 's\'ha baixat',
'setting': 'Configuració',
'donate': 'Donació',
'exit': 'Surt',
'create': 'Crea una tasca',
'directDownload': 'Baixada directa',
'advancedOptions': 'Opcions avançades',
'followSettings': 'Segueix la configuració',
'downloadLink': 'Enllaç de baixada',
'downloadLinkValid': 'Introduïu l\'enllaç de baixada',
'downloadLinkHit': 'Introduïu l\'enllaç de baixada, un per línia@append',
'downloadLinkHitDesktop': 'o arrossegueu el fitxer torrent aquí',
'download': 'Baixa',
'noFileSelected': 'Seleccioneu almenys un fitxer per continuar.',
'noStoragePermission': 'Cal permís d\'emmagatzematge',
'selectFile': 'Seleccioneu el fitxer',
'rename': 'Canvia el nom',
'basic': 'Bàsic',
'advanced': 'Avançat',
'general': 'General',
'downloadDir': 'Carpeta de baixades',
'downloadDirValid': 'Seleccioneu la carpeta de baixades',
'connections': 'Connexions',
'useServerCtime': 'Utilitza l\'hora del servidor per a la creació de fitxers',
'maxRunning': 'Màxim de tasques en execució',
'defaultDirectDownload': 'Activa la baixada directa per defecte',
'autoStartTasks': 'Inicia automàticament les tasques incompletes a l\'inici',
'autoTorrentEnable': 'Crea automàticament tasques BT a partir de fitxers .torrent',
'autoTorrentDeleteAfterDownload':
'Suprimeix el fitxer .torrent després de crear la tasca BT',
'autoDeleteMissingFileTasks': 'Suprimeix automàticament les tasques dels fitxers que falten',
'items': '@count elements',
'subscribeTracker': 'Subscripció al rastrejador',
'subscribeFail':
'La subscripció ha fallat. Comproveu la xarxa o torneu-ho a provar més tard',
'update': 'Actualitza',
'updateDaily': 'Actualització diària',
'lastUpdate': 'Última actualització: @time',
'addTracker': 'Afegeix un rastrejador',
'addTrackerHit': 'Introduïu la URL del servidor del rastrejador, un per línia',
'ui': 'UI',
'theme': 'Tema',
'themeSystem': 'Sistema',
'themeLight': 'Clar',
'themeDark': 'Fosc',
'locale': 'Idioma',
'notifyWhenNewVersion': 'Notificació d\'actualitzacions',
'analyticsEnabled': 'Puja les estadístiques',
'analyticsEnabledDesc': 'Compartiu dades d\'ús anònimes per ajudar-nos a millorar',
'about': 'Quant a',
'homepage': 'Pàgina d\'inici',
'version': 'Versió',
'protocol': 'Protocol',
'port': 'Port',
'apiToken': 'Token de l\'API',
'notSet': 'Sense congigurar',
'set': 'Configurat',
'portInUse': 'El port [@port] està en ús, canvieu-lo',
'effectAfterRestart': 'Els efectes s\'aplicaran després de reiniciar',
'developer': 'Desenvolupador',
'logDirectory': 'Carpeta dels registres',
'webhook': 'Webhook',
'webhookEnable': 'Activa el Webhook',
'webhookDesc': 'Envia notificacions HTTP POST quan les tasques finalitzin o fallin',
'webhookUrlHint': 'Introduïu la URL del webhook',
'webhookTest': 'Prova',
'webhookTestSuccess': 'La prova del webhook ha estat correcta',
'webhookTestFail': 'La prova del webhook ha fallat',
'script': 'Seqüència d\'ordres',
'scriptEnable': 'Activa la seqüència d\'ordres',
'scriptDesc': 'Executa seqüències d\'ordres personalitzades quan les baixades finalitzin correctament',
'scriptPathHint': 'Introduïu la ruta del fitxer de seqüències d\'ordres (p.ex., /ruta/al/fitxer.sh)',
'urlInvalid': 'Introduïu una URL HTTP o HTTPS vàlida',
'required': 'Aquest camp és obligatori',
'show': 'Mostra',
'continue': 'Contina',
'pause': 'Pausa',
'startAll': 'Inicia-ho tot',
'pauseAll': 'Pausa-ho tot',
'deleteTask': 'Suprimeix @count tasques',
'deleteTaskTip': 'Mantén els fitxers baixats',
'delete': 'Suprimeix',
'add': 'Afegeix',
'edit': 'Edita',
'newVersionTitle': 'Descobriu la nova versió @version',
'newVersionUpdate': 'Actualitza ara',
'newVersionLater': 'Més tard',
'extensions': 'Extensions',
'extensionInstallUrl': 'URL d\'instal·lació',
'extensionInstallSuccess': 'S\'ha instal·lat amb èxit',
'extensionUpdateSuccess': 'S\'ha actualitzat amb èxit',
'extensionDelete': 'Suprimeix l\'extensió',
'extensionAlreadyLatest': 'Ja està actualitzada',
'extensionFind': 'Cerca extensions',
'extensionDevelop': 'Desenvolupa extensions',
'extensionStoreTitle': 'Botiga d\'extensions',
'extensionStoreSection': 'Extensions de la botiga',
'extensionInstalledSection': 'Extensions instal·lades',
'extensionInstalledEmpty': 'Encara no s\'ha instal·lat cap extensió',
'extensionStoreEmpty': 'No s\'han trobat extensions coincidents',
'extensionSearchHint': 'Cerca per nom, títol, descripció o autor',
'extensionSortStars': 'Més estrelles',
'extensionSortInstalls': 'Més instal·lacions',
'extensionSortUpdated': 'Actualitzada recentment',
'extensionSortToggle': 'Commuta la direcció d\'ordenació',
'extensionLoadMore': 'Carrega\'n més',
'extensionNoMore': 'No hi ha més extensions',
'extensionInstalled': 'Instal·lada',
'extensionCanUpdate': 'Actualització disponible',
'extensionInstall': 'Instal·la',
'extensionLoadLocal': 'Instal·la de d\'una carpeta local',
'extensionFilterAll': 'Totes',
'extensionFilterMarket': 'Mercat',
'extensionFilterInstalled': 'Instal·lada',
'extensionManualInstall': 'Instal·lació manual',
'extensionInstallTools': 'Opcions d\'instal·lació',
'history': 'Historial',
'clearHistory': 'Neteja l\'historial',
'noHistoryFound': 'No s\'ha trobat cap historial',
'serviceTitle': 'Servei de baixades',
'serviceText': 'S\'està executant',
'network': 'Xarxa',
'proxy': 'Servidor intermediari',
'noProxy': 'Sense servidor intermediari',
'systemProxy': 'Servidor intermediari del sistema',
'customProxy': 'Servidor intermediari personalitzat',
'server': 'Servidor',
'username': 'Nom d\'usuari',
'password': 'Contrasenya',
'thanks': 'Gràcies',
'thanksDesc':
'Gràcies a tots els col·laboradors que han ajudat a construir i desenvolupar la comunitat del Gopeed!',
'browserExtension': 'Extensió del navegador',
'launchAtStartup': 'Executa a l\'inici',
'runAsMenubarApp': 'Executa com a aplicació de barra de menú',
'runAsMenubarAppDesc': 'Amaga la icona de l\'acoblador i executa només a la barra de menú',
'seedConfig': 'Configuració de les llavors',
'seedKeep': 'Continua sembrant fins que s\'aturi manualment',
'seedRatio': 'Proporció de les llavors',
'seedTime': 'Temps de la llavor (minuts)',
'setAsDefaultBtClient': 'Estableix com a client BT per defecte',
'taskDetail': 'Detall de la tasca',
'taskName': 'Nom de la tasca',
'taskUrl': 'URL de la tasca',
'downloadPath': 'Ruta de la baixada',
'skipVerifyCert': 'Omet la verificació del certificat',
'archives': 'Comprimits',
'autoExtract': 'Extreu els comprimits automàticament',
'archivePassword': 'Contrasenya del comprimit',
'archivePasswordHint': 'Deixeu-lo en blanc si no està protegit amb contrasenya',
'deleteAfterExtract': 'Suprimeix el comprimit després de l\'extracció',
'extracting': 'S\'està extraient',
'extractDone': 'Extracció completada',
'extractError': 'Extracció fallida',
'waitingParts': 'S\'està esperant a les parts',
'name': 'Nom',
'size': 'Mida',
'unknown': 'Desconegut',
'fileSelectedCount': 'Fitxers: ',
'fileSelectedSize': 'Mida: ',
'httpHeader': 'Capçalera',
'httpHeaderName': 'Nomm de la capçalera',
'httpHeaderValue': 'Valor de la capçalera',
'login': 'Inici de sessió',
'username_required': 'Introduïu el vostre nom d\'usuari',
'password_required': 'Introduïu la vostra contrasenya',
'login_success': 'Inici de sessió correcte',
'login_failed': 'No s\'ha pogut iniciar la sessió, comproveu el vostre nom d\'usuari i contrasenya',
'login_failed_network':
'No s\'ha pogut iniciar la sessió, comproveu la vostra connexió de xarxa',
'insertPlaceholder': 'Inseriu un marcador de posició',
'placeholderYear': 'Any actual',
'placeholderMonth': 'Mes actual (01-12)',
'placeholderDay': 'Dia actual (01-31)',
'placeholderDate': 'Data completa (AAAA-MM-DD)',
'example': 'p.ex. @value',
'downloadCategories': 'Categories de baixades',
'categoryMusic': 'Música',
'categoryVideo': 'Vídeo',
'categoryDocument': 'Document',
'categoryProgram': 'Programa',
'categoryName': 'Nom de la categoria',
'categoryPath': 'Ruta de la categoria',
'builtInCategory': 'La categoria integrada no es pot suprimir',
'selectCategory': 'Seleccioneu una categoria',
'githubMirror': 'Rèplica del GitHub',
'githubMirrorEnable': 'Activa la rèplica del GitHub',
'githubMirrorDesc': 'Utilitzeu rèpliques per accelerar les baixades de contingut del GitHub',
'githubMirrorType': 'Tipus de rèplica',
'githubMirrorUrl': 'URL de la rèplica',
'githubMirrorUrlHint': 'Introduïu la URL de la rèplica',
'updateUrl': 'Actualitza la URL',
'updateUrlManual': 'Actualització manual',
'updateUrlListen': 'Escolta l\'actualització',
'updateUrlListeningTip': 'S\'està esperant l\'actualització de l\'URL',
'updateUrlCancelListen': 'Cancel·la l\'escolta',
'updateUrlDialogHint': 'Introduïu una nova URL de baixada',
'pendingUpdateFound': 'S\'ha trobat una tasca d\'actualització pendent',
'pendingUpdateConfirm':
'La tasca "@name" està esperant l\'actualització de la URL. Voleu actualitzar-la amb la URL nova?',
'pendingUpdateYes': 'Actualitza la tasca',
'pendingUpdateNo': 'Crea una tasca nova',
'desktopNotification': 'Notificacions d\'escriptori',
'notificationTaskDone': 'Tasca completada',
'notificationTaskError': 'Error de tasca',
},
};
+21
View File
@@ -107,6 +107,27 @@ const trTR = {
'extensionAlreadyLatest': 'Zaten en son sürüm',
'extensionFind': 'Eklenti Bul',
'extensionDevelop': 'Eklenti Geliştir',
'extensionStoreTitle': 'Eklenti Mağazası',
'extensionStoreSection': 'Mağaza Eklentileri',
'extensionInstalledSection': 'Yüklü Eklentiler',
'extensionInstalledEmpty': 'Henüz yüklü eklenti yok',
'extensionStoreEmpty': 'Eşleşen eklenti bulunamadı',
'extensionSearchHint': 'Ada, başlığa, açıklamaya veya yazara göre ara',
'extensionSortStars': 'En Çok Yıldız Alanlar',
'extensionSortInstalls': 'En Çok Yüklenenler',
'extensionSortUpdated': 'Son Güncellenenler',
'extensionSortToggle': 'Sıralama yönünü değiştir',
'extensionLoadMore': 'Daha Fazla Yükle',
'extensionNoMore': 'Daha fazla eklenti yok',
'extensionInstalled': 'Yüklü',
'extensionCanUpdate': 'Güncelleme Mevcut',
'extensionInstall': 'Yükle',
'extensionLoadLocal': 'Yerel Klasörden Yükle',
'extensionFilterAll': 'Tümü',
'extensionFilterMarket': 'Market',
'extensionFilterInstalled': 'Yüklü',
'extensionManualInstall': 'Manuel Yükleme',
'extensionInstallTools': 'Yükleme Seçenekleri',
'history': 'Geçmiş',
'clearHistory': 'Geçmişi Temizle',
'noHistoryFound': 'Geçmiş Bulunamadı',
+3 -1
View File
@@ -18,6 +18,7 @@ import 'langs/es_es.dart';
import 'langs/uk_ua.dart';
import 'langs/hu_hu.dart';
import 'langs/pt_br.dart';
import 'langs/ca_es.dart';
final messages = _Messages();
@@ -42,6 +43,7 @@ class _Messages extends Translations {
...ukUA,
...huHU,
...ptBR,
...deDE
...deDE,
...caES
};
}