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 && (
+
-
- )}
-
+
+ )}
+
+ )}
diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx
index 9bb7d16..6c5e131 100644
--- a/src/components/PropertiesWindowApp.tsx
+++ b/src/components/PropertiesWindowApp.tsx
@@ -1389,14 +1389,6 @@ export const PropertiesWindowApp = () => {
}
{activeTab === 'options' && isTorrent &&
-
-
-
{t($ => $.properties.tabs.options)}
-
{t($ => $.properties.torrentPeerOptionsSavedHint)}
-
-
{t($ => $.properties.blankUsesDefault)}
-
-
diff --git a/src/components/TorrentWebSeedEditor.tsx b/src/components/TorrentWebSeedEditor.tsx
index 65aa681..849ee9d 100644
--- a/src/components/TorrentWebSeedEditor.tsx
+++ b/src/components/TorrentWebSeedEditor.tsx
@@ -19,13 +19,18 @@ const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.pat
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
const { t } = useTranslation();
const uriRefs = useRef
>([]);
+ const addButtonRef = useRef(null);
const focusAfterRemoveRef = useRef(null);
const filesForValidation = files.map(file => ({ index: file.index }));
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
useEffect(() => {
const rowIndex = focusAfterRemoveRef.current;
focusAfterRemoveRef.current = null;
- if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
+ if (rowIndex === -1) {
+ addButtonRef.current?.focus();
+ } else if (rowIndex !== null) {
+ uriRefs.current[rowIndex]?.focus();
+ }
}, [rows]);
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
const updateRow = (rowIndex: number, update: Partial) => onChange(
@@ -33,7 +38,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
);
const removeRow = (rowIndex: number) => {
const nextRows = rows.filter((_, index) => index !== rowIndex);
- focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
+ focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : -1;
onChange(nextRows);
};
@@ -57,6 +62,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
value={files[0].index}
onChange={() => undefined}
disabled
+ dir="ltr"
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
>
@@ -68,6 +74,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
value={row.fileIndex ?? ''}
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
disabled={disabled || files.length === 0}
+ dir="ltr"
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
>
@@ -91,6 +98,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
value={row.uri}
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
disabled={disabled}
+ dir="ltr"
aria-invalid={!rowIsValid}
placeholder="https://mirror.example/torrent/"
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
@@ -115,6 +123,7 @@ export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false,
)}