feat: add customizable download sources with automatic fallback
- support built-in and user-defined GitHub mirror sources - add a modal for managing custom HTTPS mirror prefixes - automatically switch sources when version checks fail - persist the working source and sync the selector in real time - update translations, configuration migration, and source tests
This commit is contained in:
1 parent
03be4acf93
commit
6d7da20e4e
15 files changed
+820
-134
No files matched your search
+13
-1
@@ -973,11 +973,23 @@ export const jaOverrides = {
|
||||
'kernel.versions.noBundled': 'このリリースには組み込みコアが含まれていません',
|
||||
'kernel.versions.offlineInstall': 'オフラインインストール',
|
||||
'kernel.versions.downloadSource': 'ダウンロード元を切り替える',
|
||||
'kernel.versions.downloadSourceHint': 'ソフトウェアとコアの更新確認・ダウンロードでは選択した経路を優先し、失敗時は他の利用可能なソースに切り替えます。ミラープロキシは第三者が提供します。',
|
||||
'kernel.versions.downloadSourceHint': '組み込み経路を選択するか、カスタム HTTPS ミラープレフィックスを追加できます。確認失敗時は自動的に切り替えます。ミラープロキシは第三者が提供します。',
|
||||
'kernel.versions.source.github': 'GitHub 公式',
|
||||
'kernel.versions.source.gitcode': 'GitCode 中国向けミラー',
|
||||
'kernel.versions.source.ghProxy': 'GitHub ミラー · gh-proxy.com',
|
||||
'kernel.versions.source.ghFast': 'GitHub ミラー · ghfast.top',
|
||||
'kernel.versions.source.custom': 'カスタムミラー',
|
||||
'kernel.versions.customMirrorPlaceholder': 'ミラープレフィックス(例: https://mirror.example.com/)',
|
||||
'kernel.versions.customMirrorAdd': 'カスタムソースを追加',
|
||||
'kernel.versions.customMirrorDialogTitle': 'カスタムダウンロード元を追加',
|
||||
'kernel.versions.customMirrorDialogDescription': 'GitHub ミラープロキシの HTTPS プレフィックスを入力してください。ダウンロード時に元の GitHub URL を末尾へ追加します。',
|
||||
'kernel.versions.customMirrorConfirm': '追加して切り替え',
|
||||
'kernel.versions.customMirrorAdded': 'カスタムミラーを追加して切り替えました',
|
||||
'kernel.versions.customMirrorAddFailed': 'ミラーの追加に失敗しました: {error}',
|
||||
'kernel.versions.customMirrorRemove': 'カスタムソースを削除',
|
||||
'kernel.versions.customMirrorSaved': '追加済みのカスタムソース',
|
||||
'kernel.versions.customMirrorRemoved': 'カスタムミラーを削除しました',
|
||||
'kernel.versions.customMirrorRemoveFailed': 'ミラーの削除に失敗しました: {error}',
|
||||
'kernel.versions.gitcodeUnavailable': 'このビルドには完全な GitCode ミラー設定が含まれていません。',
|
||||
'kernel.versions.gitcodeSaveFailed': 'ダウンロード元の切り替えに失敗しました: {error}',
|
||||
'kernel.access.firstKey': '最初のキー',
|
||||
|
||||
+13
-1
@@ -973,11 +973,23 @@ export const en: Record<MessageKey, string> = {
|
||||
'kernel.versions.noBundled': 'This release does not include a bundled core',
|
||||
'kernel.versions.offlineInstall': 'Offline Install',
|
||||
'kernel.versions.downloadSource': 'Switch Download Source',
|
||||
'kernel.versions.downloadSourceHint': 'Software and core update checks and downloads prefer the selected channel, then fall back to other available sources. Mirror proxies are third-party services.',
|
||||
'kernel.versions.downloadSourceHint': 'Choose a built-in channel or add a custom HTTPS mirror prefix. Failed checks automatically fall back. Mirror proxies are third-party services.',
|
||||
'kernel.versions.source.github': 'Official GitHub',
|
||||
'kernel.versions.source.gitcode': 'GitCode China Mirror',
|
||||
'kernel.versions.source.ghProxy': 'GitHub Mirror · gh-proxy.com',
|
||||
'kernel.versions.source.ghFast': 'GitHub Mirror · ghfast.top',
|
||||
'kernel.versions.source.custom': 'Custom Mirror',
|
||||
'kernel.versions.customMirrorPlaceholder': 'Mirror prefix, e.g. https://mirror.example.com/',
|
||||
'kernel.versions.customMirrorAdd': 'Add Custom Source',
|
||||
'kernel.versions.customMirrorDialogTitle': 'Add Custom Download Source',
|
||||
'kernel.versions.customMirrorDialogDescription': 'Enter the HTTPS prefix of a GitHub mirror proxy. The original GitHub URL is appended when downloading.',
|
||||
'kernel.versions.customMirrorConfirm': 'Add and Switch',
|
||||
'kernel.versions.customMirrorAdded': 'Custom mirror added and selected',
|
||||
'kernel.versions.customMirrorAddFailed': 'Failed to add mirror: {error}',
|
||||
'kernel.versions.customMirrorRemove': 'Remove custom source',
|
||||
'kernel.versions.customMirrorSaved': 'Added custom sources',
|
||||
'kernel.versions.customMirrorRemoved': 'Custom mirror removed',
|
||||
'kernel.versions.customMirrorRemoveFailed': 'Failed to remove mirror: {error}',
|
||||
'kernel.versions.gitcodeUnavailable': 'This build does not include complete GitCode mirror configuration.',
|
||||
'kernel.versions.gitcodeSaveFailed': 'Failed to switch download source: {error}',
|
||||
'kernel.access.firstKey': 'First key',
|
||||
|
||||
@@ -971,11 +971,23 @@ export const zhCN = {
|
||||
'kernel.versions.noBundled': '当前发行包未包含内置内核',
|
||||
'kernel.versions.offlineInstall': '离线安装',
|
||||
'kernel.versions.downloadSource': '切换下载源',
|
||||
'kernel.versions.downloadSourceHint': '软件和内核的版本检查及下载将优先使用所选渠道,失败时自动回退到其他可用源。镜像代理由第三方提供。',
|
||||
'kernel.versions.downloadSourceHint': '可选择内置渠道或添加自定义 HTTPS 镜像前缀;检测失败时会自动回退。镜像代理由第三方提供。',
|
||||
'kernel.versions.source.github': 'GitHub 官方源',
|
||||
'kernel.versions.source.gitcode': 'GitCode 国内源',
|
||||
'kernel.versions.source.ghProxy': 'GitHub 镜像 · gh-proxy.com',
|
||||
'kernel.versions.source.ghFast': 'GitHub 镜像 · ghfast.top',
|
||||
'kernel.versions.source.custom': '自定义镜像',
|
||||
'kernel.versions.customMirrorPlaceholder': '镜像前缀,例如 https://mirror.example.com/',
|
||||
'kernel.versions.customMirrorAdd': '添加自定义源',
|
||||
'kernel.versions.customMirrorDialogTitle': '添加自定义下载源',
|
||||
'kernel.versions.customMirrorDialogDescription': '填写 GitHub 镜像代理的 HTTPS 前缀。下载时会在该地址后拼接原始 GitHub URL。',
|
||||
'kernel.versions.customMirrorConfirm': '添加并切换',
|
||||
'kernel.versions.customMirrorAdded': '已添加并切换到自定义镜像',
|
||||
'kernel.versions.customMirrorAddFailed': '添加镜像失败:{error}',
|
||||
'kernel.versions.customMirrorRemove': '删除自定义源',
|
||||
'kernel.versions.customMirrorSaved': '已添加的自定义源',
|
||||
'kernel.versions.customMirrorRemoved': '已删除自定义镜像',
|
||||
'kernel.versions.customMirrorRemoveFailed': '删除镜像失败:{error}',
|
||||
'kernel.versions.gitcodeUnavailable': '当前构建未配置完整的 GitCode 镜像源。',
|
||||
'kernel.versions.gitcodeSaveFailed': '切换下载源失败:{error}',
|
||||
'kernel.access.firstKey': '第一个密钥',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Info,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useCoreRuntime } from '../coreRuntime';
|
||||
import { useI18n } from '../i18n';
|
||||
@@ -41,18 +42,27 @@ export type CoreInstallTask = {
|
||||
export type VersionSourceSettings = {
|
||||
source: VersionDownloadSource;
|
||||
gitcodeAvailable: boolean;
|
||||
customMirrors: string[];
|
||||
};
|
||||
|
||||
export type VersionDownloadSource = 'github' | 'gitcode' | 'gh-proxy' | 'gh-fast';
|
||||
export type VersionDownloadSource = string;
|
||||
|
||||
function downloadSourceLabel(source: VersionDownloadSource, t: ReturnType<typeof useI18n>['t']) {
|
||||
const keys = {
|
||||
const keys: Record<string, Parameters<typeof t>[0]> = {
|
||||
github: 'kernel.versions.source.github',
|
||||
gitcode: 'kernel.versions.source.gitcode',
|
||||
'gh-proxy': 'kernel.versions.source.ghProxy',
|
||||
'gh-fast': 'kernel.versions.source.ghFast',
|
||||
} as const;
|
||||
return t(keys[source]);
|
||||
};
|
||||
if (source.startsWith('custom:')) {
|
||||
const url = source.slice('custom:'.length);
|
||||
try {
|
||||
return `${t('kernel.versions.source.custom')} · ${new URL(url).host}`;
|
||||
} catch {
|
||||
return t('kernel.versions.source.custom');
|
||||
}
|
||||
}
|
||||
return t(keys[source] ?? 'kernel.versions.source.github');
|
||||
}
|
||||
|
||||
export type MessageType = 'info' | 'success' | 'error';
|
||||
@@ -131,6 +141,8 @@ export function VersionManagementPage() {
|
||||
const [versionSource, setVersionSource] = useState<VersionSourceSettings | null>(null);
|
||||
const [versionSourceSaving, setVersionSourceSaving] = useState(false);
|
||||
const [versionSourceError, setVersionSourceError] = useState('');
|
||||
const [customMirrorDraft, setCustomMirrorDraft] = useState('');
|
||||
const [customMirrorDialogOpen, setCustomMirrorDialogOpen] = useState(false);
|
||||
|
||||
const [toastNotice, setToastNotice] = useState<{
|
||||
message: string;
|
||||
@@ -138,6 +150,7 @@ export function VersionManagementPage() {
|
||||
} | null>(null);
|
||||
|
||||
const installDialogRef = useRef<HTMLDivElement>(null);
|
||||
const customMirrorInputRef = useRef<HTMLInputElement>(null);
|
||||
const latestCheckEpochRef = useRef(0);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
|
||||
@@ -242,6 +255,52 @@ export function VersionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const addCustomMirror = async () => {
|
||||
const url = customMirrorDraft.trim();
|
||||
if (!url) return;
|
||||
setVersionSourceSaving(true);
|
||||
setVersionSourceError('');
|
||||
try {
|
||||
const settings = await invoke<VersionSourceSettings>('add_custom_download_mirror', { url });
|
||||
setVersionSource(settings);
|
||||
setCustomMirrorDraft('');
|
||||
setCustomMirrorDialogOpen(false);
|
||||
cachedLatest = null;
|
||||
cachedLatestError = '';
|
||||
setLatest(null);
|
||||
showToast(t('kernel.versions.customMirrorAdded'), 'success');
|
||||
await Promise.allSettled([checkAppUpdate(), checkLatest(true)]);
|
||||
} catch (error) {
|
||||
const message = t('kernel.versions.customMirrorAddFailed', { error: String(error) });
|
||||
setVersionSourceError(message);
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setVersionSourceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCustomMirror = async (url: string) => {
|
||||
const wasSelected = versionSource?.source === `custom:${url}`;
|
||||
setVersionSourceSaving(true);
|
||||
setVersionSourceError('');
|
||||
try {
|
||||
const settings = await invoke<VersionSourceSettings>('remove_custom_download_mirror', {
|
||||
url,
|
||||
});
|
||||
setVersionSource(settings);
|
||||
showToast(t('kernel.versions.customMirrorRemoved'), 'success');
|
||||
if (wasSelected) {
|
||||
await Promise.allSettled([checkAppUpdate(), checkLatest(true)]);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = t('kernel.versions.customMirrorRemoveFailed', { error: String(error) });
|
||||
setVersionSourceError(message);
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setVersionSourceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const installVersion = async (version: string) => {
|
||||
setInstalling(true);
|
||||
setCancellingInstall(false);
|
||||
@@ -403,6 +462,25 @@ export function VersionManagementPage() {
|
||||
};
|
||||
}, [installDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!customMirrorDialogOpen) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.setTimeout(() => customMirrorInputRef.current?.focus(), 0);
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !versionSourceSaving) {
|
||||
setCustomMirrorDialogOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [customMirrorDialogOpen, versionSourceSaving]);
|
||||
|
||||
// Derived state calculations
|
||||
const latestVersion = latest?.version ?? '';
|
||||
const currentVersion = coreStatus?.currentVersion ?? '';
|
||||
@@ -531,8 +609,24 @@ export function VersionManagementPage() {
|
||||
</option>
|
||||
<option value="gh-proxy">{t('kernel.versions.source.ghProxy')}</option>
|
||||
<option value="gh-fast">{t('kernel.versions.source.ghFast')}</option>
|
||||
{versionSource?.customMirrors.map((url) => (
|
||||
<option key={url} value={`custom:${url}`}>
|
||||
{downloadSourceLabel(`custom:${url}`, t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="version-source-add-button"
|
||||
disabled={versionSourceSaving || appUpdateTask.running || installing}
|
||||
onClick={() => {
|
||||
setVersionSourceError('');
|
||||
setCustomMirrorDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<span>{t('kernel.versions.customMirrorAdd')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -680,6 +774,81 @@ export function VersionManagementPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{customMirrorDialogOpen ? (
|
||||
<div className="install-dialog-backdrop custom-mirror-dialog-backdrop">
|
||||
<form
|
||||
className="install-dialog app-update-dialog custom-mirror-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="custom-mirror-dialog-title"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void addCustomMirror();
|
||||
}}
|
||||
>
|
||||
<div className="install-dialog-heading">
|
||||
<span>{t('kernel.versions.downloadSource')}</span>
|
||||
<h2 id="custom-mirror-dialog-title">{t('kernel.versions.customMirrorDialogTitle')}</h2>
|
||||
</div>
|
||||
<p className="custom-mirror-dialog-description">
|
||||
{t('kernel.versions.customMirrorDialogDescription')}
|
||||
</p>
|
||||
<input
|
||||
ref={customMirrorInputRef}
|
||||
type="url"
|
||||
value={customMirrorDraft}
|
||||
disabled={versionSourceSaving}
|
||||
placeholder={t('kernel.versions.customMirrorPlaceholder')}
|
||||
aria-label={t('kernel.versions.customMirrorPlaceholder')}
|
||||
onChange={(event) => setCustomMirrorDraft(event.currentTarget.value)}
|
||||
/>
|
||||
{versionSourceError ? (
|
||||
<span className="custom-mirror-dialog-error" role="alert">{versionSourceError}</span>
|
||||
) : null}
|
||||
{versionSource?.customMirrors.length ? (
|
||||
<div className="custom-mirror-dialog-list">
|
||||
<span>{t('kernel.versions.customMirrorSaved')}</span>
|
||||
{versionSource.customMirrors.map((url) => (
|
||||
<div key={url}>
|
||||
<span title={url}>{url}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={versionSourceSaving}
|
||||
title={t('kernel.versions.customMirrorRemove')}
|
||||
aria-label={t('kernel.versions.customMirrorRemove')}
|
||||
onClick={() => void removeCustomMirror(url)}
|
||||
>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="app-update-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={versionSourceSaving}
|
||||
onClick={() => {
|
||||
setCustomMirrorDialogOpen(false);
|
||||
setCustomMirrorDraft('');
|
||||
setVersionSourceError('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="secondary-button"
|
||||
disabled={!customMirrorDraft.trim() || versionSourceSaving}
|
||||
>
|
||||
<span>{t('kernel.versions.customMirrorConfirm')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Core Update Confirmation Dialog */}
|
||||
{confirmUpdateOpen ? (
|
||||
<div className="install-dialog-backdrop app-update-dialog-backdrop">
|
||||
|
||||
+115
-1
@@ -1073,11 +1073,18 @@ textarea:focus-visible {
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
|
||||
.version-source-control,
|
||||
.version-source-control label {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.version-source-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 250px) auto;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.version-source-control select {
|
||||
width: 250px;
|
||||
max-width: 100%;
|
||||
@@ -1100,6 +1107,29 @@ textarea:focus-visible {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.version-source-add-button {
|
||||
display: inline-flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-4b4742);
|
||||
}
|
||||
|
||||
.version-source-add-button:hover:not(:disabled) {
|
||||
border-color: var(--theme-9b958c);
|
||||
background: var(--theme-faf8f2);
|
||||
}
|
||||
|
||||
.version-source-add-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.version-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1674,6 +1704,15 @@ textarea:focus-visible {
|
||||
.version-source-control select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-source-control {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.version-source-add-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.info-list {
|
||||
@@ -8301,6 +8340,81 @@ body.table-col-resizing * {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.custom-mirror-dialog {
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-description {
|
||||
margin: 0;
|
||||
color: var(--theme-6f6961);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-relaxed);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog > input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--theme-d5d2cb);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-error {
|
||||
color: var(--theme-9f2d20);
|
||||
font-size: var(--font-size-meta);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-list {
|
||||
display: grid;
|
||||
max-height: 150px;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-list > span {
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 34px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 6px 6px 10px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 7px;
|
||||
background: var(--theme-faf8f2);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-list > div > span {
|
||||
overflow: hidden;
|
||||
color: var(--theme-4b4742);
|
||||
font-size: var(--font-size-meta);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-list button {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
|
||||
.custom-mirror-dialog-list button:hover:not(:disabled) {
|
||||
background: rgba(159, 45, 32, 0.08);
|
||||
}
|
||||
|
||||
.app-update-dialog .install-dialog-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user