feat(about): add support information

This commit is contained in:
233 committed 2026-08-05 10:54:19 +08:00
1 parent f21b05d996
commit 8bc14d7406
9 files changed
+247 -1

No files matched your search

+2
View File
@@ -193,9 +193,11 @@ windows-sys = { version = "0.61", features = [
"Win32_System_Threading",
] }
windows = { version = "0.61", features = [
"Wdk_System_SystemServices",
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_System_Threading",
"Win32_System_SystemInformation",
"Win32_System_DataExchange",
"Win32_System_Memory",
"Win32_UI_Shell",
+124
View File
@@ -103,6 +103,130 @@ pub fn get_app_runtime_info(
state.info()
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSupportInfo {
os: String,
architecture: String,
runtime: String,
}
#[tauri::command]
pub fn get_support_info(state: tauri::State<'_, crate::runtime::AppRuntime>) -> AppSupportInfo {
AppSupportInfo {
os: operating_system_label(),
architecture: std::env::consts::ARCH.to_string(),
runtime: support_runtime_label(state.portable()).to_string(),
}
}
fn support_runtime_label(portable: bool) -> &'static str {
if portable { "portable" } else { "installed" }
}
fn operating_system_label() -> String {
#[cfg(target_os = "windows")]
{
return windows_version_label();
}
#[cfg(target_os = "macos")]
{
return command_output("sw_vers", &["-productName", "-productVersion"])
.unwrap_or_else(|| "macOS".to_string());
}
#[cfg(target_os = "linux")]
{
if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
let name = contents
.lines()
.find_map(|line| line.strip_prefix("PRETTY_NAME="))
.map(|value| value.trim_matches('"').to_string());
if let Some(name) = name {
return name;
}
}
return "Linux".to_string();
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
std::env::consts::OS.to_string()
}
}
#[cfg(target_os = "windows")]
fn windows_version_label() -> String {
use windows::Wdk::System::SystemServices::RtlGetVersion;
use windows::Win32::System::SystemInformation::OSVERSIONINFOW;
let mut version = OSVERSIONINFOW {
dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32,
..Default::default()
};
// RtlGetVersion queries the current Windows version in-process, avoiding a console process.
if unsafe { RtlGetVersion(&mut version) }.is_ok() {
windows_version_label_from_parts(
version.dwMajorVersion,
version.dwMinorVersion,
version.dwBuildNumber,
)
} else {
"Windows".to_string()
}
}
#[cfg(any(target_os = "windows", test))]
fn windows_version_label_from_parts(major: u32, minor: u32, build: u32) -> String {
let name = match (major, minor, build) {
(10, 0, 22_000..) => "Windows 11",
(10, 0, _) => "Windows 10",
(6, 3, _) => "Windows 8.1",
(6, 2, _) => "Windows 8",
(6, 1, _) => "Windows 7",
_ => "Windows",
};
format!("{name} ({major}.{minor}.{build})")
}
#[cfg(target_os = "macos")]
fn command_output(program: &str, args: &[&str]) -> Option<String> {
let output = std::process::Command::new(program)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?.trim().to_string();
(!value.is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use super::{support_runtime_label, windows_version_label_from_parts};
#[test]
fn support_runtime_label_matches_installation_mode() {
assert_eq!(support_runtime_label(true), "portable");
assert_eq!(support_runtime_label(false), "installed");
}
#[test]
fn windows_version_label_uses_native_version_parts() {
assert_eq!(
windows_version_label_from_parts(10, 0, 22_631),
"Windows 11 (10.0.22631)"
);
assert_eq!(
windows_version_label_from_parts(10, 0, 19_045),
"Windows 10 (10.0.19045)"
);
}
}
#[tauri::command]
pub fn get_app_lock_state(state: tauri::State<'_, AppLockState>) -> bool {
state.is_locked()
+1
View File
@@ -115,6 +115,7 @@ pub fn run() {
cmd::app::open_download_dir,
cmd::app::open_log_dir,
cmd::app::get_app_runtime_info,
cmd::app::get_support_info,
cmd::app::get_app_lock_state,
cmd::app::set_app_lock_state,
cmd::app::open_child_window,
+66 -1
View File
@@ -1,7 +1,9 @@
import { getName, getVersion } from "@tauri-apps/api/app";
import { openUrl } from "@tauri-apps/plugin-opener";
import { Check, Copy } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -12,6 +14,9 @@ import {
} from "@/components/ui/dialog";
import pkg from "../../../../package.json";
import NyaTermLogo from "../../NyaTermLogo";
import { writeClipboardText } from "@/lib/clipboard";
import { invoke } from "@/lib/invoke";
import type { AppSupportInfo } from "@/types/global";
interface AboutDialogProps {
open: boolean;
@@ -22,17 +27,52 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
const { t } = useTranslation();
const [appName, setAppName] = useState("NyaTerm");
const [appVersion, setAppVersion] = useState("0.1.0");
const [supportInfo, setSupportInfo] = useState<AppSupportInfo>({
os: "unknown",
architecture: "unknown",
runtime: "installed",
});
const [copied, setCopied] = useState(false);
useEffect(() => {
if (open) {
getName().then(setAppName).catch(console.error);
getVersion().then(setAppVersion).catch(console.error);
invoke<AppSupportInfo>("get_support_info")
.then(setSupportInfo)
.catch(() => undefined);
setCopied(false);
}
}, [open]);
const copySupportInfo = async () => {
const text = [
"NyaTerm Support Information",
`Version: ${appVersion}`,
`Operating System: ${supportInfo.os}`,
`Architecture: ${supportInfo.architecture}`,
`Runtime: ${supportInfo.runtime === "portable" ? t("about.portable") : t("about.installed")}`,
].join("\n");
try {
await writeClipboardText(text);
setCopied(true);
toast.success(t("about.supportInfoCopied"));
window.setTimeout(() => setCopied(false), 1800);
} catch {
toast.error(t("about.supportInfoCopyFailed"));
}
};
const supportRows = [
[t("about.version"), appVersion],
[t("about.operatingSystem"), supportInfo.os === "unknown" ? t("about.unknown") : supportInfo.os],
[t("about.architecture"), supportInfo.architecture === "unknown" ? t("about.unknown") : supportInfo.architecture],
[t("about.runtime"), supportInfo.runtime === "portable" ? t("about.portable") : t("about.installed")],
];
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="w-[min(320px,calc(100vw-2rem))] sm:max-w-[320px] flex flex-col items-center p-6 gap-4">
<DialogContent className="w-[min(440px,calc(100vw-2rem))] sm:max-w-[440px] flex flex-col items-center p-6 gap-4">
<DialogHeader className="items-center">
<NyaTermLogo className="w-24 h-24 object-contain" />
<DialogTitle className="text-lg">{appName}</DialogTitle>
@@ -43,6 +83,31 @@ export default function AboutDialog({ open, onClose }: AboutDialogProps) {
{t("about.description")}
</p>
<section className="w-full rounded-md border bg-muted/20 p-3" aria-labelledby="support-info-title">
<div className="flex items-center justify-between gap-3 mb-2">
<h3 id="support-info-title" className="text-sm font-medium">{t("about.supportInfo")}</h3>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 px-2 text-xs"
onClick={() => void copySupportInfo()}
title={t("about.copySupportInfo")}
aria-label={t("about.copySupportInfo")}
>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
{copied ? t("about.supportInfoCopiedShort") : t("about.copySupportInfo")}
</Button>
</div>
<dl className="grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1.5 text-xs">
{supportRows.map(([label, value]) => (
<div className="contents" key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-right font-mono">{value}</dd>
</div>
))}
</dl>
</section>
<div className="flex gap-3 w-full pt-2">
<Button
variant="outline"
+12
View File
@@ -1,8 +1,20 @@
{
"about": {
"architecture": "Architecture",
"close": "Close",
"copySupportInfo": "Copy all",
"description": "A desktop client for SSH-centric operations and mixed terminal workflows.",
"installed": "Installed",
"issues": "Issues",
"operatingSystem": "Operating system",
"portable": "Portable",
"runtime": "Runtime",
"supportInfo": "Support information",
"supportInfoCopied": "Support information copied",
"supportInfoCopiedShort": "Copied",
"supportInfoCopyFailed": "Failed to copy support information",
"unknown": "Unknown",
"version": "Version",
"website": "Website"
},
"activeSessions": {
+12
View File
@@ -1,8 +1,20 @@
{
"about": {
"architecture": "시스템 아키텍처",
"close": "닫기",
"copySupportInfo": "전체 복사",
"description": "SSH 중심 작업과 다양한 터미널 워크플로를 위한 데스크톱 클라이언트입니다.",
"installed": "설치 버전",
"issues": "이슈",
"operatingSystem": "운영 체제",
"portable": "포터블 버전",
"runtime": "실행 환경",
"supportInfo": "지원 정보",
"supportInfoCopied": "지원 정보가 복사되었습니다",
"supportInfoCopiedShort": "복사됨",
"supportInfoCopyFailed": "지원 정보를 복사하지 못했습니다",
"unknown": "알 수 없음",
"version": "소프트웨어 버전",
"website": "웹사이트"
},
"activeSessions": {
+12
View File
@@ -1,8 +1,20 @@
{
"about": {
"architecture": "系统架构",
"close": "关闭",
"copySupportInfo": "复制全部",
"description": "用于 SSH、串口和本地命令行工作的桌面终端客户端。",
"installed": "安装版",
"issues": "问题反馈",
"operatingSystem": "操作系统",
"portable": "便携版",
"runtime": "运行环境",
"supportInfo": "支持信息",
"supportInfoCopied": "支持信息已复制",
"supportInfoCopiedShort": "已复制",
"supportInfoCopyFailed": "支持信息复制失败",
"unknown": "未知",
"version": "软件版本",
"website": "官网"
},
"activeSessions": {
+12
View File
@@ -1,8 +1,20 @@
{
"about": {
"architecture": "系統架構",
"close": "關閉",
"copySupportInfo": "複製全部",
"description": "用於 SSH、序列埠和本機命令列工作的桌面終端客戶端。",
"installed": "安裝版",
"issues": "問題回報",
"operatingSystem": "作業系統",
"portable": "便攜版",
"runtime": "執行環境",
"supportInfo": "支援資訊",
"supportInfoCopied": "支援資訊已複製",
"supportInfoCopiedShort": "已複製",
"supportInfoCopyFailed": "支援資訊複製失敗",
"unknown": "未知",
"version": "軟體版本",
"website": "官方網站"
},
"activeSessions": {
+6
View File
@@ -12,6 +12,12 @@ export interface AppRuntimeInfo {
portableMarkerPath?: string | null;
}
export interface AppSupportInfo {
os: string;
architecture: string;
runtime: "portable" | "installed";
}
/** AI Agent command execution wrapper profile. */
export type AIExecutionProfile = "auto" | "posix" | "powershell" | "cmd" | "send_only" | "disabled";