refactor(updater): unify portable updates on Cloudflare R2
This commit is contained in:
1 parent
b73ea56135
commit
657b76fc7a
20 files changed
+118
-578
No files matched your search
@@ -224,6 +224,7 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
@@ -260,7 +261,14 @@ jobs:
|
||||
New-Item -ItemType File -Path (Join-Path $dataDir ".keep") -Force | Out-Null
|
||||
|
||||
Compress-Archive -Path $portableDir -DestinationPath $assetPath -Force
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$assetPath" --repo "$env:GITHUB_REPOSITORY" --clobber
|
||||
pnpm tauri signer sign --private-key-path "$env:TAURI_SIGNING_PRIVATE_KEY" "$assetPath"
|
||||
|
||||
$signaturePath = "$assetPath.sig"
|
||||
if (-not (Test-Path $signaturePath)) {
|
||||
throw "Portable updater signature was not generated: $signaturePath"
|
||||
}
|
||||
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$assetPath" "$signaturePath" --repo "$env:GITHUB_REPOSITORY" --clobber
|
||||
|
||||
rename-assets:
|
||||
name: Rename release assets
|
||||
@@ -434,6 +442,12 @@ jobs:
|
||||
} else if (lower.includes('linux_arm64') || lower.includes('arm64') || lower.includes('aarch64')) {
|
||||
keys.push('linux-aarch64');
|
||||
}
|
||||
} else if (lower.endsWith('_portable.zip.sig')) {
|
||||
if (lower.includes('windows_x64') || lower.includes('x64') || lower.includes('x86_64') || lower.includes('amd64')) {
|
||||
keys.push('windows-x86_64-portable');
|
||||
} else if (lower.includes('windows_arm64') || lower.includes('arm64') || lower.includes('aarch64')) {
|
||||
keys.push('windows-aarch64-portable');
|
||||
}
|
||||
} else if (lower.endsWith('-setup.exe.sig') || lower.endsWith('-setup.nsis.zip.sig')) {
|
||||
if (lower.includes('windows_x64') || lower.includes('x64') || lower.includes('x86_64') || lower.includes('amd64')) {
|
||||
keys.push('windows-x86_64');
|
||||
@@ -859,6 +873,27 @@ jobs:
|
||||
process.stdin.on('data', chunk => input += chunk);
|
||||
process.stdin.on('end', () => {
|
||||
const json = JSON.parse(input);
|
||||
const requiredPortablePlatforms = [
|
||||
'windows-x86_64-portable',
|
||||
'windows-aarch64-portable',
|
||||
];
|
||||
|
||||
for (const platform of requiredPortablePlatforms) {
|
||||
const item = json.platforms?.[platform];
|
||||
if (!item?.url || !item?.signature) {
|
||||
throw new Error(
|
||||
'R2 latest.json is missing a signed portable target: ' + platform
|
||||
);
|
||||
}
|
||||
const portableUrlPrefix = process.env.DOWNLOAD_BASE_URL +
|
||||
'/releases/' + process.env.GITHUB_REF_NAME + '/';
|
||||
if (!item.url.startsWith(portableUrlPrefix)) {
|
||||
throw new Error(
|
||||
'R2 latest.json portable target does not use the versioned R2 URL: ' + platform
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('R2 latest.json version:', json.version);
|
||||
console.log('Platforms:', Object.keys(json.platforms || {}).join(', '));
|
||||
});
|
||||
|
||||
@@ -268,7 +268,7 @@ Download the latest build for your platform from [nyaterm.app](https://nyaterm.a
|
||||
| macOS | `.dmg` |
|
||||
| Linux | `.deb` / `.AppImage` |
|
||||
|
||||
For the Windows portable edition, extract the zip and run `NyaTerm.exe`. **Help → Check Updates** reads version details and release notes from GitHub Releases, then verifies the downloaded archive against GitHub's SHA-256 digest. Direct GitHub downloads, built-in mirrors, and a custom mirror are available; NyaTerm preserves the complete `data/` folder while replacing the program files and restarting.
|
||||
For the Windows portable edition, extract the zip and run `NyaTerm.exe`. **Help → Check Updates** uses the same Cloudflare R2 update manifest and download source as the installed edition. Tauri updater signatures are always verified before staging an update; NyaTerm preserves the complete `data/` folder while replacing the program files and restarting.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ NyaTerm 支持以下操作系统:
|
||||
| macOS | `.dmg` |
|
||||
| Linux | `.deb` / `.AppImage` |
|
||||
|
||||
Windows 便携版解压后运行 `NyaTerm.exe` 即可。**Help → 检查更新** 会从 GitHub Releases 读取版本和更新说明,并使用 GitHub 提供的 SHA-256 摘要验证下载包。可在设置中选择 GitHub 直连、内置镜像或自定义镜像;重启时 NyaTerm 会自动替换程序文件,并完整保留 `data/` 目录。
|
||||
Windows 便携版解压后运行 `NyaTerm.exe` 即可。**Help → 检查更新** 与安装版共用 Cloudflare R2 更新清单和下载源,暂存更新前会强制验证 Tauri updater 签名。重启时 NyaTerm 会自动替换程序文件,并完整保留 `data/` 目录。
|
||||
|
||||
Windows 便携版直接下载:
|
||||
|
||||
- [NyaTerm_1.1.17_windows_x64_portable.zip](https://github.com/nyakang/nyaterm/releases/download/v1.1.17/NyaTerm_1.1.17_windows_x64_portable.zip)(x64)
|
||||
- [NyaTerm_1.1.17_windows_arm64_portable.zip](https://github.com/nyakang/nyaterm/releases/download/v1.1.17/NyaTerm_1.1.17_windows_arm64_portable.zip)(ARM64)
|
||||
- [NyaTerm_1.1.17_windows_x64_portable.zip](https://downloads.nyaterm.app/releases/v1.1.17/NyaTerm_1.1.17_windows_x64_portable.zip)(x64)
|
||||
- [NyaTerm_1.1.17_windows_arm64_portable.zip](https://downloads.nyaterm.app/releases/v1.1.17/NyaTerm_1.1.17_windows_arm64_portable.zip)(ARM64)
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+3
-3
@@ -24,12 +24,12 @@ Visit the [Releases](https://github.com/nyakang/nyaterm/releases) page and downl
|
||||
| macOS | `.dmg` |
|
||||
| Linux | `.deb` / `.AppImage` |
|
||||
|
||||
For the Windows portable edition, extract the zip and run `NyaTerm.exe`. **Help → Check Updates** reads version details and release notes from GitHub Releases, then verifies the downloaded archive against GitHub's SHA-256 digest. You can select direct GitHub downloads, a built-in mirror, or a custom mirror in Settings; when restarting, NyaTerm replaces the program files and preserves the complete `data/` folder.
|
||||
For the Windows portable edition, extract the zip and run `NyaTerm.exe`. **Help → Check Updates** uses the same Cloudflare R2 update manifest and download source as the installed edition. Tauri updater signatures are always verified before staging an update; when restarting, NyaTerm replaces the program files and preserves the complete `data/` folder.
|
||||
|
||||
Direct downloads for the Windows portable edition:
|
||||
|
||||
- [NyaTerm_1.1.17_windows_x64_portable.zip](https://github.com/nyakang/nyaterm/releases/download/v1.1.17/NyaTerm_1.1.17_windows_x64_portable.zip) (x64)
|
||||
- [NyaTerm_1.1.17_windows_arm64_portable.zip](https://github.com/nyakang/nyaterm/releases/download/v1.1.17/NyaTerm_1.1.17_windows_arm64_portable.zip) (ARM64)
|
||||
- [NyaTerm_1.1.17_windows_x64_portable.zip](https://downloads.nyaterm.app/releases/v1.1.17/NyaTerm_1.1.17_windows_x64_portable.zip) (x64)
|
||||
- [NyaTerm_1.1.17_windows_arm64_portable.zip](https://downloads.nyaterm.app/releases/v1.1.17/NyaTerm_1.1.17_windows_arm64_portable.zip) (ARM64)
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
@@ -47,9 +47,8 @@ type LatestDownloadManifest = {
|
||||
const latestDownloadManifestUrl = 'https://downloads.nyaterm.app/latest.json';
|
||||
const downloadBaseUrl = 'https://downloads.nyaterm.app';
|
||||
|
||||
// Fallback version for portable download links before the manifest loads.
|
||||
// Portable zips have no `.sig`, so they never appear in latest.json's platforms;
|
||||
// their URLs are derived from the manifest `version` instead (see buildPortableHref).
|
||||
// Fallback for the initial render and older manifests that predate portable targets.
|
||||
// The application updater itself never uses this unsigned URL derivation.
|
||||
const fallbackPortableVersion = 'latest';
|
||||
|
||||
const portableArchByKey: Partial<Record<DownloadPlatformKey, string>> = {
|
||||
@@ -65,7 +64,6 @@ function buildPortableHref(key: DownloadPlatformKey, version: string): string |
|
||||
|
||||
return `${downloadBaseUrl}/releases/v${version}/NyaTerm_${version}_windows_${arch}_portable.zip`;
|
||||
}
|
||||
|
||||
const downloadPlatforms: DownloadPlatform[] = [
|
||||
{
|
||||
key: 'windows-x86_64',
|
||||
@@ -237,15 +235,17 @@ function getPlatformKeyFromHints(os: string, architecture: string): DownloadPlat
|
||||
|
||||
function getDownloadPlatformsFromManifest(manifest: LatestDownloadManifest): DownloadPlatform[] {
|
||||
return downloadPlatforms.map((platform) => {
|
||||
// Portable zips have no `.sig`, so they never appear in manifest.platforms;
|
||||
// derive their URL from the manifest version when available.
|
||||
if (portableArchByKey[platform.key]) {
|
||||
const href = manifest.version ? buildPortableHref(platform.key, manifest.version) : undefined;
|
||||
return href ? {...platform, href} : platform;
|
||||
const manifestHref = manifest.platforms?.[platform.key]?.url;
|
||||
if (manifestHref) {
|
||||
return {...platform, href: manifestHref};
|
||||
}
|
||||
|
||||
const href = manifest.platforms?.[platform.key]?.url;
|
||||
return href ? {...platform, href} : platform;
|
||||
if (portableArchByKey[platform.key] && manifest.version) {
|
||||
const fallbackHref = buildPortableHref(platform.key, manifest.version);
|
||||
return fallbackHref ? {...platform, href: fallbackHref} : platform;
|
||||
}
|
||||
|
||||
return platform;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
-1
@@ -4306,7 +4306,6 @@ dependencies = [
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"rusty-leveldb",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialport",
|
||||
|
||||
@@ -134,7 +134,6 @@ reqwest = { version = "0.13", default-features = false, features = [
|
||||
"json",
|
||||
"form",
|
||||
"query",
|
||||
"stream",
|
||||
"native-tls",
|
||||
] }
|
||||
reqwest_012_native_tls = { package = "reqwest", version = "0.12.28", default-features = false, features = [
|
||||
@@ -149,7 +148,6 @@ hex = "0.4"
|
||||
http = "1"
|
||||
urlencoding = "2"
|
||||
zip = "8"
|
||||
semver = "1"
|
||||
encoding_rs = "0.8"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "webp"] }
|
||||
rqrr = "0.9"
|
||||
|
||||
@@ -62,9 +62,9 @@ pub use settings::{
|
||||
AiPermissionMode, AiProviderCredential, AiProviderKind, AiProviderProfile, AiReasoningEffort,
|
||||
AiSettings, AppSettings, AppearanceSettings, ClaudeCodeIntegrationSettings,
|
||||
CodexIntegrationSettings, CodexThreadMode, DiagnosticsLogLevel, DiagnosticsSettings,
|
||||
GeneralSettings, InteractionSettings, KeywordHighlightRule, PortableUpdateDownloadSource,
|
||||
ProxySettings, RiskLevel, SearchEngine, SearchSettings, SecuritySettings, TerminalColorsConfig,
|
||||
TerminalSettings, ThemeColorsConfig, ThemeConfig, TransferSettings, TranslationSettings,
|
||||
GeneralSettings, InteractionSettings, KeywordHighlightRule, ProxySettings, RiskLevel,
|
||||
SearchEngine, SearchSettings, SecuritySettings, TerminalColorsConfig, TerminalSettings,
|
||||
ThemeColorsConfig, ThemeConfig, TransferSettings, TranslationSettings,
|
||||
ai_model_id_for_credential, ai_model_id_for_provider, decrypt_ai_settings, encrypt_ai_settings,
|
||||
load_app_settings, mask_ai_settings, merge_masked_ai_settings, normalize_ai_settings,
|
||||
save_app_settings,
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
use super::super::default_true;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PortableUpdateDownloadSource {
|
||||
#[default]
|
||||
Github,
|
||||
Ghfast,
|
||||
GhProxy,
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeneralSettings {
|
||||
#[serde(default = "default_true")]
|
||||
@@ -23,10 +13,6 @@ pub struct GeneralSettings {
|
||||
pub boss_key: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub confirm_on_close: bool,
|
||||
#[serde(default)]
|
||||
pub portable_update_download_source: PortableUpdateDownloadSource,
|
||||
#[serde(default)]
|
||||
pub portable_update_custom_mirror: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -37,8 +23,6 @@ impl Default for GeneralSettings {
|
||||
minimize_to_tray: false,
|
||||
boss_key: None,
|
||||
confirm_on_close: true,
|
||||
portable_update_download_source: PortableUpdateDownloadSource::default(),
|
||||
portable_update_custom_mirror: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ pub use ai::{
|
||||
};
|
||||
pub use appearance::{AppearanceSettings, TerminalColorsConfig, ThemeColorsConfig, ThemeConfig};
|
||||
pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings};
|
||||
pub use general::{GeneralSettings, PortableUpdateDownloadSource};
|
||||
pub use general::GeneralSettings;
|
||||
pub use interaction::InteractionSettings;
|
||||
pub use proxy::ProxySettings;
|
||||
pub use search::{SearchEngine, SearchSettings};
|
||||
|
||||
@@ -7,15 +7,13 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::{PortableUpdateDownloadSource, load_app_settings};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::runtime::AppRuntime;
|
||||
|
||||
@@ -25,9 +23,6 @@ const PORTABLE_MARKER: &str = "portable.flag";
|
||||
const HELPER_FLAG: &str = "--nyaterm-portable-update-helper";
|
||||
const CLEANUP_ENV: &str = "NYATERM_PORTABLE_UPDATE_CLEANUP";
|
||||
const WORK_DIR_PREFIX: &str = "nyaterm-portable-update-";
|
||||
const GITHUB_LATEST_RELEASE_URL: &str =
|
||||
"https://api.github.com/repos/nyakang/nyaterm/releases/latest";
|
||||
const GITHUB_API_VERSION: &str = "2022-11-28";
|
||||
const MAX_ARCHIVE_ENTRIES: usize = 128;
|
||||
const MAX_PAYLOAD_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const STALE_WORK_DIR_AGE: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
@@ -47,31 +42,6 @@ pub struct PortableUpdateProgress {
|
||||
total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
body: Option<String>,
|
||||
published_at: Option<String>,
|
||||
assets: Vec<GithubReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubReleaseAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: u64,
|
||||
digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PortableRelease {
|
||||
version: String,
|
||||
date: Option<String>,
|
||||
body: Option<String>,
|
||||
asset: GithubReleaseAsset,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StagedPortableUpdate {
|
||||
work_dir: PathBuf,
|
||||
@@ -99,12 +69,12 @@ pub async fn check(
|
||||
runtime: &AppRuntime,
|
||||
) -> AppResult<Option<PortableUpdateInfo>> {
|
||||
ensure_portable_runtime(runtime)?;
|
||||
Ok(latest_portable_release(app, std::env::consts::ARCH)
|
||||
Ok(portable_update(app, std::env::consts::ARCH)
|
||||
.await?
|
||||
.map(|release| PortableUpdateInfo {
|
||||
version: release.version,
|
||||
date: release.date,
|
||||
body: release.body,
|
||||
.map(|update| PortableUpdateInfo {
|
||||
version: update.version,
|
||||
date: update.date.and_then(|date| date.format(&Rfc3339).ok()),
|
||||
body: update.body,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -123,58 +93,32 @@ pub async fn download(
|
||||
.map_err(|_| AppError::Config("A portable update download is already running".into()))?;
|
||||
let _guard = DownloadGuard(&state.downloading);
|
||||
|
||||
let release = latest_portable_release(app, std::env::consts::ARCH)
|
||||
let update = portable_update(app, std::env::consts::ARCH)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Config("No portable update is available".into()))?;
|
||||
let settings = load_app_settings(app)?;
|
||||
let download_url = rewrite_download_url(
|
||||
&release.asset.browser_download_url,
|
||||
settings.general.portable_update_download_source,
|
||||
&settings.general.portable_update_custom_mirror,
|
||||
)?;
|
||||
let client = github_client(app)?;
|
||||
let response =
|
||||
client.get(download_url).send().await.map_err(|error| {
|
||||
AppError::Config(format!("Portable update download failed: {error}"))
|
||||
let mut downloaded = 0_u64;
|
||||
let bytes = update
|
||||
.download(
|
||||
|chunk_len, total| {
|
||||
downloaded = downloaded.saturating_add(chunk_len as u64);
|
||||
let _ = on_progress.send(PortableUpdateProgress {
|
||||
downloaded,
|
||||
total: total.unwrap_or(0),
|
||||
});
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::Config(format!(
|
||||
"Portable update download or signature verification failed: {error}"
|
||||
))
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Config(format!(
|
||||
"Portable update download failed with HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let total = response.content_length().unwrap_or(release.asset.size);
|
||||
if total > MAX_PAYLOAD_BYTES || release.asset.size > MAX_PAYLOAD_BYTES {
|
||||
if bytes.len() as u64 > MAX_PAYLOAD_BYTES {
|
||||
return Err(AppError::Config(
|
||||
"Portable update archive is larger than the allowed limit".into(),
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(release.asset.size as usize);
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| {
|
||||
AppError::Config(format!("Portable update download failed: {error}"))
|
||||
})?;
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_PAYLOAD_BYTES as usize {
|
||||
return Err(AppError::Config(
|
||||
"Portable update archive is larger than the allowed limit".into(),
|
||||
));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
let _ = on_progress.send(PortableUpdateProgress {
|
||||
downloaded: bytes.len() as u64,
|
||||
total,
|
||||
});
|
||||
}
|
||||
if bytes.len() as u64 != release.asset.size {
|
||||
return Err(AppError::Config(format!(
|
||||
"Portable update size mismatch: GitHub reported {} bytes, downloaded {} bytes",
|
||||
release.asset.size,
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
verify_github_digest(&bytes, &release.sha256)?;
|
||||
let _ = on_progress.send(PortableUpdateProgress {
|
||||
downloaded: bytes.len() as u64,
|
||||
total: bytes.len() as u64,
|
||||
@@ -248,164 +192,35 @@ fn ensure_portable_runtime(runtime: &AppRuntime) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn latest_portable_release(
|
||||
app: &tauri::AppHandle,
|
||||
arch: &str,
|
||||
) -> AppResult<Option<PortableRelease>> {
|
||||
let asset_suffix = portable_asset_suffix_for_arch(arch)?;
|
||||
let client = github_client(app)?;
|
||||
let response = client
|
||||
.get(GITHUB_LATEST_RELEASE_URL)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", GITHUB_API_VERSION)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::Config(format!("Failed to check GitHub portable release: {error}"))
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(AppError::Config(format!(
|
||||
"GitHub release check failed with HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
let release: GithubRelease = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| AppError::Config(format!("Invalid GitHub release response: {error}")))?;
|
||||
portable_release_from_github(
|
||||
release,
|
||||
app.package_info().version.to_string(),
|
||||
asset_suffix,
|
||||
)
|
||||
}
|
||||
|
||||
fn portable_release_from_github(
|
||||
release: GithubRelease,
|
||||
current_version: String,
|
||||
asset_suffix: &str,
|
||||
) -> AppResult<Option<PortableRelease>> {
|
||||
let version_text = release.tag_name.trim_start_matches('v');
|
||||
let version = Version::parse(version_text).map_err(|error| {
|
||||
AppError::Config(format!(
|
||||
"GitHub release has an invalid version '{}': {error}",
|
||||
release.tag_name
|
||||
))
|
||||
})?;
|
||||
let current = Version::parse(current_version.trim_start_matches('v')).map_err(|error| {
|
||||
AppError::Config(format!(
|
||||
"Current application version '{current_version}' is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
if version <= current {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut matching_assets = release
|
||||
.assets
|
||||
.into_iter()
|
||||
.filter(|asset| asset.name.to_ascii_lowercase().ends_with(asset_suffix));
|
||||
let asset = matching_assets.next().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"GitHub release {} does not contain a compatible portable ZIP",
|
||||
release.tag_name
|
||||
))
|
||||
})?;
|
||||
if matching_assets.next().is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"GitHub release {} contains duplicate portable ZIP assets",
|
||||
release.tag_name
|
||||
)));
|
||||
}
|
||||
let sha256 = parse_github_sha256(asset.digest.as_deref())?;
|
||||
|
||||
Ok(Some(PortableRelease {
|
||||
version: version.to_string(),
|
||||
date: release.published_at,
|
||||
body: release.body,
|
||||
asset,
|
||||
sha256,
|
||||
}))
|
||||
}
|
||||
|
||||
fn github_client(app: &tauri::AppHandle) -> AppResult<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(format!("NyaTerm/{}", app.package_info().version))
|
||||
.connect_timeout(Duration::from_secs(20))
|
||||
.timeout(Duration::from_secs(15 * 60))
|
||||
async fn portable_update(app: &tauri::AppHandle, arch: &str) -> AppResult<Option<Update>> {
|
||||
let target = portable_target_for_arch(arch)?;
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.target(target)
|
||||
.build()
|
||||
.map_err(|error| AppError::Config(format!("Failed to create update client: {error}")))
|
||||
}
|
||||
|
||||
fn portable_asset_suffix_for_arch(arch: &str) -> AppResult<&'static str> {
|
||||
match arch {
|
||||
"x86_64" => Ok("_windows_x64_portable.zip"),
|
||||
"aarch64" => Ok("_windows_arm64_portable.zip"),
|
||||
other => Err(AppError::Config(format!(
|
||||
"Unsupported portable update architecture: {other}"
|
||||
.map_err(|error| {
|
||||
AppError::Config(format!("Failed to configure portable updater: {error}"))
|
||||
})?;
|
||||
match updater.check().await {
|
||||
Ok(update) => Ok(update),
|
||||
Err(
|
||||
tauri_plugin_updater::Error::TargetNotFound(_)
|
||||
| tauri_plugin_updater::Error::TargetsNotFound(_),
|
||||
) => Ok(None),
|
||||
Err(error) => Err(AppError::Config(format!(
|
||||
"Failed to check for a portable update: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_github_sha256(digest: Option<&str>) -> AppResult<String> {
|
||||
let digest = digest.ok_or_else(|| {
|
||||
AppError::Config("GitHub did not provide a SHA-256 digest for the portable ZIP".into())
|
||||
})?;
|
||||
let value = digest
|
||||
.strip_prefix("sha256:")
|
||||
.ok_or_else(|| AppError::Config("GitHub portable ZIP digest is not SHA-256".into()))?;
|
||||
if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err(AppError::Config(
|
||||
"GitHub portable ZIP SHA-256 digest is invalid".into(),
|
||||
));
|
||||
fn portable_target_for_arch(arch: &str) -> AppResult<&'static str> {
|
||||
match arch {
|
||||
"x86_64" => Ok("windows-x86_64-portable"),
|
||||
"aarch64" => Ok("windows-aarch64-portable"),
|
||||
other => Err(AppError::Config(format!(
|
||||
"Unsupported portable update architecture: {other}"
|
||||
))),
|
||||
}
|
||||
Ok(value.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn verify_github_digest(bytes: &[u8], expected: &str) -> AppResult<()> {
|
||||
let actual = hex::encode(Sha256::digest(bytes));
|
||||
if actual != expected {
|
||||
return Err(AppError::Config(format!(
|
||||
"Portable update SHA-256 mismatch: expected {expected}, downloaded {actual}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_download_url(
|
||||
original: &str,
|
||||
source: PortableUpdateDownloadSource,
|
||||
custom_mirror: &str,
|
||||
) -> AppResult<String> {
|
||||
let rewritten = match source {
|
||||
PortableUpdateDownloadSource::Github => original.to_string(),
|
||||
PortableUpdateDownloadSource::Ghfast => format!("https://ghfast.top/{original}"),
|
||||
PortableUpdateDownloadSource::GhProxy => format!("https://gh-proxy.com/{original}"),
|
||||
PortableUpdateDownloadSource::Custom => {
|
||||
let mirror = custom_mirror.trim();
|
||||
if mirror.is_empty() {
|
||||
return Err(AppError::Config(
|
||||
"A custom portable update mirror URL is required".into(),
|
||||
));
|
||||
}
|
||||
if mirror.contains("{url}") {
|
||||
mirror.replace("{url}", original)
|
||||
} else {
|
||||
format!("{}/{original}", mirror.trim_end_matches('/'))
|
||||
}
|
||||
}
|
||||
};
|
||||
let parsed = reqwest::Url::parse(&rewritten)
|
||||
.map_err(|error| AppError::Config(format!("Invalid update download URL: {error}")))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https")
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
{
|
||||
return Err(AppError::Config(
|
||||
"Update download URL must use HTTP(S) without embedded credentials".into(),
|
||||
));
|
||||
}
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
fn ensure_install_directory_writable(directory: &Path) -> AppResult<()> {
|
||||
@@ -736,82 +551,14 @@ mod tests {
|
||||
#[test]
|
||||
fn maps_supported_windows_architectures() {
|
||||
assert_eq!(
|
||||
portable_asset_suffix_for_arch("x86_64").unwrap(),
|
||||
"_windows_x64_portable.zip"
|
||||
portable_target_for_arch("x86_64").unwrap(),
|
||||
"windows-x86_64-portable"
|
||||
);
|
||||
assert_eq!(
|
||||
portable_asset_suffix_for_arch("aarch64").unwrap(),
|
||||
"_windows_arm64_portable.zip"
|
||||
);
|
||||
assert!(portable_asset_suffix_for_arch("x86").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_supported_download_sources() {
|
||||
let original = "https://github.com/nyakang/nyaterm/releases/download/v1.2.0/file.zip";
|
||||
assert_eq!(
|
||||
rewrite_download_url(original, PortableUpdateDownloadSource::Github, "").unwrap(),
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_download_url(original, PortableUpdateDownloadSource::Ghfast, "").unwrap(),
|
||||
format!("https://ghfast.top/{original}")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_download_url(original, PortableUpdateDownloadSource::GhProxy, "").unwrap(),
|
||||
format!("https://gh-proxy.com/{original}")
|
||||
);
|
||||
assert_eq!(
|
||||
rewrite_download_url(
|
||||
original,
|
||||
PortableUpdateDownloadSource::Custom,
|
||||
"https://mirror.example/{url}",
|
||||
)
|
||||
.unwrap(),
|
||||
format!("https://mirror.example/{original}")
|
||||
);
|
||||
assert!(rewrite_download_url(original, PortableUpdateDownloadSource::Custom, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_newer_github_release_and_requires_digest() {
|
||||
let release = GithubRelease {
|
||||
tag_name: "v1.1.16".into(),
|
||||
body: Some("notes".into()),
|
||||
published_at: Some("2026-07-21T11:06:47Z".into()),
|
||||
assets: vec![GithubReleaseAsset {
|
||||
name: "NyaTerm_1.1.16_windows_x64_portable.zip".into(),
|
||||
browser_download_url: "https://github.com/example.zip".into(),
|
||||
size: 42,
|
||||
digest: Some(format!("sha256:{}", "a".repeat(64))),
|
||||
}],
|
||||
};
|
||||
let selected =
|
||||
portable_release_from_github(release, "1.1.15".into(), "_windows_x64_portable.zip")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(selected.version, "1.1.16");
|
||||
assert_eq!(selected.sha256, "a".repeat(64));
|
||||
|
||||
let missing_digest = GithubRelease {
|
||||
tag_name: "v1.1.16".into(),
|
||||
body: None,
|
||||
published_at: None,
|
||||
assets: vec![GithubReleaseAsset {
|
||||
name: "NyaTerm_1.1.16_windows_x64_portable.zip".into(),
|
||||
browser_download_url: "https://github.com/example.zip".into(),
|
||||
size: 42,
|
||||
digest: None,
|
||||
}],
|
||||
};
|
||||
assert!(
|
||||
portable_release_from_github(
|
||||
missing_digest,
|
||||
"1.1.15".into(),
|
||||
"_windows_x64_portable.zip",
|
||||
)
|
||||
.is_err()
|
||||
portable_target_for_arch("aarch64").unwrap(),
|
||||
"windows-aarch64-portable"
|
||||
);
|
||||
assert!(portable_target_for_arch("x86").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,20 +14,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import type { UpdateInfo, UpdateProgress, UpdateStatus } from "@/lib/updater";
|
||||
import { checkForUpdate, downloadAndInstallUpdate, relaunchApp } from "@/lib/updater";
|
||||
import type { GeneralSettings } from "@/types/global";
|
||||
|
||||
interface UpdateDialogProps {
|
||||
open: boolean;
|
||||
@@ -157,20 +147,15 @@ function MarkdownContent({ content }: { content: string }) {
|
||||
|
||||
export default function UpdateDialog({ open, onClose, onUpdateFound }: UpdateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { appSettings, runtimeInfo, updateAppSettings } = useApp();
|
||||
const { runtimeInfo } = useApp();
|
||||
const [status, setStatus] = useState<UpdateStatus>("checking");
|
||||
const [progress, setProgress] = useState<UpdateProgress>({ downloaded: 0, total: 0 });
|
||||
const [error, setError] = useState<string>("");
|
||||
const [currentVersion, setCurrentVersion] = useState("");
|
||||
const [localUpdateInfo, setLocalUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [downloadSource, setDownloadSource] =
|
||||
useState<GeneralSettings["portable_update_download_source"]>("github");
|
||||
const [customMirror, setCustomMirror] = useState("");
|
||||
const isUpdating = useRef(false);
|
||||
const onUpdateFoundRef = useRef(onUpdateFound);
|
||||
const appSettingsRef = useRef(appSettings);
|
||||
onUpdateFoundRef.current = onUpdateFound;
|
||||
appSettingsRef.current = appSettings;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -181,8 +166,6 @@ export default function UpdateDialog({ open, onClose, onUpdateFound }: UpdateDia
|
||||
setProgress({ downloaded: 0, total: 0 });
|
||||
setError("");
|
||||
setLocalUpdateInfo(null);
|
||||
setDownloadSource(appSettingsRef.current.general.portable_update_download_source);
|
||||
setCustomMirror(appSettingsRef.current.general.portable_update_custom_mirror);
|
||||
isUpdating.current = false;
|
||||
|
||||
setStatus("checking");
|
||||
@@ -210,34 +193,6 @@ export default function UpdateDialog({ open, onClose, onUpdateFound }: UpdateDia
|
||||
};
|
||||
}, [open, runtimeInfo.portable]);
|
||||
|
||||
const handleDownloadSourceChange = useCallback(
|
||||
(source: GeneralSettings["portable_update_download_source"]) => {
|
||||
setDownloadSource(source);
|
||||
updateAppSettings({
|
||||
general: {
|
||||
...appSettings.general,
|
||||
portable_update_download_source: source,
|
||||
portable_update_custom_mirror: customMirror,
|
||||
},
|
||||
});
|
||||
},
|
||||
[appSettings.general, customMirror, updateAppSettings],
|
||||
);
|
||||
|
||||
const handleCustomMirrorChange = useCallback(
|
||||
(value: string) => {
|
||||
setCustomMirror(value);
|
||||
updateAppSettings({
|
||||
general: {
|
||||
...appSettings.general,
|
||||
portable_update_download_source: downloadSource,
|
||||
portable_update_custom_mirror: value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[appSettings.general, downloadSource, updateAppSettings],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (isUpdating.current) return;
|
||||
isUpdating.current = true;
|
||||
@@ -245,18 +200,6 @@ export default function UpdateDialog({ open, onClose, onUpdateFound }: UpdateDia
|
||||
setError("");
|
||||
|
||||
try {
|
||||
if (runtimeInfo.portable) {
|
||||
await invoke("save_app_settings", {
|
||||
settings: {
|
||||
...appSettings,
|
||||
general: {
|
||||
...appSettings.general,
|
||||
portable_update_download_source: downloadSource,
|
||||
portable_update_custom_mirror: customMirror,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
await downloadAndInstallUpdate(runtimeInfo.portable, (p) => {
|
||||
setProgress(p);
|
||||
});
|
||||
@@ -266,7 +209,7 @@ export default function UpdateDialog({ open, onClose, onUpdateFound }: UpdateDia
|
||||
setStatus("error");
|
||||
isUpdating.current = false;
|
||||
}
|
||||
}, [appSettings, customMirror, downloadSource, runtimeInfo.portable]);
|
||||
}, [runtimeInfo.portable]);
|
||||
|
||||
const handleRelaunch = useCallback(async () => {
|
||||
try {
|
||||
@@ -350,61 +293,6 @@ export default function UpdateDialog({ open, onClose, onUpdateFound }: UpdateDia
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runtimeInfo.portable && (
|
||||
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium" htmlFor="portable-update-source">
|
||||
{t("settings.portableUpdateDownloadSource")}
|
||||
</label>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{t("settings.portableUpdateDownloadSourceDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={downloadSource}
|
||||
onValueChange={(value) =>
|
||||
handleDownloadSourceChange(
|
||||
value as GeneralSettings["portable_update_download_source"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="portable-update-source" className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="github">
|
||||
{t("settings.portableUpdateSourceGithub")}
|
||||
</SelectItem>
|
||||
<SelectItem value="ghfast">
|
||||
{t("settings.portableUpdateSourceGhfast")}
|
||||
</SelectItem>
|
||||
<SelectItem value="gh_proxy">
|
||||
{t("settings.portableUpdateSourceGhProxy")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("settings.portableUpdateSourceCustom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{downloadSource === "custom" && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="portable-update-custom-mirror">
|
||||
{t("settings.portableUpdateCustomMirror")}
|
||||
</label>
|
||||
<Input
|
||||
id="portable-update-custom-mirror"
|
||||
value={customMirror}
|
||||
placeholder="https://example.com/{url}"
|
||||
onChange={(event) => handleCustomMirrorChange(event.target.value)}
|
||||
/>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
||||
{t("settings.portableUpdateCustomMirrorDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -5,10 +5,8 @@ import { useApp } from "@/context/AppContext";
|
||||
import { useConfigTransfer } from "@/hooks/useConfigTransfer";
|
||||
import { AVAILABLE_LANGUAGES } from "@/i18n";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import type { GeneralSettings } from "@/types/global";
|
||||
import {
|
||||
SettingFieldGrid,
|
||||
SettingInput,
|
||||
SettingRow,
|
||||
SettingSection,
|
||||
SettingSelect,
|
||||
@@ -17,7 +15,7 @@ import {
|
||||
|
||||
export function GeneralTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { appSettings, runtimeInfo, updateAppSettings, updateUi } = useApp();
|
||||
const { appSettings, updateAppSettings, updateUi } = useApp();
|
||||
const { handleExportDiagnostics, handleOpenLogs } = useConfigTransfer();
|
||||
|
||||
return (
|
||||
@@ -88,51 +86,6 @@ export function GeneralTab() {
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
|
||||
{runtimeInfo.portable && (
|
||||
<SettingSection
|
||||
title={t("settings.portableUpdate")}
|
||||
desc={t("settings.portableUpdateDesc")}
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
<SettingSelect
|
||||
label={t("settings.portableUpdateDownloadSource")}
|
||||
desc={t("settings.portableUpdateDownloadSourceDesc")}
|
||||
value={appSettings.general.portable_update_download_source}
|
||||
onValueChange={(source) =>
|
||||
updateAppSettings({
|
||||
general: {
|
||||
...appSettings.general,
|
||||
portable_update_download_source:
|
||||
source as GeneralSettings["portable_update_download_source"],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectItem value="github">{t("settings.portableUpdateSourceGithub")}</SelectItem>
|
||||
<SelectItem value="ghfast">{t("settings.portableUpdateSourceGhfast")}</SelectItem>
|
||||
<SelectItem value="gh_proxy">{t("settings.portableUpdateSourceGhProxy")}</SelectItem>
|
||||
<SelectItem value="custom">{t("settings.portableUpdateSourceCustom")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
{appSettings.general.portable_update_download_source === "custom" && (
|
||||
<SettingInput
|
||||
label={t("settings.portableUpdateCustomMirror")}
|
||||
desc={t("settings.portableUpdateCustomMirrorDesc")}
|
||||
value={appSettings.general.portable_update_custom_mirror}
|
||||
placeholder="https://example.com/{url}"
|
||||
onChange={(event) =>
|
||||
updateAppSettings({
|
||||
general: {
|
||||
...appSettings.general,
|
||||
portable_update_custom_mirror: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
<SettingSection
|
||||
title={t("settings.diagnostics")}
|
||||
desc={t("settings.diagnosticsDesc")}
|
||||
|
||||
@@ -191,8 +191,6 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
minimize_to_tray: false,
|
||||
boss_key: null,
|
||||
confirm_on_close: true,
|
||||
portable_update_download_source: "github",
|
||||
portable_update_custom_mirror: "",
|
||||
},
|
||||
appearance: {
|
||||
theme: "github-dark",
|
||||
|
||||
@@ -28,8 +28,6 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
minimize_to_tray: false,
|
||||
boss_key: null,
|
||||
confirm_on_close: true,
|
||||
portable_update_download_source: "github",
|
||||
portable_update_custom_mirror: "",
|
||||
},
|
||||
appearance: {
|
||||
theme: "github-dark",
|
||||
@@ -216,8 +214,6 @@ const DEFAULT_RUNTIME_INFO: AppRuntimeInfo = {
|
||||
*/
|
||||
export function ChildAppProvider({ children }: { children: ReactNode }) {
|
||||
const [appSettings, setAppSettings] = useState<AppSettings>(DEFAULT_APP_SETTINGS);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<AppRuntimeInfo>(DEFAULT_RUNTIME_INFO);
|
||||
const [runtimeInfoLoaded, setRuntimeInfoLoaded] = useState(false);
|
||||
const loaded = useRef(false);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
@@ -244,20 +240,6 @@ export function ChildAppProvider({ children }: { children: ReactNode }) {
|
||||
loadAppSettings();
|
||||
}, [loadAppSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<AppRuntimeInfo>("get_app_runtime_info")
|
||||
.then(setRuntimeInfo)
|
||||
.catch((error) => {
|
||||
logger.error({
|
||||
domain: "app.lifecycle",
|
||||
event: "runtime_info.load_failed",
|
||||
message: "Failed to load app runtime info in child window",
|
||||
error,
|
||||
});
|
||||
})
|
||||
.finally(() => setRuntimeInfoLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("settings-changed", () => {
|
||||
loadAppSettings();
|
||||
@@ -410,8 +392,8 @@ export function ChildAppProvider({ children }: { children: ReactNode }) {
|
||||
setIsLocked,
|
||||
settingsLoaded,
|
||||
startupRestoreComplete: true,
|
||||
runtimeInfo,
|
||||
runtimeInfoLoaded,
|
||||
runtimeInfo: DEFAULT_RUNTIME_INFO,
|
||||
runtimeInfoLoaded: true,
|
||||
}),
|
||||
[
|
||||
noop,
|
||||
@@ -430,8 +412,6 @@ export function ChildAppProvider({ children }: { children: ReactNode }) {
|
||||
isLocked,
|
||||
setIsLocked,
|
||||
settingsLoaded,
|
||||
runtimeInfo,
|
||||
runtimeInfoLoaded,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1808,16 +1808,6 @@
|
||||
"policyAccept": "Accept (Auto-add)",
|
||||
"policyPrompt": "Prompt (Ask user)",
|
||||
"policyStrict": "Strict (Reject unknown)",
|
||||
"portableUpdate": "Portable Updates",
|
||||
"portableUpdateCustomMirror": "Custom mirror URL",
|
||||
"portableUpdateCustomMirrorDesc": "Use {url} as a placeholder, or enter a URL prefix.",
|
||||
"portableUpdateDesc": "Use GitHub Releases for portable update metadata and choose how the ZIP is downloaded.",
|
||||
"portableUpdateDownloadSource": "Download Source",
|
||||
"portableUpdateDownloadSourceDesc": "The selected mirror only transports the file; its SHA-256 is checked against GitHub.",
|
||||
"portableUpdateSourceCustom": "Custom mirror",
|
||||
"portableUpdateSourceGhProxy": "gh-proxy.com",
|
||||
"portableUpdateSourceGhfast": "ghfast.top",
|
||||
"portableUpdateSourceGithub": "GitHub direct",
|
||||
"preserveTimestamps": "Keep Timestamps",
|
||||
"preserveTimestampsDesc": "Keep original modification timestamps.",
|
||||
"privateKey": "Private key",
|
||||
|
||||
@@ -1804,16 +1804,6 @@
|
||||
"policyAccept": "수락(자동 추가)",
|
||||
"policyPrompt": "확인(사용자에게 묻기)",
|
||||
"policyStrict": "엄격(알 수 없는 키 거부)",
|
||||
"portableUpdate": "포터블 업데이트",
|
||||
"portableUpdateCustomMirror": "사용자 지정 미러 URL",
|
||||
"portableUpdateCustomMirrorDesc": "{url} 자리 표시자를 사용하거나 URL 접두사를 입력하세요.",
|
||||
"portableUpdateDesc": "GitHub Releases에서 포터블 업데이트 정보를 가져오고 ZIP 다운로드 경로를 선택합니다.",
|
||||
"portableUpdateDownloadSource": "다운로드 소스",
|
||||
"portableUpdateDownloadSourceDesc": "미러는 파일 전송만 담당하며 GitHub SHA-256으로 파일을 검증합니다.",
|
||||
"portableUpdateSourceCustom": "사용자 지정 미러",
|
||||
"portableUpdateSourceGhProxy": "gh-proxy.com",
|
||||
"portableUpdateSourceGhfast": "ghfast.top",
|
||||
"portableUpdateSourceGithub": "GitHub 직접 연결",
|
||||
"preserveTimestamps": "타임스탬프 유지",
|
||||
"preserveTimestampsDesc": "원본 수정 타임스탬프를 유지합니다.",
|
||||
"processManagerInterval": "프로세스 간격(초)",
|
||||
|
||||
@@ -1808,16 +1808,6 @@
|
||||
"policyAccept": "接受 (自动添加)",
|
||||
"policyPrompt": "提示 (询问用户)",
|
||||
"policyStrict": "严格 (拒绝未知)",
|
||||
"portableUpdate": "便携版更新",
|
||||
"portableUpdateCustomMirror": "自定义镜像 URL",
|
||||
"portableUpdateCustomMirrorDesc": "可使用 {url} 占位符,也可以填写 URL 前缀。",
|
||||
"portableUpdateDesc": "使用 GitHub Releases 获取便携版更新信息,并选择 ZIP 下载方式。",
|
||||
"portableUpdateDownloadSource": "下载来源",
|
||||
"portableUpdateDownloadSourceDesc": "镜像只负责传输文件,下载后会用 GitHub 提供的 SHA-256 校验。",
|
||||
"portableUpdateSourceCustom": "自定义镜像",
|
||||
"portableUpdateSourceGhProxy": "gh-proxy.com",
|
||||
"portableUpdateSourceGhfast": "ghfast.top",
|
||||
"portableUpdateSourceGithub": "GitHub 直连",
|
||||
"preserveTimestamps": "保留时间戳",
|
||||
"preserveTimestampsDesc": "传输文件时保留原始修改时间。",
|
||||
"privateKey": "私钥",
|
||||
|
||||
@@ -1803,16 +1803,6 @@
|
||||
"policyAccept": "接受 (自動新增)",
|
||||
"policyPrompt": "提示 (詢問使用者)",
|
||||
"policyStrict": "嚴格 (拒絕未知)",
|
||||
"portableUpdate": "便攜版更新",
|
||||
"portableUpdateCustomMirror": "自訂鏡像 URL",
|
||||
"portableUpdateCustomMirrorDesc": "可使用 {url} 佔位符,也可以填寫 URL 前綴。",
|
||||
"portableUpdateDesc": "使用 GitHub Releases 取得便攜版更新資訊,並選擇 ZIP 下載方式。",
|
||||
"portableUpdateDownloadSource": "下載來源",
|
||||
"portableUpdateDownloadSourceDesc": "鏡像只負責傳輸檔案,下載後會使用 GitHub 提供的 SHA-256 校驗。",
|
||||
"portableUpdateSourceCustom": "自訂鏡像",
|
||||
"portableUpdateSourceGhProxy": "gh-proxy.com",
|
||||
"portableUpdateSourceGhfast": "ghfast.top",
|
||||
"portableUpdateSourceGithub": "GitHub 直連",
|
||||
"preserveTimestamps": "保留時間戳",
|
||||
"preserveTimestampsDesc": "傳輸檔案時保留原始修改時間。",
|
||||
"privateKey": "私鑰",
|
||||
|
||||
Vendored
-2
@@ -752,8 +752,6 @@ export interface GeneralSettings {
|
||||
minimize_to_tray: boolean;
|
||||
boss_key: string | null;
|
||||
confirm_on_close: boolean;
|
||||
portable_update_download_source: "github" | "ghfast" | "gh_proxy" | "custom";
|
||||
portable_update_custom_mirror: string;
|
||||
}
|
||||
|
||||
export type BackgroundImageFit = "cover" | "contain" | "stretch" | "tile";
|
||||
|
||||
Reference in new issue
Block a user