diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6709bb2..479c5e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,6 +124,7 @@ jobs: APP="src-tauri/target/${{ matrix.target }}/release/bundle/macos/Firelink.app" DMG="$(find src-tauri/target/${{ matrix.target }}/release/bundle/dmg -name '*.dmg' -print -quit)" test -n "$DMG" + FIRELINK_MACOS_APP="$APP" node scripts/app-associations.node-test.js npm run verify:macos-signing -- --app "$APP" --dmg "$DMG" node scripts/verify-binaries.js --search-root "$APP" --target ${{ matrix.target }} ARIA2="$(find "$APP" -type f -name 'aria2c-${{ matrix.target }}' -print -quit)" diff --git a/scripts/app-associations.node-test.js b/scripts/app-associations.node-test.js index 4ff84ad..be07a6c 100644 --- a/scripts/app-associations.node-test.js +++ b/scripts/app-associations.node-test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; @@ -29,3 +30,41 @@ test('declares the native macOS BitTorrent content type', () => { test('declares magnet as a desktop deep-link scheme', () => { assert.deepEqual(tauriConfig.plugins['deep-link'].desktop.schemes, ['firelink', 'magnet']); }); + +const packagedAppPath = process.env.FIRELINK_MACOS_APP; + +if (packagedAppPath) { + test('packaged macOS app exports the Torrent UTI and magnet URL scheme', () => { + assert.equal(process.platform, 'darwin', 'packaged macOS association checks require macOS'); + const infoPlistPath = path.join(packagedAppPath, 'Contents', 'Info.plist'); + assert.ok(fs.existsSync(infoPlistPath), `missing packaged Info.plist: ${infoPlistPath}`); + + const plist = JSON.parse(execFileSync('plutil', ['-convert', 'json', '-o', '-', infoPlistPath], { + encoding: 'utf8' + })); + const urlTypes = Array.isArray(plist.CFBundleURLTypes) ? plist.CFBundleURLTypes : []; + const schemes = urlTypes.flatMap(entry => ( + entry && typeof entry === 'object' && Array.isArray(entry.CFBundleURLSchemes) + ? entry.CFBundleURLSchemes.filter(scheme => typeof scheme === 'string') + : [] + )); + assert.ok(schemes.includes('firelink'), 'packaged app must retain the Firelink deep-link scheme'); + assert.ok(schemes.includes('magnet'), 'packaged app must export the magnet URL scheme'); + + const documentTypes = Array.isArray(plist.CFBundleDocumentTypes) ? plist.CFBundleDocumentTypes : []; + const torrentDocument = documentTypes.find(entry => + entry + && typeof entry === 'object' + && Array.isArray(entry.CFBundleTypeExtensions) + && entry.CFBundleTypeExtensions.some(extension => + typeof extension === 'string' && extension.toLowerCase() === 'torrent' + ) + ); + assert.ok(torrentDocument, 'packaged app must claim the .torrent extension'); + assert.ok( + Array.isArray(torrentDocument.LSItemContentTypes) + && torrentDocument.LSItemContentTypes.includes('org.bittorrent.torrent'), + 'packaged app must claim the standard BitTorrent UTI' + ); + }); +} diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 199f495..57eb161 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -974,6 +974,24 @@ export const AddDownloadsModal = () => { : root; }; + const revealTorrentAdvanced = (preferredIndex?: number) => { + setAdvancedExpanded(true); + setSelectedItemIndex(current => { + const preferredIsSelectedTorrent = preferredIndex !== undefined + && parsedItems[preferredIndex]?.selected !== false + && parsedItems[preferredIndex]?.isTorrent; + if (preferredIsSelectedTorrent) return preferredIndex; + + const currentIsSelectedTorrent = current !== null + && parsedItems[current]?.selected !== false + && parsedItems[current]?.isTorrent; + if (currentIsSelectedTorrent) return current; + + const firstSelectedTorrentIndex = parsedItems.findIndex(item => item.selected !== false && item.isTorrent); + return firstSelectedTorrentIndex >= 0 ? firstSelectedTorrentIndex : current; + }); + }; + const handleAction = async (action: AddDownloadAction) => { if (isSubmitting || isSubmittingRef.current || !canSubmitMetadataRows(parsedItems)) { return; @@ -1000,41 +1018,50 @@ export const AddDownloadsModal = () => { && torrentMaxPeers.trim() && (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000) ) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && !isValidTorrentTrackerList(torrentTrackers)) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentTrackersInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && !isValidTorrentExcludeTrackerList(torrentExcludeTrackers)) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true }); return; } if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true }); return; } - for (const item of selectedItems) { - if (!item.isTorrent || !item.torrentFiles?.length) continue; + for (const [itemIndex, item] of parsedItems.entries()) { + if (item.selected === false || !item.isTorrent || !item.torrentFiles?.length) continue; const rows = item.torrentWebSeedRows ?? []; if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) { + revealTorrentAdvanced(itemIndex); addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true }); return; } @@ -1044,6 +1071,7 @@ export const AddDownloadsModal = () => { && torrentStopTimeout.trim() && (!Number.isInteger(Number(torrentStopTimeout)) || Number(torrentStopTimeout) < 0 || Number(torrentStopTimeout) > MAX_TORRENT_STOP_TIMEOUT) ) { + revealTorrentAdvanced(); addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true }); return; } @@ -1711,6 +1739,7 @@ export const AddDownloadsModal = () => { return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length); }; const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex]; + const selectedItemIsTorrent = selectedItem?.isTorrent === true; const hasSftpRows = parsedItems.some(item => item.selected !== false && !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')); @@ -1846,6 +1875,30 @@ export const AddDownloadsModal = () => { item => item.metadataBlockedReason === 'unsafe-url' ).length; const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount; + const readyMetadataCount = selectedItems.filter(item => item.status === 'ready').length; + const hasCustomTorrentOptions = Boolean( + torrentMaxPeers.trim() + || torrentPeerSpeedLimit.trim() + || torrentCheckIntegrity + || torrentRemoveUnselectedFile + || torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED + || torrentTrackers.trim() + || torrentExcludeTrackers.trim() + || torrentTrackerConnectTimeout.trim() + || torrentTrackerTimeout.trim() + || (torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) !== 0) + || (torrentStopTimeout.trim() && Number(torrentStopTimeout) !== 0) + || torrentFileAllocation !== 'prealloc' + || torrentPreviewHeadEnabled + || torrentPreviewTailEnabled + || parsedItems.some(item => item.selected !== false && item.isTorrent && (item.torrentWebSeedRows?.length ?? 0) > 0) + ); + const localizedSelectedSummary = t($ => $.addDownloads.selectedSummary, { + ready: readyMetadataCount, + fallback: fallbackMetadataCount, + mediaRetry: failedMediaMetadataCount, + blocked: blockedMetadataCount, + }); const activePlaylistUrls = new Set( urls.split('\n').map(url => url.trim()).filter(Boolean).map(normalizeComparableUrl) ); @@ -2001,15 +2054,33 @@ export const AddDownloadsModal = () => {

); })} -
- - {t($ => $.addDownloads.selectedSummary, { - ready: selectedItems.filter(item => item.status === 'ready').length, - fallback: fallbackMetadataCount, - mediaRetry: failedMediaMetadataCount, - blocked: blockedMetadataCount, - })} - +
+
+ {localizedSelectedSummary} + + {readyMetadataCount} + {t($ => $.addDownloads.selectedSummaryReady)} + + + {fallbackMetadataCount} + {t($ => $.addDownloads.selectedSummaryFallback)} + + + {failedMediaMetadataCount} + {t($ => $.addDownloads.selectedSummaryMediaRetry)} + + + {blockedMetadataCount} + {t($ => $.addDownloads.selectedSummaryBlocked)} + +
+
+
@@ -2158,24 +2230,6 @@ export const AddDownloadsModal = () => { )} - {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && ( -
-
- {t($ => $.properties.torrentWebSeeds)} -
-

{t($ => $.properties.torrentWebSeedsHint)}

- setParsedItems(items => items.map((item, index) => index === selectedItemIndex - ? { ...item, torrentWebSeedRows: rows } - : item - ))} - idPrefix="add-torrent-web-seed" - /> -
- )} - {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
@@ -2202,6 +2256,7 @@ export const AddDownloadsModal = () => { step={1} value={torrentSeedTime} onChange={event => setTorrentSeedTime(event.target.value)} + dir="ltr" className="app-control w-20 px-2 py-1 text-end font-mono" /> {t($ => $.addDownloads.minutes)} @@ -2214,6 +2269,7 @@ export const AddDownloadsModal = () => { step={0.1} value={torrentSeedRatio} onChange={event => setTorrentSeedRatio(event.target.value)} + dir="ltr" className="app-control w-20 px-2 py-1 text-end font-mono" aria-describedby="torrent-seed-ratio-hint" /> @@ -2239,12 +2295,45 @@ export const AddDownloadsModal = () => { step={128} value={torrentUploadLimit} onChange={event => setTorrentUploadLimit(event.target.value)} + dir="ltr" className="app-control w-24 px-2 py-1 text-end font-mono" aria-label={t($ => $.addDownloads.torrentUploadLimit)} /> KiB/s
) : null} + + {advancedExpanded && ( +
+
+
{t($ => $.properties.torrentWebSeeds)}
+

{t($ => $.properties.torrentWebSeedsHint)}

+ setParsedItems(items => items.map((item, index) => index === selectedItemIndex + ? { ...item, torrentWebSeedRows: rows } + : item + ))} + idPrefix="add-torrent-web-seed" + /> +
+
+ )} )} @@ -2836,18 +2935,26 @@ export const AddDownloadsModal = () => { {/* Advanced */} -
- + {(!selectedItemIsTorrent || advancedExpanded) && ( +
+ {selectedItemIsTorrent ? ( +
+ {t($ => $.addDownloads.advancedTransfer)} +
+ ) : ( + + )} - {advancedExpanded && ( -
+ {advancedExpanded && ( +