refactor(updater): unify portable updates on Cloudflare R2

This commit is contained in:
233 committed 2026-07-22 16:30:55 +08:00
1 parent b73ea56135
commit 657b76fc7a
20 files changed
+118 -578

No files matched your search

+36 -1
View File
@@ -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(', '));
});
+1 -1
View File
@@ -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
@@ -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
+11 -11
View File
@@ -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;
});
}
-1
View File
@@ -4306,7 +4306,6 @@ dependencies = [
"russh",
"russh-sftp",
"rusty-leveldb",
"semver",
"serde",
"serde_json",
"serialport",
-2
View File
@@ -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"
+3 -3
View File
@@ -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,
-16
View File
@@ -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(),
}
}
}
+1 -1
View File
@@ -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};
+55 -308
View File
@@ -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]
+2 -114
View File
@@ -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")}
+1 -48
View File
@@ -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")}
-2
View File
@@ -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",
+2 -22
View File
@@ -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,
],
);
-10
View File
@@ -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",
-10
View File
@@ -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": "프로세스 간격(초)",
-10
View File
@@ -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": "私钥",
-10
View File
@@ -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": "私鑰",
-2
View File
@@ -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";