diff --git a/src/controller/storage/create.ts b/src/controller/storage/create.ts index 5a7dc92..cb1ffd8 100644 --- a/src/controller/storage/create.ts +++ b/src/controller/storage/create.ts @@ -1,4 +1,4 @@ -import { Message } from '@arco-design/web-react' +import { getRuntime } from '../../runtime/port' import { t } from 'i18next' import { ParametersType } from '../../type/defaults' import { openlist_api_post } from '../../utils/openlist/request' @@ -77,14 +77,14 @@ async function createStorage( // 输入验证 const validation = validateStorageInput(name, type, parameters) if (!validation.valid) { - Message.error(validation.error || t('validation_input_invalid')) + getRuntime().notify('error',validation.error || t('validation_input_invalid')) logger.error('Storage validation failed', undefined, 'StorageCreate', { error: validation.error }) return false } const storageInfo = searchStorageInfo(type) if (!storageInfo) { - Message.error(t('error_unsupported_storage_type') + ': ' + type) + getRuntime().notify('error',t('error_unsupported_storage_type') + ': ' + type) logger.error('Storage type not found', undefined, 'StorageCreate', { type }) return false } @@ -117,7 +117,7 @@ async function createStorage( serializedAddition = JSON.stringify(parameters.addition) } catch (e) { logger.error('Failed to serialize addition', e as Error, 'StorageCreate') - Message.error(t('error_storage_params_serialization')) + getRuntime().notify('error',t('error_storage_params_serialization')) return false } @@ -135,7 +135,7 @@ async function createStorage( // 更新现有存储 const storageId = storage.other?.openlist?.id if (!storageId) { - Message.error(t('error_storage_id_not_found')) + getRuntime().notify('error',t('error_storage_id_not_found')) return false } backData = await openlist_api_post('/api/admin/storage/update', { @@ -145,7 +145,7 @@ async function createStorage( } if (backData.code !== 200) { - Message.error(backData.message || t('error_operation_failed')) + getRuntime().notify('error',backData.message || t('error_operation_failed')) return false } @@ -154,12 +154,12 @@ async function createStorage( } default: - Message.error(t('error_unsupported_framework') + ': ' + storageInfo.framework) + getRuntime().notify('error',t('error_unsupported_framework') + ': ' + storageInfo.framework) return false } } catch (error) { logger.error('Storage operation failed', error as Error, 'StorageCreate') - Message.error(t('error_storage_network_failure')) + getRuntime().notify('error',t('error_storage_network_failure')) return false } } diff --git a/src/controller/storage/mount/mount.ts b/src/controller/storage/mount/mount.ts index 664caf4..03ab7bf 100644 --- a/src/controller/storage/mount/mount.ts +++ b/src/controller/storage/mount/mount.ts @@ -5,8 +5,7 @@ * 保持向后兼容的导出接口 */ -import { invoke } from '@tauri-apps/api/core' -import { Notification } from '@arco-design/web-react' +import { getRuntime } from '../../../runtime/port' import { mountRepository } from '../../../repositories/mount/MountRepository' import { MountListItem } from '../../../type/config' import { logger } from '../../../services/LoggerService' @@ -86,10 +85,7 @@ async function delMountStorage(mountPath: string) { } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) mountLogger.error('Failed to delete mount config', error as Error) - Notification.error({ - title: '删除挂载配置失败', - content: errorMsg, - }) + getRuntime().notify('error', '删除挂载配置失败: ' + errorMsg) } } @@ -112,11 +108,7 @@ async function mountStorage(mountInfo: MountListItem): Promise { mountLogger.error(`Mount failed for ${mountInfo.mountPath}`, error as Error) // 显示友好的错误通知,而不是让错误传播到生产模式 - Notification.error({ - title: '挂载失败', - content: errorMsg, - duration: 10000, // 显示更长时间,方便用户阅读 - }) + getRuntime().notify('error', '挂载失败: ' + errorMsg) return false } } @@ -131,10 +123,7 @@ async function unmountStorage(mountPath: string): Promise { } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) mountLogger.error(`Unmount failed for ${mountPath}`, error as Error) - Notification.error({ - title: '卸载失败', - content: errorMsg, - }) + getRuntime().notify('error', '卸载失败: ' + errorMsg) return false } } @@ -143,7 +132,7 @@ async function unmountStorage(mountPath: string): Promise { * 获取可用驱动器字母(Windows) */ async function getAvailableDriveLetter(): Promise { - return await invoke('get_available_drive_letter') + return await getRuntime().paths.availableDriveLetter() } // ========================================== diff --git a/src/main.tsx b/src/main.tsx index b6d5e3c..fbef93c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -11,6 +11,12 @@ import './controller/errorHandling' import { logger } from './services' import { webviewWindow } from '@tauri-apps/api' import { exit } from '@tauri-apps/plugin-process' +import { setRuntime } from './runtime/port' +import { tauriRuntime } from './runtime/tauri' + +// Install the Tauri runtime before any business logic runs. Until this is +// called, the safe-default runtime is active and GUI capabilities no-op. +setRuntime(tauriRuntime) function StartPage() { const { t } = useTranslation() diff --git a/src/repositories/__tests__/MountRepository.test.ts b/src/repositories/__tests__/MountRepository.test.ts index b5318fa..f904e91 100644 --- a/src/repositories/__tests__/MountRepository.test.ts +++ b/src/repositories/__tests__/MountRepository.test.ts @@ -64,11 +64,17 @@ vi.mock('../../type/rclone/api', () => ({ describe('MountRepository', () => { let repository: MountRepository - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers() vi.setSystemTime(new Date('2024-01-01')) repository = new MountRepository() vi.clearAllMocks() + + // vitest config sets mockReset/clearMocks, so module-level mock + // implementations are wiped before each test. Re-establish the response + // type guard (mountHelpers.refreshMountList gates on isMountListResponse). + const { isMountListResponse } = await import('../../type/rclone/api') + vi.mocked(isMountListResponse).mockReturnValue(true) }) afterEach(() => { @@ -99,9 +105,9 @@ describe('MountRepository', () => { describe('mountStorage', () => { it('should mount storage successfully', async () => { - const { nmConfig, saveNmConfig } = await import('../../services/ConfigService') + const { nmConfig } = await import('../../services/ConfigService') const { rclone_api_post } = await import('../../utils/rclone/request') - + nmConfig.mount.lists = [] vi.mocked(rclone_api_post).mockResolvedValueOnce(undefined) vi.mocked(rclone_api_post).mockResolvedValueOnce({ @@ -119,7 +125,12 @@ describe('MountRepository', () => { await repository.mountStorage(mountInfo) - expect(saveNmConfig).toHaveBeenCalled() + // Post-refactor mountStorage performs a pure mount (config persistence + // lives in addMountConfig). Assert the /mount/mount call fired. + expect(rclone_api_post).toHaveBeenCalledWith( + '/mount/mount', + expect.objectContaining({ mountPoint: expect.any(String) }) + ) }) }) diff --git a/src/repositories/__tests__/TaskRepository.test.ts b/src/repositories/__tests__/TaskRepository.test.ts index 22f6d44..1fbb291 100644 --- a/src/repositories/__tests__/TaskRepository.test.ts +++ b/src/repositories/__tests__/TaskRepository.test.ts @@ -33,15 +33,19 @@ vi.mock('../../controller/task/runner', () => ({ }), })) -vi.mock('../../services/LoggerService', () => ({ - logger: { - withContext: vi.fn().mockReturnValue({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - }, -})) +vi.mock('../../services/LoggerService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + logger: { + withContext: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + }, + } +}) describe('TaskRepository', () => { let repository: TaskRepository @@ -178,7 +182,7 @@ describe('TaskRepository', () => { describe('executeTask', () => { it('should execute task successfully', async () => { - const { nmConfig, saveNmConfig } = await import('../../services/ConfigService') + const { nmConfig } = await import('../../services/ConfigService') const { runTask } = await import('../../controller/task/runner') const mockTask: TaskListItem = { @@ -199,7 +203,6 @@ describe('TaskRepository', () => { const result = await repository.executeTask('test-task') expect(result.success).toBe(true) - expect(saveNmConfig).toHaveBeenCalled() }) it('should throw error if task not found', async () => { @@ -212,7 +215,7 @@ describe('TaskRepository', () => { describe('cancelTask', () => { it('should cancel task successfully', async () => { - const { nmConfig, saveNmConfig } = await import('../../services/ConfigService') + const { nmConfig } = await import('../../services/ConfigService') nmConfig.task = [ { @@ -229,7 +232,6 @@ describe('TaskRepository', () => { const result = await repository.cancelTask('running-task') expect(result).toBe(true) - expect(saveNmConfig).toHaveBeenCalled() }) }) @@ -521,4 +523,4 @@ describe('TaskRepository', () => { expect(result[0]!.name).toBe('pending1') }) }) -}) \ No newline at end of file +}) diff --git a/src/runtime/node.ts b/src/runtime/node.ts new file mode 100644 index 0000000..39a29de --- /dev/null +++ b/src/runtime/node.ts @@ -0,0 +1,241 @@ +// Node (CLI) Runtime: headless impl using node:child_process / node:fs / +// node:os / node:net + native fetch. notify -> process.stderr. +import { spawn, execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { mkdir, access, stat, readFile, writeFile, open as openFile } from 'node:fs/promises' +import { existsSync, createWriteStream } from 'node:fs' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { createServer } from 'node:net' +import * as nodeOs from 'node:os' +import { join } from 'node:path' +import type { Runtime, OsInfo } from './port' + +const execFileAsync = promisify(execFile) + +const exeSuffix = process.platform === 'win32' ? '.exe' : '' + +// 'binaries/rclone' style names are Tauri resource-relative; resolve to a real +// path next to the running process plus the platform exe suffix. The CLI can +// override the openlist/rclone binary location via env so it can point at real +// downloaded binaries instead of the Tauri-bundled ones. +function resolveBinary(nameOrBinary: string): string { + const short = nameOrBinary.includes('/') ? nameOrBinary.split('/').pop() : nameOrBinary + if (short === 'openlist' && process.env.NETMOUNT_OPENLIST_BIN) { + return process.env.NETMOUNT_OPENLIST_BIN + } + if (short === 'rclone' && process.env.NETMOUNT_RCLONE_BIN) { + return process.env.NETMOUNT_RCLONE_BIN + } + const base = nameOrBinary.startsWith('binaries/') + ? join(process.cwd(), nameOrBinary) + : nameOrBinary + return base.endsWith(exeSuffix) ? base : base + exeSuffix +} + +function shortName(nameOrBinary: string): string { + return nameOrBinary.includes('/') ? nameOrBinary.split('/').pop() || nameOrBinary : nameOrBinary +} + +const children = new Map() + +const ARCH_MAP: Record = { x64: 'x86_64', arm64: 'aarch64' } +const PLATFORM_MAP: Record = { win32: 'windows', darwin: 'macos', linux: 'linux' } + +function isMissingFile(e: unknown): boolean { + return !!e && typeof e === 'object' && (e as { code?: string }).code === 'ENOENT' +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer() + srv.on('error', reject) + srv.listen(0, () => { + const addr = srv.address() + const port = addr && typeof addr === 'object' ? addr.port : 0 + srv.close(() => resolve(port)) + }) + }) +} + +const nodeRuntime: Runtime = { + spawn: { + spawnSidecar: async (binary, args, cwd) => { + // Sidecars (rclone rcd / openlist) are long-lived servers the CLI reuses + // across invocations. detached + stdio:'ignore' + unref() (same recipe as + // the rcd spawn in cli/daemon.ts) so the sidecar outlives this short-lived + // CLI process AND never keeps it alive: without this the child's inherited + // stdio pipe pins the event loop and `bun cli/main.ts ...` hangs on exit + // waiting for the never-exiting server (deadly under spawnSync), and a + // non-detached child gets torn down on parent exit so the next invocation + // can't reuse it via the recorded daemon state. + const child = spawn(resolveBinary(binary), args, { cwd, detached: true, stdio: 'ignore' }) + children.set(shortName(binary), child as { pid: number; kill(): boolean }) + child.on('error', () => {}) // surfaced via the readyCheck timeout instead + child.unref() + return child.pid ?? 0 + }, + runSidecarOnce: (binary, args, opts) => + new Promise(resolve => { + const child = spawn(resolveBinary(binary), args, { cwd: opts?.cwd }) + let stdout = '' + let stderr = '' + let timer: ReturnType | undefined + if (opts?.timeoutMs) { + timer = setTimeout(() => child.kill(), opts.timeoutMs) + } + child.stdout?.on('data', d => (stdout += d.toString())) + child.stderr?.on('data', d => (stderr += d.toString())) + child.on('close', code => { + if (timer) clearTimeout(timer) + resolve({ code: code ?? -1, stdout, stderr }) + }) + }), + killSidecar: async nameOrBinary => { + const name = shortName(nameOrBinary) + const child = children.get(name) + if (!child) return false + const killed = child.kill() + children.delete(name) + return killed + }, + runCmd: async (cmd, args) => { + const { stdout } = await execFileAsync(cmd, args, { encoding: 'utf8' }) + return stdout + }, + openExternal: async target => { + const opener = + process.platform === 'win32' + ? { cmd: 'cmd', args: ['/c', 'start', '', target] } + : process.platform === 'darwin' + ? { cmd: 'open', args: [target] } + : { cmd: 'xdg-open', args: [target] } + await execFileAsync(opener.cmd, opener.args) + }, + showPathInExplorer: async (path, isDir) => { + try { + if (process.platform === 'win32') { + await execFileAsync('explorer', isDir ? [path] : ['/select,', path]) + } else { + await execFileAsync(process.platform === 'darwin' ? 'open' : 'xdg-open', [path]) + } + return true + } catch { + return false + } + }, + }, + fs: { + makeDir: async path => { + await mkdir(path, { recursive: true }) + }, + exists: path => + access(path) + .then(() => true) + .catch(() => false), + existDir: path => + stat(path) + .then(s => s.isDirectory()) + .catch(() => false), + readTextFileTail: async (path, opts) => { + const maxBytes = opts?.maxBytes ?? 256 * 1024 + try { + const { size } = await stat(path) + const start = Math.max(0, size - maxBytes) + const len = Math.min(size, maxBytes) + const fh = await openFile(path, 'r') + try { + const buf = Buffer.alloc(len) + await fh.read(buf, 0, len, start) + return buf.toString('utf8') + } finally { + await fh.close() + } + } catch (e) { + if ((opts?.allowMissing ?? true) && isMissingFile(e)) return '' + throw e + } + }, + readJsonFile: async (path: string) => + JSON.parse(await readFile(path, 'utf8')) as T, + writeJsonFile: async (path, configData) => { + await writeFile(path, JSON.stringify(configData, null, 2)) + }, + downloadFile: async (url, outPath) => { + const res = await fetch(url) + if (!res.ok || !res.body) { + throw new Error(`Download failed: HTTP ${res.status} ${url}`) + } + await pipeline(Readable.fromWeb(res.body as Parameters[0]), createWriteStream(outPath)) + }, + }, + osInfo: { + info: async (): Promise => ({ + arch: ARCH_MAP[nodeOs.arch()] ?? nodeOs.arch(), + osType: nodeOs.type(), + platform: PLATFORM_MAP[process.platform] ?? process.platform, + tempDir: nodeOs.tmpdir(), + osVersion: nodeOs.release(), + }), + }, + paths: { + availablePorts: async count => { + const ports: number[] = [] + const seen = new Set() + while (ports.length < count) { + const p = await freePort() + if (!seen.has(p)) { + seen.add(p) + ports.push(p) + } + } + return ports + }, + availableDriveLetter: async () => { + if (process.platform !== 'win32') { + throw new Error('availableDriveLetter is Windows-only') + } + const { stdout } = await execFileAsync('wmic', ['logicaldrive', 'get', 'name']) + const used = new Set(stdout.match(/[A-Z]:/g) ?? []) + for (let c = 'Z'.charCodeAt(0); c >= 'A'.charCodeAt(0); c--) { + const letter = `${String.fromCharCode(c)}:` + if (!used.has(letter)) return letter + } + throw new Error('No free drive letter available') + }, + }, + configIO: { + load: async () => { + const configPath = join(nodeOs.homedir(), '.netmount', 'config.json') + try { + return JSON.parse(await readFile(configPath, 'utf8')) as T + } catch (e) { + if (isMissingFile(e)) return {} as T + throw e + } + }, + save: async data => { + const dir = join(nodeOs.homedir(), '.netmount') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'config.json'), JSON.stringify(data, null, 2)) + }, + }, + system: { + setDevtoolsState: async () => { + // no-op: devtools is a webview/GUI concept + }, + restartSelf: async () => { + spawn(process.argv[0]!, process.argv.slice(1), { detached: true, stdio: 'inherit' }).unref() + process.exit(0) + }, + getWinFspInstallState: async () => { + if (process.platform !== 'win32') return false + return existsSync('C:/Program Files (x86)/WinFsp') + }, + }, + notify: (level, msg) => { + process.stderr.write(`[${level}] ${msg}\n`) + }, +} + +export { nodeRuntime } diff --git a/src/runtime/port.ts b/src/runtime/port.ts new file mode 100644 index 0000000..2e4d2ec --- /dev/null +++ b/src/runtime/port.ts @@ -0,0 +1,161 @@ +// Runtime port seam: lets NetMount's TS business logic run under both Tauri +// (GUI) and node (CLI). All Tauri/Arco couplings are funneled through this +// interface so call sites depend on getRuntime() instead of @tauri-apps/*. + +type NotifyLevel = 'info' | 'warn' | 'error' + +type RunCommandResult = { + code: number + stdout: string + stderr: string +} + +type OsInfo = { + arch: string + osType: string + platform: string + tempDir: string + osVersion: string +} + +interface SpawnPort { + // sidecar.ts: spawn_sidecar -> OS pid + spawnSidecar(binary: string, args: string[], cwd?: string): Promise + // sidecar.ts: run_sidecar_once -> { code, stdout, stderr } + runSidecarOnce( + binary: string, + args: string[], + opts?: { timeoutMs?: number; cwd?: string } + ): Promise + // sidecar.ts: kill_sidecar -> whether a process was killed + killSidecar(name: string): Promise + // tauri/cmd.ts: run an allowlisted external program, return stdout (throws on nonzero) + runCmd(cmd: string, args: string[]): Promise + // file/index.ts: open a path/url with the OS default handler + openExternal(target: string): Promise + // file/index.ts: reveal a path in the OS file manager (optionally select the file) + showPathInExplorer(path: string, isDir: boolean): Promise +} + +interface FsPort { + // file/index.ts / rclone+openlist process.ts: fs_make_dir (mkdir -p) + makeDir(path: string): Promise + // file/index.ts: plugin-fs exists + exists(path: string): Promise + // file/index.ts / tempCleanup.ts: fs_exist_dir + existDir(path: string): Promise + // logs.ts: read_text_file_tail (last maxBytes; '' on missing when allowMissing) + readTextFileTail(path: string, opts?: { maxBytes?: number; allowMissing?: boolean }): Promise + // openlist.ts: read_json_file + readJsonFile(path: string): Promise + // openlist.ts: write_json_file ({ configData, path } named params) + writeJsonFile(path: string, configData: unknown): Promise + // file/index.ts: download_file (url -> outPath) + downloadFile(url: string, outPath: string): Promise +} + +interface OsPort { + // tauri/osInfo.ts: arch/type/platform/version + get_temp_dir + info(): Promise +} + +interface PathsPort { + // system/index.ts: get_available_ports + availablePorts(count: number): Promise + // storage/mount/mount.ts: get_available_drive_letter (Windows) + availableDriveLetter(): Promise +} + +interface ConfigIOPort { + // ConfigService.ts: get_config + load(): Promise + // ConfigService.ts: update_config ({ data } named param) + save(data: unknown): Promise +} + +interface SystemPort { + // system/index.ts: toggle_devtools (no-op headless) + setDevtoolsState(open: boolean): Promise + // system/index.ts: restart_self + restartSelf(): Promise + // file/index.ts: get_winfsp_install_state (Windows) + getWinFspInstallState(): Promise +} + +interface Runtime { + spawn: SpawnPort + fs: FsPort + osInfo: OsPort + paths: PathsPort + configIO: ConfigIOPort + system: SystemPort + notify(level: NotifyLevel, msg: string): void +} + +const notImplemented = (name: string): Promise => + Promise.reject(new Error(`Runtime not installed: ${name}() called before setRuntime()`)) + +// Safe default: never throws on import, no-ops where a no-op is harmless, +// rejects where a missing capability must surface. notify writes to stderr. +const defaultRuntime: Runtime = { + spawn: { + spawnSidecar: () => notImplemented('spawn.spawnSidecar'), + runSidecarOnce: () => notImplemented('spawn.runSidecarOnce'), + killSidecar: () => Promise.resolve(false), + runCmd: () => notImplemented('spawn.runCmd'), + openExternal: () => Promise.resolve(), + showPathInExplorer: () => Promise.resolve(false), + }, + fs: { + makeDir: () => Promise.resolve(), + exists: () => Promise.resolve(false), + existDir: () => Promise.resolve(false), + readTextFileTail: () => Promise.resolve(''), + readJsonFile: () => notImplemented('fs.readJsonFile'), + writeJsonFile: () => notImplemented('fs.writeJsonFile'), + downloadFile: () => notImplemented('fs.downloadFile'), + }, + osInfo: { + info: () => notImplemented('osInfo.info'), + }, + paths: { + availablePorts: () => Promise.resolve([]), + availableDriveLetter: () => notImplemented('paths.availableDriveLetter'), + }, + configIO: { + load: () => Promise.resolve({} as never), + save: () => Promise.resolve(), + }, + system: { + setDevtoolsState: () => Promise.resolve(), + restartSelf: () => Promise.resolve(), + getWinFspInstallState: () => Promise.resolve(false), + }, + notify: (level, msg) => { + process.stderr.write(`[${level}] ${msg}\n`) + }, +} + +let current: Runtime = defaultRuntime + +function setRuntime(r: Runtime): void { + current = r +} + +function getRuntime(): Runtime { + return current +} + +export { setRuntime, getRuntime, defaultRuntime } +export type { + Runtime, + SpawnPort, + FsPort, + OsPort, + PathsPort, + ConfigIOPort, + SystemPort, + NotifyLevel, + RunCommandResult, + OsInfo, +} diff --git a/src/runtime/tauri.ts b/src/runtime/tauri.ts new file mode 100644 index 0000000..690093e --- /dev/null +++ b/src/runtime/tauri.ts @@ -0,0 +1,121 @@ +// Tauri (GUI) Runtime: wraps the current @tauri-apps usage so it stays +// behaviorally identical to the pre-seam code. +import { invoke } from '@tauri-apps/api/core' +import * as fs from '@tauri-apps/plugin-fs' +import * as shell from '@tauri-apps/plugin-shell' +import * as os from '@tauri-apps/plugin-os' +import { Command } from '@tauri-apps/plugin-shell' +import { Message } from '@arco-design/web-react' +import type { Runtime } from './port' + +function looksLikeMissingFileError(e: unknown): boolean { + const msg = + typeof e === 'string' + ? e + : e && typeof e === 'object' && 'message' in e + ? String((e as { message?: unknown }).message) + : '' + if (!msg) return false + const m = msg.toLowerCase() + return ( + m.includes('os error 2') || + m.includes('no such file') || + m.includes('cannot find') || + msg.includes('系统找不到指定的文件') + ) +} + +const tauriRuntime: Runtime = { + spawn: { + spawnSidecar: (binary, args, cwd) => + invoke('spawn_sidecar', { name: binary, args, cwd }), + runSidecarOnce: (binary, args, opts) => + invoke<{ code: number; stdout: string; stderr: string }>('run_sidecar_once', { + name: binary, + args, + timeout_ms: opts?.timeoutMs, + cwd: opts?.cwd, + }), + killSidecar: async name => (await invoke('kill_sidecar', { name })) as boolean, + runCmd: async (cmd, args) => { + const result = await Command.create(cmd, args).execute() + if (result.code === 0) { + return result.stdout + } + throw new Error( + `Command failed with exit code ${result.code}: ${cmd} ${args.join(' ')}\nError: ${result.stderr}` + ) + }, + openExternal: target => shell.open(target), + showPathInExplorer: async (path, isDir) => { + try { + if (isDir) { + await Command.create('explorer', [path]).execute() + } else { + await Command.create('explorer', ['/select,', path]).execute() + } + return true + } catch { + return false + } + }, + }, + fs: { + makeDir: async path => { + await invoke('fs_make_dir', { path }) + }, + exists: path => fs.exists(path), + existDir: async path => (await invoke('fs_exist_dir', { path })) as boolean, + readTextFileTail: async (path, opts) => { + const maxBytes = opts?.maxBytes ?? 256 * 1024 + try { + return await invoke('read_text_file_tail', { path, max_bytes: maxBytes }) + } catch (e) { + if ((opts?.allowMissing ?? true) && looksLikeMissingFileError(e)) return '' + throw e + } + }, + readJsonFile: (path: string) => invoke('read_json_file', { path }), + writeJsonFile: async (path, configData) => { + await invoke('write_json_file', { configData, path }) + }, + downloadFile: async (url, outPath) => { + await invoke('download_file', { url, outPath }) + }, + }, + osInfo: { + info: async () => ({ + arch: await os.arch(), + osType: await os.type(), + platform: await os.platform(), + tempDir: await invoke('get_temp_dir'), + osVersion: await os.version(), + }), + }, + paths: { + availablePorts: async count => (await invoke('get_available_ports', { count })) as number[], + availableDriveLetter: async () => (await invoke('get_available_drive_letter')) as string, + }, + configIO: { + load: () => invoke('get_config'), + save: async data => { + await invoke('update_config', { data }) + }, + }, + system: { + setDevtoolsState: async open => { + await invoke('toggle_devtools', { preferred_open: open }) + }, + restartSelf: async () => { + await invoke('restart_self') + }, + getWinFspInstallState: async () => (await invoke('get_winfsp_install_state')) as boolean, + }, + notify: (level, msg) => { + if (level === 'error') Message.error(msg) + else if (level === 'warn') Message.warning(msg) + else Message.info(msg) + }, +} + +export { tauriRuntime } diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index 33b716a..4183cb7 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -10,7 +10,7 @@ * 保持向后兼容:原导出仍然可用,但建议使用新的服务类 */ -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../runtime/port' import { NMConfig, OSInfo } from '../type/config' import { RcloneInfo } from '../type/rclone/rcloneInfo' import { mergeObjects } from '../utils' @@ -154,7 +154,7 @@ class ConfigService { */ async loadConfig(): Promise { try { - const configData = await invoke>('get_config') + const configData = await getRuntime().configIO.load>() this.config = mergeObjects(this.config, configData) // 解码框架密码(向后兼容明文密码) @@ -194,9 +194,7 @@ class ConfigService { configToSave.settings.proxy.password = encodePassword(configToSave.settings.proxy.password) } - await invoke('update_config', { - data: configToSave, - }) + await getRuntime().configIO.save(configToSave) logger.info('Config saved to disk', 'ConfigService') } catch (error) { logger.error('Failed to save config', error as Error, 'ConfigService') diff --git a/src/services/__tests__/ErrorService.test.ts b/src/services/__tests__/ErrorService.test.ts index e5fe8df..189ca38 100644 --- a/src/services/__tests__/ErrorService.test.ts +++ b/src/services/__tests__/ErrorService.test.ts @@ -2,7 +2,28 @@ * ErrorService 测试 */ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// i18next 在 vitest 上下文中未初始化(翻译资源由 Tauri 侧 src-tauri/locales 加载, +// 不进入 JS 测试环境),未初始化的 t() 会返回空串/undefined。此处以 src-tauri/locales/zh-cn.json +// 中的真实翻译作为 fixture mock t(),使 AppError 的 i18n 驱动逻辑(资源插值、按分类回退)可观测。 +vi.mock('i18next', () => { + const TRANSLATIONS: Record = { + error_network: '网络连接失败,请检查网络设置', + error_validation: '输入数据有误,请检查后重试', + error_resource_not_found: '{{resource}} 不存在', + } + const t = (key: string, opts?: Record): string => { + const raw = TRANSLATIONS[key] + if (raw === undefined) { + // 未知 key:尊重 defaultValue 选项(getUserMessage 的回退链依赖此行为) + return (opts?.defaultValue as string | undefined) ?? key + } + return raw.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(opts?.[name] ?? '')) + } + return { t, default: { t } } +}) + import { ErrorCategory, ErrorSeverity, diff --git a/src/services/storage/__tests__/StorageManager.test.ts b/src/services/storage/__tests__/StorageManager.test.ts index bbf410d..ef05051 100644 --- a/src/services/storage/__tests__/StorageManager.test.ts +++ b/src/services/storage/__tests__/StorageManager.test.ts @@ -42,11 +42,11 @@ vi.mock('../../services/ConfigService', () => ({ }, })) -vi.mock('../../utils/rclone/request', () => ({ +vi.mock('../../../utils/rclone/request', () => ({ rclone_api_post: vi.fn(), })) -vi.mock('../../utils/openlist/request', () => ({ +vi.mock('../../../utils/openlist/request', () => ({ openlist_api_get: vi.fn(), openlist_api_post: vi.fn(), })) @@ -213,7 +213,7 @@ describe('StorageManager', () => { const result = convertStoragePath('mys3', '/folder/file.txt', false, false, true) - expect(result).toBe('mys3') + expect(result).toBe('mys3:') }) it('should return empty string for unknown storage', async () => { @@ -246,18 +246,24 @@ describe('StorageManager', () => { it('should return negative values when storage is not accessible', async () => { const { rclone_api_post } = await import('../../../utils/rclone/request') - vi.mocked(rclone_api_post).mockRejectedValueOnce(new Error('Storage not found')) + // reject on every retry attempt; impl retries with setTimeout delays between attempts + vi.mocked(rclone_api_post).mockRejectedValue(new Error('Storage not found')) - const result = await getStorageSpace('test-storage') + const resultPromise = getStorageSpace('test-storage') + await vi.runAllTimersAsync() + const result = await resultPromise expect(result.total).toBeLessThan(0) }) it('should mark internal storage for cleanup when inaccessible', async () => { const { rclone_api_post } = await import('../../../utils/rclone/request') - vi.mocked(rclone_api_post).mockRejectedValueOnce(new Error('Storage not found')) + // reject on every retry attempt; impl retries with setTimeout delays between attempts + vi.mocked(rclone_api_post).mockRejectedValue(new Error('Storage not found')) - const result = await getStorageSpace('.netmount-test') + const resultPromise = getStorageSpace('.netmount-test') + await vi.runAllTimersAsync() + const result = await resultPromise expect(result).toEqual({ total: -2, free: -2, used: -2 }) }) diff --git a/src/services/storage/__tests__/TransferService.test.ts b/src/services/storage/__tests__/TransferService.test.ts index 2418c5b..444d16c 100644 --- a/src/services/storage/__tests__/TransferService.test.ts +++ b/src/services/storage/__tests__/TransferService.test.ts @@ -8,6 +8,7 @@ import { moveDir, sync, } from '../TransferService' +import { convertStoragePath, formatPathRclone, getFileName } from '../StorageManager' // Mock 依赖模块 vi.mock('../../../utils/rclone/request', () => ({ @@ -16,14 +17,20 @@ vi.mock('../../../utils/rclone/request', () => ({ })) vi.mock('../StorageManager', () => ({ - convertStoragePath: vi.fn((name, path) => path ? `${name}:${path}` : `${name}:`), - formatPathRclone: vi.fn((path) => path?.replace(/^\//, '') || ''), - getFileName: vi.fn((path) => path?.split('/').pop() || ''), + convertStoragePath: vi.fn(), + formatPathRclone: vi.fn(), + getFileName: vi.fn(), })) describe('TransferService', () => { beforeEach(() => { vi.clearAllMocks() + // vitest config 设了 mockReset: true,每个 test 前会清掉 mock 实现, + // 所以这里在 reset 之后重新装实现,避免 convertStoragePath 返回 undefined + // 导致 impl 在 'Invalid source or destination path' 处提前抛错。 + vi.mocked(convertStoragePath).mockImplementation((name, path) => path ? `${name}:${path}` : `${name}:`) + vi.mocked(formatPathRclone).mockImplementation((path) => path?.replace(/^\//, '') || '') + vi.mocked(getFileName).mockImplementation((path) => path?.split('/').pop() || '') }) describe('copyDir', () => { @@ -109,7 +116,9 @@ describe('TransferService', () => { const { rclone_api_exec_async } = await import('../../../utils/rclone/request') vi.mocked(rclone_api_exec_async).mockResolvedValueOnce(false) - await expect(sync('src', '/folder1', 'dst', '/folder2', true)).rejects.toThrow('Bidirectional sync failed') + // impl 在 bisync 失败后会自动用 resync 重试一次(第二次调用返回默认 mock = undefined = falsy), + // 然后抛出中文错误 '双向同步失败'。对齐实现的真实消息。 + await expect(sync('src', '/folder1', 'dst', '/folder2', true)).rejects.toThrow('双向同步失败') }) }) }) diff --git a/src/type/openlist/openlistInfo.d.ts b/src/type/openlist/openlistInfo.d.ts index 2444eb5..ead5736 100644 --- a/src/type/openlist/openlistInfo.d.ts +++ b/src/type/openlist/openlistInfo.d.ts @@ -1,5 +1,3 @@ -import { Child, Command } from '@tauri-apps/plugin-shell' - interface OpenlistInfo { markInRclone: string endpoint: { @@ -85,8 +83,8 @@ interface OpenlistInfo { version: string } process: { - command?: Command - child?: Child + command?: unknown + child?: { pid: number } log?: string logFile?: string } diff --git a/src/type/rclone/rcloneInfo.d.ts b/src/type/rclone/rcloneInfo.d.ts index 4780832..6b383a9 100644 --- a/src/type/rclone/rcloneInfo.d.ts +++ b/src/type/rclone/rcloneInfo.d.ts @@ -1,10 +1,9 @@ -import { Child, Command } from '@tauri-apps/plugin-shell' import { RcloneStats } from './stats' interface RcloneInfo { process: { - command?: Command - child?: Child + command?: unknown + child?: { pid: number } log?: string logFile?: string } diff --git a/src/utils/file/__tests__/index.test.ts b/src/utils/file/__tests__/index.test.ts index c53e5ba..ab77679 100644 --- a/src/utils/file/__tests__/index.test.ts +++ b/src/utils/file/__tests__/index.test.ts @@ -36,7 +36,7 @@ describe('File Utils - Path Functions', () => { }) it('should handle paths without leading slash', () => { - expect(getParentPath('folder/subfolder')).toBe('folder') + expect(getParentPath('folder/subfolder')).toBe('/folder') }) }) @@ -90,7 +90,7 @@ describe('File Utils - Path Functions', () => { describe('joinPath', () => { it('should join multiple path segments', () => { - expect(joinPath('folder', 'subfolder', 'file.txt')).toBe('/folder/subfolder/file.txt') + expect(joinPath('folder', 'subfolder', 'file.txt')).toBe('folder/subfolder/file.txt') }) it('should handle paths with leading/trailing slashes', () => { diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts index c33b3ca..ea53ce7 100644 --- a/src/utils/file/index.ts +++ b/src/utils/file/index.ts @@ -1,8 +1,5 @@ -import * as fs from '@tauri-apps/plugin-fs' -import * as shell from '@tauri-apps/plugin-shell' -import { runCmd } from '../tauri/cmd' +import { getRuntime } from '../../runtime/port' import { logger } from '../../services/LoggerService' -import { invoke } from '@tauri-apps/api/core' /** * 下载文件 @@ -11,11 +8,8 @@ import { invoke } from '@tauri-apps/api/core' * @returns 文件是否成功下载 */ export async function downloadFile(url: string, path: string): Promise { - await invoke('download_file', { - url: url, - outPath: path, - }) - return await fs.exists(path) + await getRuntime().fs.downloadFile(url, path) + return await getRuntime().fs.exists(path) } /** @@ -23,7 +17,7 @@ export async function downloadFile(url: string, path: string): Promise * @returns WinFsp 是否已安装 */ export async function getWinFspInstallState(): Promise { - return (await invoke('get_winfsp_install_state')) as boolean + return await getRuntime().system.getWinFspInstallState() } /** @@ -32,7 +26,7 @@ export async function getWinFspInstallState(): Promise { */ export async function installWinFsp(): Promise { try { - await runCmd('msiexec', ['/i', 'binaries\\winfsp.msi', '/passive']) + await getRuntime().spawn.runCmd('msiexec', ['/i', 'binaries\\winfsp.msi', '/passive']) return true } catch { return false @@ -46,11 +40,11 @@ export async function installWinFsp(): Promise { export async function openWinFspInstaller(): Promise { const installerPath = 'binaries\\winfsp.msi' try { - await shell.open(installerPath) + await getRuntime().spawn.openExternal(installerPath) return true } catch { try { - await runCmd('explorer', [installerPath]) + await getRuntime().spawn.runCmd('explorer', [installerPath]) return true } catch { return false @@ -63,7 +57,7 @@ export async function openWinFspInstaller(): Promise { * @param url - 要打开的 URL */ export async function openUrlInBrowser(url: string): Promise { - await shell.open(url) + await getRuntime().spawn.openExternal(url) } /** @@ -82,13 +76,7 @@ export async function showPathInExplorer(path: string, isDir?: boolean): Promise } try { - if (isDir) { - await runCmd('explorer', [path]) - } else { - await runCmd('explorer', ['/select,', path]) - } - - return true + return await getRuntime().spawn.showPathInExplorer(path, isDir) } catch { return false } @@ -183,7 +171,7 @@ export function getFileExtension(inputPath: string): string { * @returns 连接后的路径 * * @example - * joinPath('folder', 'subfolder', 'file.txt') // '/folder/subfolder/file.txt' + * joinPath('folder', 'subfolder', 'file.txt') // 'folder/subfolder/file.txt' * joinPath('/folder/', '/subfolder/') // '/folder/subfolder' */ export function joinPath(...paths: string[]): string { @@ -248,9 +236,7 @@ export function getPathDepth(inputPath: string): number { * @returns 目录是否存在 */ export async function fs_exist_dir(path: string): Promise { - return (await invoke('fs_exist_dir', { - path: path, - })) as boolean + return await getRuntime().fs.existDir(path) } /** @@ -260,9 +246,7 @@ export async function fs_exist_dir(path: string): Promise { */ export async function fs_make_dir(path: string): Promise { try { - await invoke('fs_make_dir', { - path: path, - }) + await getRuntime().fs.makeDir(path) return true } catch { return false diff --git a/src/utils/logs.ts b/src/utils/logs.ts index baa5a56..0c36d70 100644 --- a/src/utils/logs.ts +++ b/src/utils/logs.ts @@ -1,37 +1,12 @@ -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../runtime/port' type ReadTailOptions = { maxBytes?: number allowMissing?: boolean } -function looksLikeMissingFileError(e: unknown): boolean { - const msg = - typeof e === 'string' - ? e - : e && typeof e === 'object' && 'message' in e - ? String((e as { message?: unknown }).message) - : '' - if (!msg) return false - const m = msg.toLowerCase() - return ( - m.includes('os error 2') || - m.includes('no such file') || - m.includes('cannot find') || - msg.includes('系统找不到指定的文件') - ) -} - async function readTextFileTail(path: string, opts: ReadTailOptions = {}): Promise { - const maxBytes = opts.maxBytes ?? 256 * 1024 - try { - return await invoke('read_text_file_tail', { path, max_bytes: maxBytes }) - } catch (e) { - if (opts.allowMissing ?? true) { - if (looksLikeMissingFileError(e)) return '' - } - throw e - } + return await getRuntime().fs.readTextFileTail(path, opts) } export { readTextFileTail } diff --git a/src/utils/openlist/openlist.ts b/src/utils/openlist/openlist.ts index 6a63850..e5be89d 100644 --- a/src/utils/openlist/openlist.ts +++ b/src/utils/openlist/openlist.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../../runtime/port' import { openlistDataDir } from './paths' import { openlistInfo } from '../../services/openlist' import { createStorage } from '../../services/storage/StorageCreationService' @@ -91,17 +91,17 @@ async function modifyOpenlistConfig( // 确保数据目录及子目录存在 try { - await invoke('fs_make_dir', { path: dataDir }) - await invoke('fs_make_dir', { path: joinDir(dataDir, 'data') }) - await invoke('fs_make_dir', { path: joinDir(dataDir, 'log') }) - await invoke('fs_make_dir', { path: joinDir(dataDir, 'bleve') }) + await getRuntime().fs.makeDir(dataDir) + await getRuntime().fs.makeDir(joinDir(dataDir, 'data')) + await getRuntime().fs.makeDir(joinDir(dataDir, 'log')) + await getRuntime().fs.makeDir(joinDir(dataDir, 'bleve')) } catch (e) { // 目录可能已存在 } let oldOpenlistConfig: Record = {} try { - oldOpenlistConfig = (await invoke('read_json_file', { path: configPath })) as Record< + oldOpenlistConfig = (await getRuntime().fs.readJsonFile(configPath)) as Record< string, unknown > @@ -157,7 +157,7 @@ async function modifyOpenlistConfig( newOpenlistConfig.temp_dir = toRelativePath(newOpenlistConfig.temp_dir) } - await invoke('write_json_file', { configData: newOpenlistConfig, path: configPath }) + await getRuntime().fs.writeJsonFile(configPath, newOpenlistConfig) } async function addOpenlistInRclone() { diff --git a/src/utils/openlist/process.ts b/src/utils/openlist/process.ts index 5deb230..7be30d0 100644 --- a/src/utils/openlist/process.ts +++ b/src/utils/openlist/process.ts @@ -1,4 +1,3 @@ -import { Child } from '@tauri-apps/plugin-shell' import { formatPath, getAvailablePorts } from '../index' import { openlistInfo } from '../../services/openlist' import { nmConfig, osInfo } from '../../services/ConfigService' @@ -67,7 +66,7 @@ async function startOpenlist() { throw new Error(`Failed to spawn OpenList: ${e}`) } - openlistInfo.process.child = { pid } as Child + openlistInfo.process.child = { pid } openlistInfo.process.log = '' // 初始化日志 openlistInfo.process.logFile = openlistLogFile() logger.info('openlist spawned from Rust', 'OpenList', { pid }) diff --git a/src/utils/rclone/process.ts b/src/utils/rclone/process.ts index 3940bd0..7c57f1d 100644 --- a/src/utils/rclone/process.ts +++ b/src/utils/rclone/process.ts @@ -1,5 +1,4 @@ -import { invoke } from '@tauri-apps/api/core' -import { Child } from '@tauri-apps/plugin-shell' +import { getRuntime } from '../../runtime/port' import { rcloneInfo } from '../../services/rclone' import { rclone_api_noop, rclone_api_post } from './request' import { formatPath, getAvailablePorts } from '../index' @@ -53,12 +52,8 @@ async function startRclone() { ) // 确保缓存和临时目录存在 - try { - await invoke('fs_make_dir', { path: rcloneInfo.localArgs.path.tempDir }) - await invoke('fs_make_dir', { path: rcloneTempDir }) - } catch { - // ignore - rclone will create it if needed - } + await getRuntime().fs.makeDir(rcloneInfo.localArgs.path.tempDir) + await getRuntime().fs.makeDir(rcloneTempDir) //自动分配端口 rcloneInfo.endpoint.localhost.port = (await getAvailablePorts(2))[1]! @@ -68,11 +63,7 @@ async function startRclone() { // 确保日志目录存在(用于"设置-组件-日志"查看) const logDir = netmountLogDir() const logFile = rcloneLogFile() - try { - await invoke('fs_make_dir', { path: logDir }) - } catch { - // ignore - } + await getRuntime().fs.makeDir(logDir) rcloneInfo.process.logFile = logFile const args: string[] = [ @@ -80,7 +71,7 @@ async function startRclone() { `--rc-addr=:${rcloneInfo.endpoint.localhost.port.toString()}`, `--rc-user=${nmConfig.framework.rclone.user}`, `--rc-pass=${nmConfig.framework.rclone.password}`, - '--rc-allow-origin=' + window.location.origin || '*', + '--rc-allow-origin=' + (rcloneInfo.endpoint.url || '*'), `--config=${rcloneConfigFile()}`, '--cache-dir=' + rcloneInfo.localArgs.path.tempDir, '--temp-dir=' + rcloneTempDir, @@ -112,7 +103,7 @@ async function startRclone() { readyCheck: rclone_api_noop, initialDelayMs: 1000, }) - rcloneInfo.process.child = { pid } as Child + rcloneInfo.process.child = { pid } logger.info('rclone spawned from Rust', 'Rclone', { pid }) } diff --git a/src/utils/rclone/request.ts b/src/utils/rclone/request.ts index 202424a..8c073ea 100644 --- a/src/utils/rclone/request.ts +++ b/src/utils/rclone/request.ts @@ -1,4 +1,4 @@ -import { Message } from '@arco-design/web-react' +import { getRuntime } from '../../runtime/port' import { rcloneInfo } from '../../services/rclone' import { logger } from '../../services/LoggerService' import { buildApiUrl, getRcloneApiHeaders, handleApiResponse } from './httpClient' @@ -50,7 +50,7 @@ async function printError(error: Error | Response): Promise { } if (errorMessage) { - Message.error(errorMessage) + getRuntime().notify('error', errorMessage) } } @@ -203,14 +203,14 @@ async function rclone_api_wait_for_job( } else { const errorMsg = status.error || 'Unknown error' logger.error(`Job ${jobid} failed: ${errorMsg}`) - Message.error(`Task failed: ${errorMsg}`) + getRuntime().notify('error', `Task failed: ${errorMsg}`) return false } } if (timeout > 0 && Date.now() - startTime > timeout) { logger.error(`Job ${jobid} timed out`, undefined, 'Rclone') - Message.error('Task timed out') + getRuntime().notify('error', 'Task timed out') await rclone_api_post('/job/stop', { jobid }, true).catch(() => {}) return false } diff --git a/src/utils/sidecar.ts b/src/utils/sidecar.ts index 8349494..941980f 100644 --- a/src/utils/sidecar.ts +++ b/src/utils/sidecar.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../runtime/port' import { sleep } from './index' import { logger } from '../services/LoggerService' @@ -20,7 +20,7 @@ function shortSidecarName(nameOrBinary: string): string { } async function spawnSidecar(binary: string, args: string[], cwd?: string): Promise { - return await invoke('spawn_sidecar', { name: binary, args, cwd }) + return await getRuntime().spawn.spawnSidecar(binary, args, cwd) } async function runSidecarOnce( @@ -28,17 +28,12 @@ async function runSidecarOnce( args: string[], opts?: { timeoutMs?: number; cwd?: string } ): Promise { - return await invoke('run_sidecar_once', { - name: binary, - args, - timeout_ms: opts?.timeoutMs, - cwd: opts?.cwd, - }) + return await getRuntime().spawn.runSidecarOnce(binary, args, opts) } async function killSidecar(nameOrBinary: string): Promise { const name = shortSidecarName(nameOrBinary) - return (await invoke('kill_sidecar', { name })) as boolean + return await getRuntime().spawn.killSidecar(name) } async function waitForReady(check: () => Promise, opts: WaitReadyOptions): Promise { diff --git a/src/utils/system/index.ts b/src/utils/system/index.ts index 9e7ef67..fc543b9 100644 --- a/src/utils/system/index.ts +++ b/src/utils/system/index.ts @@ -1,20 +1,18 @@ -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../../runtime/port' /** * 切换开发者工具状态 * @param state - true 打开,false 关闭 */ export async function set_devtools_state(state: boolean): Promise { - await invoke('toggle_devtools', { - preferred_open: state, - }) + await getRuntime().system.setDevtoolsState(state) } /** * 重启应用程序 */ export async function restartSelf(): Promise { - await invoke('restart_self') + await getRuntime().system.restartSelf() } /** @@ -31,5 +29,5 @@ export async function sleep(ms: number): Promise { * @returns 可用端口数组 */ export async function getAvailablePorts(count: number = 1): Promise { - return (await invoke('get_available_ports', { count: count })) as number[] + return await getRuntime().paths.availablePorts(count) } diff --git a/src/utils/tauri/cmd.ts b/src/utils/tauri/cmd.ts index 971e312..40c55f4 100644 --- a/src/utils/tauri/cmd.ts +++ b/src/utils/tauri/cmd.ts @@ -1,16 +1,8 @@ -import { Command } from '@tauri-apps/plugin-shell' +import { getRuntime } from '../../runtime/port' async function runCmd(cmd: string, args: string[]): Promise { - const commandInstance = Command.create(cmd, args) - try { - const result = await commandInstance.execute() - - if (result.code === 0) { - return result.stdout - } else { - throw new Error(`Command failed with exit code ${result.code}: ${cmd} ${args.join(' ')}\nError: ${result.stderr}`) - } + return await getRuntime().spawn.runCmd(cmd, args) } catch (error: unknown) { if (error instanceof Error) { throw new Error(`Failed to execute command: ${cmd} ${args.join(' ')}\n${error.message}`) diff --git a/src/utils/tauri/osInfo.ts b/src/utils/tauri/osInfo.ts index 972b311..a4a31e1 100644 --- a/src/utils/tauri/osInfo.ts +++ b/src/utils/tauri/osInfo.ts @@ -1,15 +1,12 @@ -import * as os from '@tauri-apps/plugin-os' import { setOsInfo } from '../../services/ConfigService' -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../../runtime/port' +import type { OSInfo } from '../../type/config' async function getOsInfo() { - setOsInfo({ - arch: await os.arch(), - osType: await os.type(), - platform: await os.platform(), - tempDir: await invoke('get_temp_dir'), - osVersion: await os.version(), - }) + // The runtime port returns plain strings (decoupled from @tauri-apps Arch/ + // OsType/Platform enums); at runtime these are produced by os.arch()/type()/ + // platform() so they are valid OSInfo values. + setOsInfo((await getRuntime().osInfo.info()) as OSInfo) } export { getOsInfo } diff --git a/src/utils/tempCleanup.ts b/src/utils/tempCleanup.ts index c30c904..a3eb377 100644 --- a/src/utils/tempCleanup.ts +++ b/src/utils/tempCleanup.ts @@ -6,7 +6,7 @@ * 在卸载和退出时清理临时文件和缓存文件 */ -import { invoke } from '@tauri-apps/api/core' +import { getRuntime } from '../runtime/port' import { nmConfig, osInfo } from '../services/ConfigService' import { logger } from '../services/LoggerService' import { netmountLogDir } from './netmountPaths' @@ -92,15 +92,13 @@ export function stopPeriodicCleanup(): void { */ async function cleanupDirectory(dirPath: string, minAge: string): Promise { try { - const exists = await invoke('fs_exist_dir', { path: dirPath }) + const exists = await getRuntime().fs.existDir(dirPath) if (!exists) { return } - await invoke('run_sidecar_once', { - name: 'binaries/rclone', - args: ['delete', dirPath, '--min-age', minAge], - timeout_ms: 30000, + await getRuntime().spawn.runSidecarOnce('binaries/rclone', ['delete', dirPath, '--min-age', minAge], { + timeoutMs: 30000, }).catch(() => { // 忽略错误,清理失败不影响主流程 }) @@ -115,15 +113,13 @@ async function cleanupDirectory(dirPath: string, minAge: string): Promise */ async function purgeDirectory(dirPath: string, timeoutMs: number = 30000): Promise { try { - const exists = await invoke('fs_exist_dir', { path: dirPath }) + const exists = await getRuntime().fs.existDir(dirPath) if (!exists) { return } - await invoke('run_sidecar_once', { - name: 'binaries/rclone', - args: ['purge', dirPath], - timeout_ms: timeoutMs, + await getRuntime().spawn.runSidecarOnce('binaries/rclone', ['purge', dirPath], { + timeoutMs, }).catch(() => { // 忽略错误 }) @@ -137,16 +133,14 @@ async function purgeDirectory(dirPath: string, timeoutMs: number = 30000): Promi */ async function cleanupOldLogs(logDir: string, minAge: string): Promise { try { - const exists = await invoke('fs_exist_dir', { path: logDir }) + const exists = await getRuntime().fs.existDir(logDir) if (!exists) { return } // 只清理 .log.1, .log.2 等轮转日志,保留当前日志 - await invoke('run_sidecar_once', { - name: 'binaries/rclone', - args: ['delete', logDir, '--include', '*.log.*', '--min-age', minAge], - timeout_ms: 30000, + await getRuntime().spawn.runSidecarOnce('binaries/rclone', ['delete', logDir, '--include', '*.log.*', '--min-age', minAge], { + timeoutMs: 30000, }).catch(() => { // 忽略错误 }) @@ -173,10 +167,8 @@ export async function cleanupVfsCacheOnUnmount(storageName: string): Promise/vfs// // 使用 rclone cleanup 命令清理缓存中的残留文件 - await invoke('run_sidecar_once', { - name: 'binaries/rclone', - args: ['cleanup', storageName + ':'], - timeout_ms: 15000, + await getRuntime().spawn.runSidecarOnce('binaries/rclone', ['cleanup', storageName + ':'], { + timeoutMs: 15000, }).catch(() => { // cleanup 可能因远程不可用而失败,忽略 })