mirror of
https://github.com/VirtualHotBar/NetMount.git
synced 2026-06-02 01:13:32 +08:00
feat: 添加国际化检查脚本和诊断导出功能
新增国际化检查脚本 check-i18n.mjs 用于验证本地化文件的完整性 添加诊断导出功能,支持导出应用日志和配置信息到 zip 文件 更新多个语言文件,补充缺失的翻译项和描述 调整 CI 工作流中 pnpm 和 node 的安装顺序
This commit is contained in:
10
.github/workflows/main.yml
vendored
10
.github/workflows/main.yml
vendored
@@ -98,6 +98,11 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -105,11 +110,6 @@ jobs:
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"check:i18n": "node scripts/check-i18n.mjs",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
@@ -47,4 +48,4 @@
|
||||
"vite": "^5.4.2",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
132
scripts/check-i18n.mjs
Normal file
132
scripts/check-i18n.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// i18n key convention (storage/description):
|
||||
// - Display label key: storage.<raw>
|
||||
// - Description key: description.<id>
|
||||
// - Canonical <id>: lowercase + trim + collapse whitespace (e.g. "WebDav" -> "webdav", "IPFS API" -> "ipfs api")
|
||||
// Runtime also adds alias keys for a few historical case variants to keep backward compatibility.
|
||||
const LOCALES_DIR = path.resolve('src-tauri', 'locales');
|
||||
|
||||
function normalizeStorageId(raw) {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function formatKeyList(keys) {
|
||||
return keys.length <= 8 ? keys.join(', ') : `${keys.slice(0, 8).join(', ')}, ... (+${keys.length - 8})`;
|
||||
}
|
||||
|
||||
async function loadJson(filePath) {
|
||||
const text = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entries = await readdir(LOCALES_DIR, { withFileTypes: true });
|
||||
const localeFiles = entries
|
||||
.filter((e) => e.isFile() && e.name.endsWith('.json'))
|
||||
.map((e) => path.join(LOCALES_DIR, e.name))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
if (localeFiles.length === 0) {
|
||||
console.error(`[check:i18n] No locale json files found under: ${LOCALES_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
for (const filePath of localeFiles) {
|
||||
const localeName = path.basename(filePath);
|
||||
let json;
|
||||
try {
|
||||
json = await loadJson(filePath);
|
||||
} catch (e) {
|
||||
console.error(`[check:i18n] Failed to parse ${localeName}: ${e?.message ?? String(e)}`);
|
||||
hasError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!json || typeof json !== 'object' || Array.isArray(json)) {
|
||||
console.error(`[check:i18n] ${localeName} must be a JSON object`);
|
||||
hasError = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = Object.keys(json);
|
||||
|
||||
// 1) No empty strings
|
||||
const emptyKeys = keys.filter((k) => typeof json[k] === 'string' && json[k].trim() === '');
|
||||
if (emptyKeys.length > 0) {
|
||||
console.error(`[check:i18n] ${localeName}: empty strings found: ${formatKeyList(emptyKeys)}`);
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// 2) storage.* must have description.<normalized id>
|
||||
const storageKeys = keys.filter((k) => k.startsWith('storage.'));
|
||||
const missingDescriptions = [];
|
||||
for (const storageKey of storageKeys) {
|
||||
const suffix = storageKey.slice('storage.'.length);
|
||||
const id = normalizeStorageId(suffix);
|
||||
const descKey = `description.${id}`;
|
||||
if (!isNonEmptyString(json[descKey])) {
|
||||
missingDescriptions.push(`${storageKey} -> ${descKey}`);
|
||||
}
|
||||
}
|
||||
if (missingDescriptions.length > 0) {
|
||||
console.error(
|
||||
`[check:i18n] ${localeName}: storage has but description missing (${missingDescriptions.length})`,
|
||||
);
|
||||
for (const item of missingDescriptions.slice(0, 60)) {
|
||||
console.error(` - ${item}`);
|
||||
}
|
||||
if (missingDescriptions.length > 60) {
|
||||
console.error(` ... (+${missingDescriptions.length - 60})`);
|
||||
}
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// 3) Detect storage case/whitespace duplicates (same normalized id)
|
||||
const groups = new Map();
|
||||
for (const storageKey of storageKeys) {
|
||||
const suffix = storageKey.slice('storage.'.length);
|
||||
const id = normalizeStorageId(suffix);
|
||||
const group = groups.get(id) ?? [];
|
||||
group.push(suffix);
|
||||
groups.set(id, group);
|
||||
}
|
||||
const duplicates = [];
|
||||
for (const [id, rawSuffixes] of groups.entries()) {
|
||||
const unique = [...new Set(rawSuffixes)];
|
||||
if (unique.length > 1) {
|
||||
duplicates.push({ id, variants: unique.sort((a, b) => a.localeCompare(b)) });
|
||||
}
|
||||
}
|
||||
if (duplicates.length > 0) {
|
||||
console.warn(`[check:i18n] ${localeName}: storage key variants share the same id (${duplicates.length})`);
|
||||
for (const d of duplicates.slice(0, 30)) {
|
||||
console.warn(` - ${d.id}: ${d.variants.join(' | ')}`);
|
||||
}
|
||||
if (duplicates.length > 30) {
|
||||
console.warn(` ... (+${duplicates.length - 30})`);
|
||||
}
|
||||
// Do not fail for duplicates; normalize/alias layer handles compatibility.
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[check:i18n] OK');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`[check:i18n] Unexpected error: ${e?.stack ?? e?.message ?? String(e)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ rand = "0.9"
|
||||
reqwest = { version = "0.13", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
zip = "2.4.2"
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-os = "2.3.2"
|
||||
tauri-plugin-fs = "2.4.5"
|
||||
|
||||
@@ -190,6 +190,8 @@
|
||||
"shell:default",
|
||||
"process:default",
|
||||
"os:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -189,9 +189,16 @@
|
||||
"components": "Components",
|
||||
"log": "Log",
|
||||
"start_hide": "Hide Window on Startup",
|
||||
"close_to_tray": "Minimize to Tray on Close",
|
||||
"auto_recover_components": "Auto-recover Components",
|
||||
"quit": "Quit",
|
||||
"tray_show": "Show Main Window",
|
||||
"open_log_dir": "Open Log Folder",
|
||||
"export_diagnostics": "Export Diagnostics",
|
||||
"diagnostics_exported": "Diagnostics exported",
|
||||
"rclone_restarting": "Rclone is unhealthy; restarting…",
|
||||
"rclone_restarted": "Rclone restarted",
|
||||
"openlist_restarting": "OpenList is unhealthy; restarting…",
|
||||
"openlist_restarted": "OpenList restarted",
|
||||
"tray_hide": "Hide Main Window",
|
||||
"language": "Language",
|
||||
"update_available": "New Version Available",
|
||||
@@ -326,7 +333,6 @@
|
||||
"storage.189Cloud": "189 Cloud",
|
||||
"storage.189CloudPC": "189 Cloud PC",
|
||||
"storage.OpenList V2": "OpenList",
|
||||
"storage.Alias": "Alias",
|
||||
"storage.Aliyundrive": "Aliyun Drive",
|
||||
"storage.AliyundriveOpen": "Aliyun Drive Open",
|
||||
"storage.AliyundriveShare": "Aliyun Drive Share",
|
||||
@@ -335,30 +341,20 @@
|
||||
"storage.BaiduShare": "Baidu Cloud Share",
|
||||
"storage.ChaoXingGroupDrive": "ChaoXing Group Drive",
|
||||
"storage.Cloudreve": "Cloudreve",
|
||||
"storage.Crypt": "Crypt Encryption",
|
||||
"storage.Doge": "Doge Cloud",
|
||||
"storage.Dropbox": "Dropbox",
|
||||
"storage.FTP": "FTP Server",
|
||||
"storage.FeijiPan": "Feiji Pan",
|
||||
"storage.GoogleDrive": "Google Drive",
|
||||
"storage.GooglePhoto": "Google Photos",
|
||||
"storage.ILanZou": "ILanZou",
|
||||
"storage.IPFS API": "IPFS API",
|
||||
"storage.Lanzou": "Lanzou",
|
||||
"storage.Local": "Local Storage",
|
||||
"storage.MediaTrack": "MediaTrack",
|
||||
"storage.Mega_nz": "Mega",
|
||||
"storage.MoPan": "MoPan",
|
||||
"storage.Onedrive": "OneDrive",
|
||||
"storage.OnedriveAPP": "OneDrive APP",
|
||||
"storage.PikPak": "PikPak",
|
||||
"storage.PikPakShare": "PikPak Share",
|
||||
"storage.Quark": "Quark Cloud",
|
||||
"storage.Quqi": "Quqi Cloud",
|
||||
"storage.S3": "S3 Object Storage",
|
||||
"storage.SFTP": "SFTP",
|
||||
"storage.SMB": "SMB",
|
||||
"storage.Seafile": "Seafile",
|
||||
"storage.Teambition": "Teambition",
|
||||
"storage.Terabox": "Terabox",
|
||||
"storage.Thunder": "Thunder Cloud",
|
||||
@@ -369,7 +365,6 @@
|
||||
"storage.UrlTree": "URL Tree",
|
||||
"storage.VTencent": "V Tencent",
|
||||
"storage.Virtual": "Virtual Storage",
|
||||
"storage.WebDav": "WebDAV",
|
||||
"storage.WeiYun": "Weiyun",
|
||||
"storage.WoPan": "WoPan",
|
||||
"storage.YandexDisk": "Yandex Disk",
|
||||
@@ -430,6 +425,7 @@
|
||||
"storage.drive": "Google Drive",
|
||||
"storage.gcs": "Google Cloud Storage",
|
||||
"storage.onedriveshare": "OneDrive Share Link",
|
||||
"storage.onedrive sharelink": "OneDrive Share Link",
|
||||
"storage.189cloudtv": "189 Cloud TV",
|
||||
"storage.115 open": "115 Cloud Open",
|
||||
"storage.NeteaseMusic": "NetEase Cloud Music",
|
||||
@@ -499,6 +495,7 @@
|
||||
"description.baiduphoto": "Yi Ke Album is a photo stoshare, which is convenient for collaboration and distribution.",
|
||||
"description.123panshare": "123 Pan Share functionality enables users to share files stored on 123 Pan, facilitating collaboration and distribution.",
|
||||
"description.onedrive sharelink": "OneDrive Share Link allows users to share files stored on OneDrive with others, facilitating collaboration and distribution.",
|
||||
"description.onedriveshare": "OneDrive Share Link allows users to share files stored on OneDrive with others, facilitating collaboration and distribution.",
|
||||
"description.189cloudtv": "189 Cloud TV interface",
|
||||
"description.115 open": "115 Official Open API",
|
||||
"description.lark": "Lark Drive lets you save and manage all your content in cloud storage anytime, anywhere, on any device.",
|
||||
@@ -521,6 +518,63 @@
|
||||
"description.lenovonasshare": "Lenovo NAS Share: Access shared resources on Lenovo NAS devices.",
|
||||
"description.misskey": "Misskey is a decentralized social platform, and this driver is used to access its file/attachment resources.",
|
||||
"description.thunderbrowserexpert": "Thunder Browser Expert Edition related file resource access method.",
|
||||
"description.115 cloud": "115 Cloud Storage: Store, manage, and access files in your 115 Cloud account.",
|
||||
"description.115 share": "115 Cloud Share: Access files via 115 share links.",
|
||||
"description.123pan": "123 Pan is a cloud storage service for storing and managing files online.",
|
||||
"description.123panlink": "123 Pan Direct Link: Access files via a direct link.",
|
||||
"description.azureblob": "Azure Blob Storage is Microsoft Azure's object storage service, suitable for storing massive amounts of unstructured data.",
|
||||
"description.azurefiles": "Azure Files provides fully managed SMB file shares in Microsoft Azure.",
|
||||
"description.b2": "Backblaze B2 is an object storage service offering durable, low-cost storage for files and backups.",
|
||||
"description.baidushare": "Baidu Cloud Share: Access files via Baidu share links.",
|
||||
"description.cache": "Cache storage: Temporarily stores data to improve performance.",
|
||||
"description.chunker": "Chunker: Splits large files into smaller chunks for transfer or processing.",
|
||||
"description.combine": "Combine: Combines multiple sources into a single unified view.",
|
||||
"description.compress": "Compress: Stores files in compressed form to save space.",
|
||||
"description.drive": "Google Drive is a cloud storage service provided by Google, integrated with the Google ecosystem, supporting document collaboration, backup, and sharing.",
|
||||
"description.feijipan": "Feiji Pan is a cloud storage service for storing and managing files online.",
|
||||
"description.fichier": "Fichier is a file hosting service that provides online storage and sharing.",
|
||||
"description.filefabric": "FileFabric is an enterprise file management and content platform for secure access and sharing.",
|
||||
"description.gcs": "Google Cloud Storage is a cloud storage service from Google Cloud Platform, offering various storage tiers suitable for different performance, cost, and durability requirements.",
|
||||
"description.gphotos": "Google Photos is a photo backup and sharing service provided by Google, allowing users to easily store, organize, and share photos and videos.",
|
||||
"description.hasher": "Hasher: Generates checksums/hashes for files to verify integrity.",
|
||||
"description.hdfs": "HDFS (Hadoop Distributed File System) is a distributed file system designed for large-scale data storage.",
|
||||
"description.hidrive": "HiDrive is a cloud storage service offering file sync, backup, and sharing.",
|
||||
"description.ilanzou": "ILanZou is a Lanzou-related cloud storage/drive service for online files.",
|
||||
"description.imagekit": "ImageKit is a media storage and delivery service for images and videos.",
|
||||
"description.internetarchive": "Internet Archive is a non-profit digital library offering free access to archived content.",
|
||||
"description.ipfs api": "IPFS API: Access IPFS-backed content via an API endpoint.",
|
||||
"description.koofr": "Koofr is a cloud storage service supporting file sync, backup, and sharing.",
|
||||
"description.lanzou": "Lanzou is a cloud storage service commonly used for file sharing and distribution.",
|
||||
"description.linkbox": "Linkbox is a cloud storage/file sharing service for managing online files.",
|
||||
"description.local": "Local storage on this device (use existing folders and disks as a backend).",
|
||||
"description.mailru": "Mail.ru Cloud is a cloud storage service for file synchronization and sharing.",
|
||||
"description.mediatrack": "MediaTrack: Access and manage media resources provided by the MediaTrack service.",
|
||||
"description.mega_nz": "Mega is a cloud storage service that emphasizes user privacy protection, offering end-to-end encryption and large-capacity storage options.",
|
||||
"description.memory": "Memory: Stores data in memory (temporary, not persisted to disk).",
|
||||
"description.mopan": "MoPan is a cloud storage service for storing and managing files online.",
|
||||
"description.netstorage": "NetStorage is a cloud storage service for file hosting and distribution.",
|
||||
"description.oos": "Oracle Object Storage provides durable and scalable object storage on Oracle Cloud.",
|
||||
"description.openlist v2": "OpenList is an open-source file hosting and management application that supports various cloud storage services.",
|
||||
"description.pikpak": "PikPak is a cloud storage service focused on saving and managing files from multiple sources.",
|
||||
"description.pikpakshare": "PikPak Share: Access files via PikPak share links.",
|
||||
"description.premiumizeme": "Premiumize.me is a multi-hoster and cloud service for downloading, caching, and storing files.",
|
||||
"description.protondrive": "Proton Drive is an encrypted cloud storage service focused on privacy and security.",
|
||||
"description.putio": "Put.io is a cloud storage and download manager that can fetch content from various sources.",
|
||||
"description.quark": "Quark Cloud is a cloud storage service for file storage, synchronization, and sharing.",
|
||||
"description.quatrix": "Quatrix is a secure file sharing and collaboration platform for enterprises.",
|
||||
"description.sharefile": "ShareFile is a secure file sharing and collaboration service (Citrix ShareFile).",
|
||||
"description.sia": "Sia is a decentralized cloud storage platform.",
|
||||
"description.smb": "SMB (Server Message Block) is a network file sharing protocol for accessing shared folders and files on a LAN.",
|
||||
"description.storj": "Storj is a decentralized cloud object storage service.",
|
||||
"description.sugarsync": "SugarSync is a cloud file synchronization and backup service.",
|
||||
"description.tardigrade": "Tardigrade (Storj) provides decentralized object storage built on the Storj network.",
|
||||
"description.thunderexpert": "Thunder Expert Edition: Access Thunder-related cloud disk resources via the Expert driver.",
|
||||
"description.uc": "UC Cloud is a cloud storage service provided by UC Browser, allowing users to conveniently access and manage files through UC Browser.",
|
||||
"description.union": "Union: Combines multiple remotes into a single virtual remote.",
|
||||
"description.uptobox": "UpToBox is a file hosting service providing online storage and sharing.",
|
||||
"description.urltree": "URL Tree: Organize and access multiple URLs as a tree-like virtual filesystem.",
|
||||
"description.virtual": "Virtual storage: A virtualized backend that combines or transforms other storages.",
|
||||
"description.zoho": "Zoho WorkDrive/Zoho storage services provide cloud file storage and collaboration.",
|
||||
"restartself_to_take_effect": "Restart to take effect",
|
||||
"unable_to_obtain_transmission_speed":"The specific transmission speed may not be available at present, but the transmission is still in progress.",
|
||||
|
||||
|
||||
@@ -197,9 +197,16 @@
|
||||
"components": "组件",
|
||||
"log": "日志",
|
||||
"start_hide": "启动时隐藏窗口",
|
||||
"close_to_tray": "关闭窗口最小化到托盘",
|
||||
"auto_recover_components": "组件自愈",
|
||||
"quit": "退出",
|
||||
"tray_show": "显示主窗口",
|
||||
"open_log_dir": "打开日志目录",
|
||||
"export_diagnostics": "导出诊断包",
|
||||
"diagnostics_exported": "诊断包已导出",
|
||||
"rclone_restarting": "Rclone 异常,正在自动重启…",
|
||||
"rclone_restarted": "Rclone 已重启",
|
||||
"openlist_restarting": "OpenList 异常,正在自动重启…",
|
||||
"openlist_restarted": "OpenList 已重启",
|
||||
"tray_hide": "隐藏主窗口",
|
||||
"language": "语言",
|
||||
"update_available": "发现新版本",
|
||||
@@ -338,7 +345,6 @@
|
||||
"storage.189Cloud": "天翼云盘",
|
||||
"storage.189CloudPC": "天翼云盘 PC",
|
||||
"storage.OpenList": "OpenList",
|
||||
"storage.Alias": "别名",
|
||||
"storage.Aliyundrive": "阿里云盘",
|
||||
"storage.AliyundriveOpen": "阿里云盘 Open",
|
||||
"storage.AliyundriveShare": "阿里云盘分享",
|
||||
@@ -347,30 +353,20 @@
|
||||
"storage.BaiduShare": "百度网盘分享",
|
||||
"storage.ChaoXingGroupDrive": "超星小组网盘",
|
||||
"storage.Cloudreve": "Cloudreve",
|
||||
"storage.Crypt": "Crypt 加密",
|
||||
"storage.Doge": "多吉云",
|
||||
"storage.Dropbox": "Dropbox",
|
||||
"storage.FTP": "FTP服务器",
|
||||
"storage.FeijiPan": "小飞机网盘",
|
||||
"storage.GoogleDrive": "Google Drive",
|
||||
"storage.GooglePhoto": "Google 相册",
|
||||
"storage.ILanZou": "蓝奏优享版",
|
||||
"storage.IPFS API": "IPFS API",
|
||||
"storage.Lanzou": "蓝奏云",
|
||||
"storage.Local": "本地存储",
|
||||
"storage.MediaTrack": "分秒帧",
|
||||
"storage.Mega_nz": "Mega",
|
||||
"storage.MoPan": "MoPan魔盘",
|
||||
"storage.Onedrive": "OneDrive",
|
||||
"storage.OnedriveAPP": "OneDrive APP",
|
||||
"storage.PikPak": "PikPak",
|
||||
"storage.PikPakShare": "PikPak分享",
|
||||
"storage.Quark": "夸克网盘",
|
||||
"storage.Quqi": "曲奇云盘",
|
||||
"storage.S3": "S3 对象存储",
|
||||
"storage.SFTP": "SFTP",
|
||||
"storage.SMB": "SMB",
|
||||
"storage.Seafile": "Seafile网盘",
|
||||
"storage.Teambition": "Teambition网盘",
|
||||
"storage.Terabox": "Terabox网盘",
|
||||
"storage.Thunder": "迅雷云盘",
|
||||
@@ -381,7 +377,6 @@
|
||||
"storage.UrlTree": "链接树",
|
||||
"storage.VTencent": "腾讯智能创作平台",
|
||||
"storage.Virtual": "虚拟存储",
|
||||
"storage.WebDav": "WebDAV",
|
||||
"storage.WeiYun": "腾讯微云",
|
||||
"storage.WoPan": "联通云盘",
|
||||
"storage.YandexDisk": "Yandex Disk",
|
||||
@@ -442,6 +437,7 @@
|
||||
"storage.drive": "Google Drive",
|
||||
"storage.gcs": "Google Cloud Storage",
|
||||
"storage.onedriveshare": "OneDrive 分享链接",
|
||||
"storage.onedrive sharelink": "OneDrive 分享链接",
|
||||
"storage.189cloudtv": "天翼云盘 TV",
|
||||
"storage.115 open": "115网盘 Open",
|
||||
"storage.NeteaseMusic": "网易云音乐",
|
||||
@@ -568,6 +564,7 @@
|
||||
"description.123panlink": "123网盘直链服务允许用户获取文件的直接下载链接,便于快速访问和传输文件。",
|
||||
"description.123panshare": "123网盘分享功能使用户能够将存储在123网盘上的文件与他人共享,便于协作和分发。",
|
||||
"description.onedrive sharelink": "OneDrive 分享链接允许用户将存储在 OneDrive 上的文件与他人共享,便于协作和分发。",
|
||||
"description.onedriveshare": "OneDrive 分享链接允许用户将存储在 OneDrive 上的文件与他人共享,便于协作和分发。",
|
||||
"description.189cloudtv": "天翼云盘的TV接口",
|
||||
"description.115 open": "115 官方开放API",
|
||||
"description.lark": "Lark Drive允许您随时随地在任何设备上保存和管理云存储中的所有内容。",
|
||||
|
||||
@@ -197,9 +197,16 @@
|
||||
"components": "組件",
|
||||
"log": "日誌",
|
||||
"start_hide": "啟動時隱藏視窗",
|
||||
"close_to_tray": "關閉視窗最小化到托盤",
|
||||
"auto_recover_components": "元件異常自動修復",
|
||||
"quit": "退出",
|
||||
"tray_show": "顯示主視窗",
|
||||
"open_log_dir": "打開日誌目錄",
|
||||
"export_diagnostics": "匯出診斷包",
|
||||
"diagnostics_exported": "診斷包已匯出",
|
||||
"rclone_restarting": "Rclone 異常,正在自動重啟…",
|
||||
"rclone_restarted": "Rclone 已重啟",
|
||||
"openlist_restarting": "OpenList 異常,正在自動重啟…",
|
||||
"openlist_restarted": "OpenList 已重啟",
|
||||
"tray_hide": "隱藏主視窗",
|
||||
"language": "語言",
|
||||
"update_available": "發現新版本",
|
||||
@@ -338,7 +345,6 @@
|
||||
"storage.189Cloud": "天翼雲盤",
|
||||
"storage.189CloudPC": "天翼雲盤 PC",
|
||||
"storage.OpenList": "OpenList",
|
||||
"storage.Alias": "別名",
|
||||
"storage.Aliyundrive": "阿裡雲盤",
|
||||
"storage.AliyundriveOpen": "阿裡雲盤 Open",
|
||||
"storage.AliyundriveShare": "阿裡雲盤分享",
|
||||
@@ -347,30 +353,20 @@
|
||||
"storage.BaiduShare": "百度網盤分享",
|
||||
"storage.ChaoXingGroupDrive": "超星小組網盤",
|
||||
"storage.Cloudreve": "Cloudreve",
|
||||
"storage.Crypt": "Crypt 加密",
|
||||
"storage.Doge": "多吉雲",
|
||||
"storage.Dropbox": "Dropbox",
|
||||
"storage.FTP": "FTP伺服器",
|
||||
"storage.FeijiPan": "小飛機網盤",
|
||||
"storage.GoogleDrive": "Google Drive",
|
||||
"storage.GooglePhoto": "Google 相冊",
|
||||
"storage.ILanZou": "藍奏優享版",
|
||||
"storage.IPFS API": "IPFS API",
|
||||
"storage.Lanzou": "藍奏雲",
|
||||
"storage.Local": "本機存放區",
|
||||
"storage.MediaTrack": "分秒幀",
|
||||
"storage.Mega_nz": "Mega",
|
||||
"storage.MoPan": "MoPan魔盤",
|
||||
"storage.Onedrive": "OneDrive",
|
||||
"storage.OnedriveAPP": "OneDrive APP",
|
||||
"storage.PikPak": "PikPak",
|
||||
"storage.PikPakShare": "PikPak分享",
|
||||
"storage.Quark": "誇克網盤",
|
||||
"storage.Quqi": "曲奇雲盤",
|
||||
"storage.S3": "S3 物件存儲",
|
||||
"storage.SFTP": "SFTP",
|
||||
"storage.SMB": "SMB",
|
||||
"storage.Seafile": "Seafile網盤",
|
||||
"storage.Teambition": "Teambition網盤",
|
||||
"storage.Terabox": "Terabox網盤",
|
||||
"storage.Thunder": "迅雷雲盤",
|
||||
@@ -381,7 +377,6 @@
|
||||
"storage.UrlTree": "連結樹",
|
||||
"storage.VTencent": "騰訊智能創作平臺",
|
||||
"storage.Virtual": "虛擬存儲",
|
||||
"storage.WebDav": "WebDAV",
|
||||
"storage.WeiYun": "騰訊微雲",
|
||||
"storage.WoPan": "聯通雲盤",
|
||||
"storage.YandexDisk": "Yandex Disk",
|
||||
@@ -442,6 +437,7 @@
|
||||
"storage.drive": "Google Drive",
|
||||
"storage.gcs": "Google Cloud Storage",
|
||||
"storage.onedriveshare": "OneDrive 分享連結",
|
||||
"storage.onedrive sharelink": "OneDrive 分享連結",
|
||||
"storage.189cloudtv": "天翼雲盤 TV",
|
||||
"storage.115 open": "115網盤 Open",
|
||||
"storage.NeteaseMusic": "網易雲音樂",
|
||||
@@ -568,6 +564,7 @@
|
||||
"description.123panlink": "123網盤直鏈服務允許使用者獲取檔的直接下載連結,便於快速訪問和傳輸檔。",
|
||||
"description.123panshare": "123網盤分享功能使使用者能夠將存儲在123網盤上的檔與他人共用,便於協作和分發。",
|
||||
"description.onedrive sharelink": "OneDrive 分享連結允許用戶將存儲在 OneDrive 上的文件與他人共享,便於協作和分發。",
|
||||
"description.onedriveshare": "OneDrive 分享連結允許用戶將存儲在 OneDrive 上的文件與他人共享,便於協作和分發。",
|
||||
"description.189cloudtv": "天翼雲盤的TV接口",
|
||||
"description.115 open": "115 官方開放API",
|
||||
"description.lark": "Lark Drive讓您隨時隨地在任何裝置上保存和管理雲端儲存的所有內容。",
|
||||
|
||||
@@ -17,7 +17,7 @@ impl Default for Config {
|
||||
"mount": { "lists": [] },
|
||||
"task": [],
|
||||
"api": { "url": "https://api.hotpe.top/API/NetMount" },
|
||||
"settings": { "themeMode": "auto", "startHide": false, "closeToTray": true },
|
||||
"settings": { "themeMode": "auto", "startHide": false, "autoRecoverComponents": true },
|
||||
"framework": {
|
||||
"rclone": { "user": random_str(32), "password": random_str(128) },
|
||||
"openlist": { "user": "admin", "password": random_str(16) }
|
||||
|
||||
258
src-tauri/src/diagnostics.rs
Normal file
258
src-tauri/src/diagnostics.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use tauri::Manager as _;
|
||||
|
||||
use crate::Runtime;
|
||||
|
||||
fn resolve_tilde(app: &tauri::AppHandle<Runtime>, path: &str) -> anyhow::Result<PathBuf> {
|
||||
if path.starts_with('~') {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))?;
|
||||
let rest = path
|
||||
.trim_start_matches('~')
|
||||
.trim_start_matches(['/', '\\']);
|
||||
if rest.is_empty() {
|
||||
Ok(home)
|
||||
} else {
|
||||
Ok(home.join(rest))
|
||||
}
|
||||
} else {
|
||||
Ok(Path::new(path).to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn app_data_dir(app: &tauri::AppHandle<Runtime>) -> anyhow::Result<PathBuf> {
|
||||
Ok(app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))?
|
||||
.join(".netmount"))
|
||||
}
|
||||
|
||||
fn ensure_under_app_data_dir(app: &tauri::AppHandle<Runtime>, candidate: &Path) -> anyhow::Result<()> {
|
||||
let base = app_data_dir(app)?;
|
||||
let base = base.canonicalize().unwrap_or(base);
|
||||
let candidate = candidate
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| candidate.to_owned());
|
||||
if !candidate.starts_with(&base) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Access denied: only files under {} are allowed",
|
||||
base.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_file_tail(path: &Path, max_bytes: u64) -> anyhow::Result<Vec<u8>> {
|
||||
let max_bytes = max_bytes.max(1024);
|
||||
let mut file = fs::File::open(path)?;
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let start = len.saturating_sub(max_bytes);
|
||||
file.seek(SeekFrom::Start(start))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn redact_json(value: &mut serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
for (k, v) in map.iter_mut() {
|
||||
let key = k.to_ascii_lowercase();
|
||||
let is_sensitive = matches!(
|
||||
key.as_str(),
|
||||
"password"
|
||||
| "pass"
|
||||
| "passwd"
|
||||
| "token"
|
||||
| "secret"
|
||||
| "access_key"
|
||||
| "accesskey"
|
||||
| "refresh_token"
|
||||
| "client_secret"
|
||||
| "private_key"
|
||||
| "apikey"
|
||||
| "api_key"
|
||||
);
|
||||
if is_sensitive {
|
||||
*v = Value::String("***REDACTED***".into());
|
||||
} else {
|
||||
redact_json(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
for v in arr.iter_mut() {
|
||||
redact_json(v);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn zip_add_bytes<W: Write + Seek>(
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
zip.start_file(name, options)?;
|
||||
zip.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zip_add_string<W: Write + Seek>(
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
name: &str,
|
||||
s: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
zip_add_bytes(zip, name, s.as_bytes())
|
||||
}
|
||||
|
||||
fn maybe_add_tail_file<W: Write + Seek>(
|
||||
app: &tauri::AppHandle<Runtime>,
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
entry_name: &str,
|
||||
path: &Path,
|
||||
max_bytes: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_under_app_data_dir(app, path)?;
|
||||
let data = read_file_tail(path, max_bytes)?;
|
||||
zip_add_bytes(zip, entry_name, &data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn maybe_add_redacted_json_file<W: Write + Seek>(
|
||||
app: &tauri::AppHandle<Runtime>,
|
||||
zip: &mut zip::ZipWriter<W>,
|
||||
entry_name: &str,
|
||||
path: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_under_app_data_dir(app, path)?;
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut json: serde_json::Value = serde_json::from_str(&content)?;
|
||||
redact_json(&mut json);
|
||||
let pretty = serde_json::to_string_pretty(&json)?;
|
||||
zip_add_string(zip, entry_name, &pretty)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_diagnostics(
|
||||
app: tauri::AppHandle<Runtime>,
|
||||
out_path: String,
|
||||
) -> anyhow_tauri::TAResult<String> {
|
||||
fn inner(app: &tauri::AppHandle<Runtime>, out_path: &str) -> anyhow::Result<String> {
|
||||
let out_path = out_path.trim();
|
||||
if out_path.is_empty() {
|
||||
return Err(anyhow::anyhow!("Output path is required"));
|
||||
}
|
||||
if !out_path.to_ascii_lowercase().ends_with(".zip") {
|
||||
return Err(anyhow::anyhow!("Output file must end with .zip"));
|
||||
}
|
||||
|
||||
let out = resolve_tilde(app, out_path)?;
|
||||
if let Some(parent) = out.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).map_err(anyhow::Error::from)?;
|
||||
}
|
||||
}
|
||||
|
||||
let file = fs::File::create(&out).map_err(anyhow::Error::from)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
|
||||
let meta = serde_json::json!({
|
||||
"app": "NetMount",
|
||||
"app_version": env!("CARGO_PKG_VERSION"),
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"timestamp_unix_ms": now_ms(),
|
||||
});
|
||||
let meta_str = serde_json::to_string_pretty(&meta).map_err(anyhow::Error::from)?;
|
||||
zip_add_string(&mut zip, "meta.json", &meta_str)?;
|
||||
|
||||
let data_dir = app_data_dir(app)?;
|
||||
|
||||
// redacted configs
|
||||
if let Err(e) = maybe_add_redacted_json_file(
|
||||
app,
|
||||
&mut zip,
|
||||
"netmount/config.redacted.json",
|
||||
&data_dir.join("config.json"),
|
||||
) {
|
||||
warnings.push(format!("netmount config: {}", e));
|
||||
}
|
||||
if let Err(e) = maybe_add_redacted_json_file(
|
||||
app,
|
||||
&mut zip,
|
||||
"openlist/config.redacted.json",
|
||||
&data_dir.join("openlist").join("config.json"),
|
||||
) {
|
||||
warnings.push(format!("openlist config: {}", e));
|
||||
}
|
||||
|
||||
// log tails
|
||||
if let Err(e) = maybe_add_tail_file(
|
||||
app,
|
||||
&mut zip,
|
||||
"logs/rclone.log.tail",
|
||||
&data_dir.join("log").join("rclone.log"),
|
||||
512 * 1024,
|
||||
) {
|
||||
warnings.push(format!("rclone log: {}", e));
|
||||
}
|
||||
if let Err(e) = maybe_add_tail_file(
|
||||
app,
|
||||
&mut zip,
|
||||
"logs/netmount.log.tail",
|
||||
&data_dir.join("log").join("netmount.log"),
|
||||
512 * 1024,
|
||||
) {
|
||||
warnings.push(format!("netmount log: {}", e));
|
||||
}
|
||||
if let Err(e) = maybe_add_tail_file(
|
||||
app,
|
||||
&mut zip,
|
||||
"logs/openlist.log.tail",
|
||||
&data_dir.join("openlist").join("log").join("log.log"),
|
||||
512 * 1024,
|
||||
) {
|
||||
warnings.push(format!("openlist log: {}", e));
|
||||
}
|
||||
|
||||
if !warnings.is_empty() {
|
||||
let content = warnings.join("\n");
|
||||
let _ = zip_add_string(&mut zip, "warnings.txt", &content);
|
||||
}
|
||||
|
||||
zip.finish().map_err(anyhow::Error::from)?;
|
||||
Ok(out.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
inner(&app, &out_path).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn now_ms() -> u128 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use locale::Locale;
|
||||
use tray::Tray;
|
||||
|
||||
mod config;
|
||||
mod diagnostics;
|
||||
mod fs;
|
||||
mod locale;
|
||||
mod sidecar;
|
||||
@@ -181,29 +182,6 @@ pub fn init() -> anyhow::Result<()> {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.on_window_event(|window, event| match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
let close_to_tray = window
|
||||
.app_handle()
|
||||
.with_app_state::<Config, _>(|config| {
|
||||
config
|
||||
.0
|
||||
.get("settings")
|
||||
.and_then(|s| s.get("closeToTray"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
if close_to_tray {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
// Ensure sidecars don't get orphaned on Unix when quitting via window close.
|
||||
sidecar::cleanup();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| {
|
||||
if let Some(window) = app.app_main_window() {
|
||||
let _ = window.toggle_visibility(Some(true));
|
||||
@@ -219,6 +197,7 @@ pub fn init() -> anyhow::Result<()> {
|
||||
update_config,
|
||||
get_language_pack,
|
||||
download_file,
|
||||
diagnostics::export_diagnostics,
|
||||
get_autostart_state,
|
||||
set_autostart_state,
|
||||
get_winfsp_install_state,
|
||||
|
||||
@@ -4,11 +4,13 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import i18n from "../../services/i18n";
|
||||
import { roConfig } from "../../services/config";
|
||||
import { hooks } from "../../services/hook";
|
||||
import { normalizeI18nPack } from "../../services/i18nPack";
|
||||
|
||||
async function setLocalized(lang: string) {
|
||||
lang = lang.toLowerCase();
|
||||
|
||||
const pack: Record<string, string> = await invoke("get_language_pack");
|
||||
const rawPack: Record<string, string> = await invoke("get_language_pack");
|
||||
const pack = normalizeI18nPack(rawPack);
|
||||
i18n.addResourceBundle(lang, "translation", pack)
|
||||
i18n.changeLanguage(lang);
|
||||
hooks.setLocaleStr(getLangCode(lang));
|
||||
|
||||
@@ -6,9 +6,9 @@ import { startUpdateCont } from "./stats/continue"
|
||||
import { reupMount } from "./storage/mount/mount"
|
||||
import { reupStorage } from "./storage/storage"
|
||||
import { listenWindow, windowsHide } from "./window"
|
||||
import { formatPath, sleep } from "../utils/utils"
|
||||
import { sleep } from "../utils/utils"
|
||||
import { t } from "i18next"
|
||||
import { startRclone, stopRclone } from "../utils/rclone/process"
|
||||
import { restartRclone, startRclone, stopRclone } from "../utils/rclone/process"
|
||||
import { getOsInfo } from "../utils/tauri/osInfo"
|
||||
import { startTaskScheduler } from "./task/task"
|
||||
import { autoMount } from "./task/autoMount"
|
||||
@@ -16,15 +16,20 @@ import { setThemeMode } from "./setting/setting"
|
||||
import { setLocalized } from "./language/localized"
|
||||
import { checkNotice } from "./update/notice"
|
||||
import { updateStorageInfoList } from "./storage/allList"
|
||||
import { startOpenlist, stopOpenlist } from "../utils/openlist/process"
|
||||
import { restartOpenlist, startOpenlist, stopOpenlist } from "../utils/openlist/process"
|
||||
import { homeDir } from "@tauri-apps/api/path"
|
||||
import { openlist_api_get } from "../utils/openlist/request"
|
||||
import { openlist_api_get, openlist_api_ping } from "../utils/openlist/request"
|
||||
import { openlistInfo } from "../services/openlist"
|
||||
import { addOpenlistInRclone } from "../utils/openlist/openlist"
|
||||
import { Notification } from "@arco-design/web-react"
|
||||
import { rclone_api_noop } from "../utils/rclone/request"
|
||||
import { defaultCacheDir } from "../utils/netmountPaths"
|
||||
|
||||
type SetStartStrFn = (str: string) => void;
|
||||
|
||||
let componentWatchdogTimer: number | undefined
|
||||
let componentWatchdogStopping = false
|
||||
|
||||
async function init(setStartStr: SetStartStrFn) {
|
||||
|
||||
setStartStr(t('init'))
|
||||
@@ -54,7 +59,7 @@ async function init(setStartStr: SetStartStrFn) {
|
||||
|
||||
//设置缓存路径
|
||||
if (!nmConfig.settings.path.cacheDir) {
|
||||
nmConfig.settings.path.cacheDir=formatPath(roConfig.env.path.homeDir+'/.cache/netmount', osInfo.platform === "windows")
|
||||
nmConfig.settings.path.cacheDir = defaultCacheDir()
|
||||
}
|
||||
|
||||
setThemeMode(nmConfig.settings.themeMode)
|
||||
@@ -84,6 +89,8 @@ async function init(setStartStr: SetStartStrFn) {
|
||||
//开始任务队列
|
||||
await startTaskScheduler()
|
||||
|
||||
startComponentWatchdog()
|
||||
|
||||
await main()
|
||||
}
|
||||
|
||||
@@ -157,6 +164,11 @@ async function reupOpenlistVersion() {
|
||||
|
||||
|
||||
async function exit(isRestartSelf: boolean = false) {
|
||||
componentWatchdogStopping = true
|
||||
if (componentWatchdogTimer) {
|
||||
clearInterval(componentWatchdogTimer)
|
||||
componentWatchdogTimer = undefined
|
||||
}
|
||||
try {
|
||||
await saveNmConfig()
|
||||
await stopRclone()
|
||||
@@ -172,4 +184,87 @@ async function exit(isRestartSelf: boolean = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export { init, main, exit }
|
||||
function startComponentWatchdog() {
|
||||
componentWatchdogStopping = false
|
||||
if (componentWatchdogTimer) {
|
||||
clearInterval(componentWatchdogTimer)
|
||||
componentWatchdogTimer = undefined
|
||||
}
|
||||
|
||||
let running = false
|
||||
let rcloneFailCount = 0
|
||||
let openlistFailCount = 0
|
||||
let rcloneCooldownUntil = 0
|
||||
let openlistCooldownUntil = 0
|
||||
|
||||
const COOLDOWN_MS = 60_000
|
||||
const INTERVAL_MS = 10_000
|
||||
const FAIL_THRESHOLD = 3
|
||||
|
||||
componentWatchdogTimer = window.setInterval(async () => {
|
||||
if (componentWatchdogStopping) return
|
||||
if (!nmConfig.settings.autoRecoverComponents) return
|
||||
if (running) return
|
||||
running = true
|
||||
|
||||
try {
|
||||
const now = Date.now()
|
||||
|
||||
if (rcloneInfo.process.child) {
|
||||
const ok = await rclone_api_noop()
|
||||
rcloneFailCount = ok ? 0 : rcloneFailCount + 1
|
||||
if (!ok && rcloneFailCount >= FAIL_THRESHOLD && now >= rcloneCooldownUntil) {
|
||||
rcloneCooldownUntil = now + COOLDOWN_MS
|
||||
rcloneFailCount = 0
|
||||
Notification.warning({
|
||||
id: 'rclone_auto_recover',
|
||||
title: t('transmit'),
|
||||
content: t('rclone_restarting'),
|
||||
})
|
||||
try {
|
||||
await restartRclone()
|
||||
Notification.success({
|
||||
id: 'rclone_auto_recover_ok',
|
||||
title: t('success'),
|
||||
content: t('rclone_restarted'),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('restartRclone failed:', e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rcloneFailCount = 0
|
||||
}
|
||||
|
||||
if (openlistInfo.process.child) {
|
||||
const ok = await openlist_api_ping()
|
||||
openlistFailCount = ok ? 0 : openlistFailCount + 1
|
||||
if (!ok && openlistFailCount >= FAIL_THRESHOLD && now >= openlistCooldownUntil) {
|
||||
openlistCooldownUntil = now + COOLDOWN_MS
|
||||
openlistFailCount = 0
|
||||
Notification.warning({
|
||||
id: 'openlist_auto_recover',
|
||||
title: t('storage'),
|
||||
content: t('openlist_restarting'),
|
||||
})
|
||||
try {
|
||||
await restartOpenlist()
|
||||
Notification.success({
|
||||
id: 'openlist_auto_recover_ok',
|
||||
title: t('success'),
|
||||
content: t('openlist_restarted'),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('restartOpenlist failed:', e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openlistFailCount = 0
|
||||
}
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}, INTERVAL_MS)
|
||||
}
|
||||
|
||||
export { init, main, exit }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { StorageInfoType, StorageParamItemType } from "../../../../type/controller/storage/info";
|
||||
import { openlist_api_get } from "../../../../utils/openlist/request";
|
||||
|
||||
function normalizeStorageId(raw: string): string {
|
||||
return String(raw ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
// 结构A:对象映射 { driverName: {config, common, additional} }
|
||||
// 结构B:数组 [ {name, config, common, additional} ]
|
||||
|
||||
@@ -207,9 +211,9 @@ async function updateOpenlistStorageInfoList() {
|
||||
};
|
||||
|
||||
openlistStorageInfoList.push({
|
||||
label: 'storage.' + config.name,
|
||||
label: 'storage.' + normalizeStorageId(config.name ?? key),
|
||||
type: key,
|
||||
description: 'description.' + key.toLocaleLowerCase(),
|
||||
description: 'description.' + normalizeStorageId(key),
|
||||
framework: 'openlist',
|
||||
defaultParams: {
|
||||
name: config.name + '_new',
|
||||
@@ -287,9 +291,9 @@ async function updateOpenlistStorageInfoListFallback(): Promise<StorageInfoType[
|
||||
};
|
||||
|
||||
openlistStorageInfoList.push({
|
||||
label: 'storage.' + config.name,
|
||||
label: 'storage.' + normalizeStorageId(config.name ?? driverName),
|
||||
type: driverName,
|
||||
description: 'description.' + driverName.toLocaleLowerCase(),
|
||||
description: 'description.' + normalizeStorageId(driverName),
|
||||
framework: 'openlist',
|
||||
defaultParams: {
|
||||
name: config.name + '_new',
|
||||
@@ -318,4 +322,4 @@ async function updateOpenlistStorageInfoListFallback(): Promise<StorageInfoType[
|
||||
}
|
||||
|
||||
|
||||
export { updateOpenlistStorageInfoList }
|
||||
export { updateOpenlistStorageInfoList }
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FilterType, ParamItemOptionType, StorageInfoType, StorageParamItemType, RcloneProvider } from "../../../../type/controller/storage/info";
|
||||
import { rclone_api_post } from "../../../../utils/rclone/request";
|
||||
|
||||
function normalizeStorageId(raw: string): string {
|
||||
return String(raw ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
async function updateRcloneStorageInfoList() {
|
||||
const response = await rclone_api_post('/config/providers');
|
||||
const providers = (response?.providers as RcloneProvider[]) || []
|
||||
@@ -125,9 +129,9 @@ async function updateRcloneStorageInfoList() {
|
||||
|
||||
|
||||
rcloneStorageInfoList.push({
|
||||
label: 'storage.'+ provider.Prefix,//provider.Name
|
||||
label: 'storage.' + normalizeStorageId(provider.Prefix),//provider.Name
|
||||
type: provider.Prefix,
|
||||
description: 'description.'+provider.Prefix,//provider.Description
|
||||
description: 'description.' + normalizeStorageId(provider.Prefix),//provider.Description
|
||||
framework:'rclone',
|
||||
defaultParams: {
|
||||
name: provider.Name + '_new',
|
||||
@@ -140,4 +144,4 @@ async function updateRcloneStorageInfoList() {
|
||||
}
|
||||
|
||||
|
||||
export{ updateRcloneStorageInfoList }
|
||||
export{ updateRcloneStorageInfoList }
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { nmConfig, saveNmConfig } from "../services/config";
|
||||
import { saveNmConfig } from "../services/config";
|
||||
import { webviewWindow } from "@tauri-apps/api";
|
||||
|
||||
export const window = webviewWindow.getCurrentWebviewWindow()
|
||||
|
||||
function listenWindow() {
|
||||
// Close behavior is handled in Rust to ensure it works even if the frontend is busy.
|
||||
// Keep this listener only for "hide on close" legacy behavior when enabled.
|
||||
window.listen('tauri://close-requested', () => {
|
||||
if (nmConfig.settings.closeToTray) {
|
||||
windowsHide();
|
||||
}
|
||||
windowsHide();
|
||||
return false
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import { getVersion } from '@tauri-apps/api/app';
|
||||
import * as shell from '@tauri-apps/plugin-shell';
|
||||
import { rcloneInfo } from '../../services/rclone';
|
||||
import { setLocalized } from '../../controller/language/localized';
|
||||
import { formatPath, openUrlInBrowser, set_devtools_state } from '../../utils/utils';
|
||||
import { formatPath, openUrlInBrowser, set_devtools_state, showPathInExplorer } from '../../utils/utils';
|
||||
import { showLog } from '../other/modal';
|
||||
import { openlistInfo } from '../../services/openlist';
|
||||
import * as dialog from '@tauri-apps/plugin-dialog';
|
||||
import { exit } from '../../controller/main';
|
||||
import { readTextFileTail } from '../../utils/logs';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { netmountLogDir, openlistLogFile, rcloneLogFile } from '../../utils/netmountPaths';
|
||||
|
||||
// const CollapseItem = Collapse.Item;
|
||||
const FormItem = Form.Item;
|
||||
@@ -111,9 +113,9 @@ export default function Setting_page() {
|
||||
forceUpdate()
|
||||
}} />
|
||||
</FormItem>
|
||||
<FormItem label={t('close_to_tray')}>
|
||||
<Switch checked={nmConfig.settings.closeToTray} onChange={(value) => {
|
||||
nmConfig.settings.closeToTray = value
|
||||
<FormItem label={t('auto_recover_components')}>
|
||||
<Switch checked={nmConfig.settings.autoRecoverComponents} onChange={(value) => {
|
||||
nmConfig.settings.autoRecoverComponents = value
|
||||
forceUpdate()
|
||||
}} />
|
||||
</FormItem>
|
||||
@@ -159,7 +161,7 @@ export default function Setting_page() {
|
||||
showLog(modal, rcloneInfo.process.log!)
|
||||
return
|
||||
}
|
||||
showLogFromFileTail(rcloneInfo.process.logFile || '~/.netmount/log/rclone.log')
|
||||
showLogFromFileTail(rcloneInfo.process.logFile || rcloneLogFile())
|
||||
}}>{t('log')}</Link>): {rcloneInfo.version.version}
|
||||
<br />
|
||||
<Link onClick={() => { shell.open(roConfig.url.openlist) }}>Openlist</Link>(<Link onClick={() => {
|
||||
@@ -167,9 +169,48 @@ export default function Setting_page() {
|
||||
showLog(modal, openlistInfo.process.log!)
|
||||
return
|
||||
}
|
||||
showLogFromFileTail(openlistInfo.process.logFile || '~/.netmount/openlist/log/log.log')
|
||||
showLogFromFileTail(openlistInfo.process.logFile || openlistLogFile())
|
||||
}}>{t('log')}</Link>): {openlistInfo.version.version}
|
||||
<br />
|
||||
<Space style={{ marginTop: '0.5rem' }}>
|
||||
<Button onClick={async () => {
|
||||
const dir = netmountLogDir()
|
||||
if (osInfo.platform === 'windows') {
|
||||
const ok = await showPathInExplorer(dir, true)
|
||||
if (!ok) {
|
||||
Message.error(dir)
|
||||
}
|
||||
} else {
|
||||
Message.info(dir)
|
||||
}
|
||||
}}>{t('open_log_dir')}</Button>
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const path = await dialog.save({
|
||||
title: t('export_diagnostics'),
|
||||
defaultPath: `netmount-diagnostics-${ts}.zip`,
|
||||
filters: [{ name: 'Zip', extensions: ['zip'] }],
|
||||
})
|
||||
if (!path) return
|
||||
const out = await invoke<string>('export_diagnostics', { outPath: path })
|
||||
Message.success(`${t('diagnostics_exported')}: ${out}`)
|
||||
} catch (e) {
|
||||
const msg = (() => {
|
||||
if (typeof e === 'string') return e
|
||||
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
|
||||
return (e as { message: string }).message
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(e)
|
||||
} catch {
|
||||
return String(e)
|
||||
}
|
||||
})()
|
||||
Message.error(msg)
|
||||
}
|
||||
}}>{t('export_diagnostics')}</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title={t('about')} style={{}} size='small'>
|
||||
<Row >
|
||||
@@ -188,7 +229,7 @@ export default function Setting_page() {
|
||||
<br />
|
||||
<Link onClick={() => { openUrlInBrowser(roConfig.url.docs) }}> {t('docs')} </Link>
|
||||
<br />
|
||||
<Link onClick={() => { open(roConfig.url.docs + '/license') }}> {t('licence')} </Link>
|
||||
<Link onClick={() => { openUrlInBrowser(roConfig.url.docs + '/license') }}> {t('licence')} </Link>
|
||||
<br />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -65,7 +65,7 @@ let nmConfig: NMConfig = {
|
||||
settings: {
|
||||
themeMode: roConfig.options.setting.themeMode.select[roConfig.options.setting.themeMode.defIndex]!,
|
||||
startHide: false,
|
||||
closeToTray: true,
|
||||
autoRecoverComponents: true,
|
||||
language: undefined,
|
||||
path: {
|
||||
cacheDir: undefined as string | undefined
|
||||
|
||||
57
src/services/i18nPack.ts
Normal file
57
src/services/i18nPack.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type I18nPack = Record<string, string>;
|
||||
|
||||
// Key convention for storage i18n:
|
||||
// - Prefer canonical ids in code: `storage.<id>` and `description.<id>`
|
||||
// - Canonical <id> is: lowercase + trim + collapse whitespace
|
||||
// - Keep compatibility with historical case variants via alias keys.
|
||||
function normalizeStorageId(raw: string): string {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const STORAGE_KEY_ALIASES: Record<string, string> = {
|
||||
'storage.Alias': 'storage.alias',
|
||||
'storage.Crypt': 'storage.crypt',
|
||||
'storage.Dropbox': 'storage.dropbox',
|
||||
'storage.FTP': 'storage.ftp',
|
||||
'storage.Local': 'storage.local',
|
||||
'storage.Onedrive': 'storage.onedrive',
|
||||
'storage.PikPak': 'storage.pikpak',
|
||||
'storage.S3': 'storage.s3',
|
||||
'storage.SFTP': 'storage.sftp',
|
||||
'storage.SMB': 'storage.smb',
|
||||
'storage.Seafile': 'storage.seafile',
|
||||
'storage.WebDav': 'storage.webdav',
|
||||
};
|
||||
|
||||
export function normalizeI18nPack(pack: I18nPack): I18nPack {
|
||||
const out: I18nPack = { ...pack };
|
||||
|
||||
for (const [key, value] of Object.entries(pack)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
|
||||
if (key.startsWith('storage.')) {
|
||||
const suffix = key.slice('storage.'.length);
|
||||
const canonical = `storage.${normalizeStorageId(suffix)}`;
|
||||
if (!(canonical in out)) out[canonical] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.startsWith('description.')) {
|
||||
const suffix = key.slice('description.'.length);
|
||||
const canonical = `description.${normalizeStorageId(suffix)}`;
|
||||
if (!(canonical in out)) out[canonical] = value;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [aliasKey, canonicalKey] of Object.entries(STORAGE_KEY_ALIASES)) {
|
||||
if (!(aliasKey in out) && canonicalKey in out) {
|
||||
out[aliasKey] = out[canonicalKey]!;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
2
src/type/config.d.ts
vendored
2
src/type/config.d.ts
vendored
@@ -13,7 +13,7 @@ interface NMConfig {
|
||||
settings: {
|
||||
themeMode: 'dark' | 'light' | 'auto' | string,
|
||||
startHide: boolean,
|
||||
closeToTray: boolean,
|
||||
autoRecoverComponents: boolean,
|
||||
language?: string,
|
||||
path: {
|
||||
cacheDir?:string
|
||||
|
||||
@@ -2,24 +2,36 @@ import { osInfo, roConfig } from '../services/config'
|
||||
import { formatPath } from './utils'
|
||||
|
||||
function netmountDataDir(): string {
|
||||
return formatPath(roConfig.env.path.homeDir + '/.netmount/', osInfo.osType === 'windows')
|
||||
return formatPath(roConfig.env.path.homeDir + '/.netmount/', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function defaultCacheDir(): string {
|
||||
return formatPath(roConfig.env.path.homeDir + '/.cache/netmount', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function rcloneConfigFile(): string {
|
||||
return formatPath(netmountDataDir() + '/rclone.conf', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function netmountLogDir(): string {
|
||||
return formatPath(netmountDataDir() + '/log/', osInfo.osType === 'windows')
|
||||
return formatPath(netmountDataDir() + '/log/', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function rcloneLogFile(): string {
|
||||
return formatPath(netmountLogDir() + '/rclone.log', osInfo.osType === 'windows')
|
||||
return formatPath(netmountLogDir() + '/rclone.log', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function openlistLogFile(): string {
|
||||
return formatPath(netmountDataDir() + '/openlist/log/log.log', osInfo.osType === 'windows')
|
||||
return formatPath(openlistDataDir() + '/log/log.log', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function openlistDataDir(): string {
|
||||
return formatPath(netmountDataDir() + '/openlist/', osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
function sidecarLogFile(name: string): string {
|
||||
const safe = (name || 'sidecar').replace(/[^\w.-]+/g, '_')
|
||||
return formatPath(netmountLogDir() + `/sidecar-${safe}.log`, osInfo.osType === 'windows')
|
||||
return formatPath(netmountLogDir() + `/sidecar-${safe}.log`, osInfo.platform === 'windows')
|
||||
}
|
||||
|
||||
export { netmountDataDir, netmountLogDir, rcloneLogFile, openlistLogFile, sidecarLogFile }
|
||||
export { defaultCacheDir, netmountDataDir, netmountLogDir, rcloneConfigFile, rcloneLogFile, openlistDataDir, openlistLogFile, sidecarLogFile }
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { osInfo, roConfig } from "../../services/config";
|
||||
import { formatPath } from "../utils";
|
||||
|
||||
const openlistDataDir = () => {
|
||||
return formatPath(roConfig.env.path.homeDir + '/.netmount/openlist/', osInfo.osType === "windows")
|
||||
}
|
||||
import { openlistDataDir } from "../netmountPaths";
|
||||
|
||||
const addParams = (): string[] => {
|
||||
const params: string[] = []
|
||||
|
||||
@@ -5,14 +5,10 @@ import { rclone_api_noop, rclone_api_post } from "./request";
|
||||
import { formatPath, getAvailablePorts } from "../utils";
|
||||
import { openlistInfo } from "../../services/openlist";
|
||||
import { delStorage } from "../../controller/storage/storage";
|
||||
import { nmConfig, osInfo, roConfig } from "../../services/config";
|
||||
import { netmountLogDir, rcloneLogFile } from "../netmountPaths";
|
||||
import { nmConfig, osInfo } from "../../services/config";
|
||||
import { netmountLogDir, rcloneConfigFile, rcloneLogFile } from "../netmountPaths";
|
||||
import { restartSidecar, startSidecarAndWait, stopSidecarGracefully } from "../sidecarService";
|
||||
|
||||
const rcloneDataDir = () => {
|
||||
return formatPath(roConfig.env.path.homeDir + '/.netmount/', osInfo.osType === "windows")
|
||||
}
|
||||
|
||||
async function startRclone() {
|
||||
if (rcloneInfo.process.child) {
|
||||
await stopRclone()
|
||||
@@ -42,7 +38,7 @@ async function startRclone() {
|
||||
`--rc-user=${nmConfig.framework.rclone.user}`,
|
||||
`--rc-pass=${nmConfig.framework.rclone.password}`,
|
||||
'--rc-allow-origin=' + window.location.origin || '*',
|
||||
'--config=' + formatPath(rcloneDataDir() + '/rclone.conf', osInfo.osType === "windows"),
|
||||
`--config=${rcloneConfigFile()}`,
|
||||
'--cache-dir=' + rcloneInfo.localArgs.path.tempDir,
|
||||
`--log-file=${logFile}`,
|
||||
'--log-level=INFO'
|
||||
|
||||
Reference in New Issue
Block a user