fix: 修复桌面端下载保存对话框
This commit is contained in:
@@ -1,4 +1,11 @@
|
|||||||
# 项目上下文记录
|
# 项目上下文记录
|
||||||
|
## 2026-05-21:修复桌面端下载不弹保存路径
|
||||||
|
|
||||||
|
- 桌面端不再依赖 WebView 的 `<a download>` 行为保存文件;`frontend/src/api/client.ts` 在 Tauri 环境下会通过 `@tauri-apps/api/core` 调用原生命令,普通浏览器和 Server Portable 仍保留原有 Blob 下载逻辑。
|
||||||
|
- `src-tauri/src/main.rs` 新增 `download_to_file` 命令:先弹出系统保存对话框,再使用 Rust `reqwest` 按前端传入的 HTTP 方法、URL、Header 和请求体流式请求后端接口,并写入用户选择的路径;用户取消保存时不报错,HTTP 错误会带回前端并继续触发 401 退出登录逻辑。
|
||||||
|
- 新增依赖 `@tauri-apps/api`、`tauri-plugin-dialog`、`reqwest` 和 `serde`;Tauri schema 文件会因 dialog 插件更新,`src-tauri/gen/schemas/` 仍需保留在版本库中用于 VS Code JSON 校验。
|
||||||
|
- 已验证 `npm run build`、`.venv\Scripts\python.exe -m compileall app`、`cargo fmt --manifest-path src-tauri\Cargo.toml --check` 和带临时 sidecar 占位文件的 `cargo check --manifest-path src-tauri\Cargo.toml` 均通过;验证后已清理 `frontend/dist`、`src-tauri/target`、`src-tauri/binaries` 和 Python 缓存。
|
||||||
|
|
||||||
## 2026-05-20:统一端口、升级 3.0.0 并收敛桌面安装行为
|
## 2026-05-20:统一端口、升级 3.0.0 并收敛桌面安装行为
|
||||||
|
|
||||||
- 应用户要求,应用访问端口统一回 `9081`:Server Portable、Docker 宿主机映射、Tauri 桌面 sidecar、桌面前端 `VITE_API_BASE` 和前端 Tauri 兜底 API 地址均使用 `http://127.0.0.1:9081`;桌面版启动前只会清理同名 `capareport-server.exe` 的残留监听进程,避免误杀其它占用 `9081` 的程序。
|
- 应用户要求,应用访问端口统一回 `9081`:Server Portable、Docker 宿主机映射、Tauri 桌面 sidecar、桌面前端 `VITE_API_BASE` 和前端 Tauri 兜底 API 地址均使用 `http://127.0.0.1:9081`;桌面版启动前只会清理同名 `capareport-server.exe` 的残留监听进程,避免误杀其它占用 `9081` 的程序。
|
||||||
|
|||||||
Generated
+11
@@ -8,6 +8,7 @@
|
|||||||
"name": "capacity-report-frontend",
|
"name": "capacity-report-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
@@ -1004,6 +1005,16 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/api": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/tauri"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview --host 0.0.0.0"
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
|
|||||||
@@ -5,7 +5,29 @@ const DESKTOP_API_BASE = 'http://127.0.0.1:9081';
|
|||||||
const API_BASE = resolveApiBase();
|
const API_BASE = resolveApiBase();
|
||||||
const API_FETCH_RETRIES = 8;
|
const API_FETCH_RETRIES = 8;
|
||||||
const API_FETCH_RETRY_DELAY_MS = 500;
|
const API_FETCH_RETRY_DELAY_MS = 500;
|
||||||
|
const TAURI_HTTP_ERROR_PREFIX = 'CAPAREPORT_HTTP_ERROR:';
|
||||||
let onUnauthorized: (() => void) | null = null;
|
let onUnauthorized: (() => void) | null = null;
|
||||||
|
let cachedTauriInvoke: TauriInvoke | null = null;
|
||||||
|
|
||||||
|
type TauriInvoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
|
||||||
|
|
||||||
|
interface TauriDownloadHeader {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDownloadRequest {
|
||||||
|
url: string;
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
filename: string;
|
||||||
|
headers: TauriDownloadHeader[];
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDownloadResult {
|
||||||
|
saved: boolean;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
export class ApiRequestError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -134,6 +156,17 @@ export async function download(url: string, body: unknown, filename: string): Pr
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
await downloadWithTauri({
|
||||||
|
url: apiUrl(url),
|
||||||
|
method: 'POST',
|
||||||
|
filename,
|
||||||
|
headers: headersToPairs(headers),
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetchWithRetry(apiUrl(url), {
|
const response = await fetchWithRetry(apiUrl(url), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
@@ -155,6 +188,16 @@ export async function downloadGet(url: string, fallbackFilename: string): Promis
|
|||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
await downloadWithTauri({
|
||||||
|
url: apiUrl(url),
|
||||||
|
method: 'GET',
|
||||||
|
filename: fallbackFilename,
|
||||||
|
headers: headersToPairs(headers)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetchWithRetry(apiUrl(url), { headers });
|
const response = await fetchWithRetry(apiUrl(url), { headers });
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
@@ -216,6 +259,60 @@ async function saveBlobResponse(response: Response, filename: string): Promise<v
|
|||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadWithTauri(request: TauriDownloadRequest): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoke = await getTauriInvoke();
|
||||||
|
await invoke<TauriDownloadResult>('download_to_file', { request });
|
||||||
|
} catch (error) {
|
||||||
|
throw parseTauriDownloadError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTauriInvoke(): Promise<TauriInvoke> {
|
||||||
|
if (!cachedTauriInvoke) {
|
||||||
|
const api = await import('@tauri-apps/api/core');
|
||||||
|
cachedTauriInvoke = api.invoke;
|
||||||
|
}
|
||||||
|
return cachedTauriInvoke;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headersToPairs(headers: Headers): TauriDownloadHeader[] {
|
||||||
|
const pairs: TauriDownloadHeader[] = [];
|
||||||
|
headers.forEach((value, name) => {
|
||||||
|
pairs.push({ name, value });
|
||||||
|
});
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTauriDownloadError(error: unknown): Error {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || '下载失败');
|
||||||
|
if (!message.startsWith(TAURI_HTTP_ERROR_PREFIX)) {
|
||||||
|
return error instanceof Error ? error : new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = message.slice(TAURI_HTTP_ERROR_PREFIX.length);
|
||||||
|
const separator = payload.indexOf(':');
|
||||||
|
const status = Number(payload.slice(0, separator));
|
||||||
|
const body = separator >= 0 ? payload.slice(separator + 1) : '';
|
||||||
|
const parsed = parseApiErrorText(body, `下载失败 (${status || 'HTTP'})`);
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
clearToken();
|
||||||
|
onUnauthorized?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiRequestError(parsed.message, status || 500, parsed.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApiErrorText(text: string, fallback: string): { message: string; detail?: ApiErrorDetail } {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text) as ApiError;
|
||||||
|
return parseApiError(data, fallback);
|
||||||
|
} catch {
|
||||||
|
return { message: text || fallback };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
async function fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 0; attempt <= API_FETCH_RETRIES; attempt += 1) {
|
for (let attempt = 0; attempt <= API_FETCH_RETRIES; attempt += 1) {
|
||||||
|
|||||||
Generated
+70
@@ -237,8 +237,11 @@ dependencies = [
|
|||||||
name = "capacity-report-desktop"
|
name = "capacity-report-desktop"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2010,6 +2013,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2512,6 +2516,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -3214,6 +3242,48 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 1.1.2+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-shell"
|
name = "tauri-plugin-shell"
|
||||||
version = "2.3.5"
|
version = "2.3.5"
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ edition = "2021"
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2402,6 +2402,72 @@
|
|||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -2402,6 +2402,72 @@
|
|||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tauri::{Manager, Runtime};
|
use tauri::{Manager, Runtime};
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
use tauri_plugin_shell::process::CommandChild;
|
use tauri_plugin_shell::process::CommandChild;
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
|
|
||||||
const SERVER_PORT: u16 = 9081;
|
const SERVER_PORT: u16 = 9081;
|
||||||
const SERVER_ADDR: &str = "127.0.0.1:9081";
|
const SERVER_ADDR: &str = "127.0.0.1:9081";
|
||||||
|
const HTTP_ERROR_PREFIX: &str = "CAPAREPORT_HTTP_ERROR:";
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
const SERVER_PROCESS_NAME: &str = "capareport-server.exe";
|
const SERVER_PROCESS_NAME: &str = "capareport-server.exe";
|
||||||
|
|
||||||
@@ -25,6 +27,27 @@ struct ServerProcess {
|
|||||||
|
|
||||||
struct ServerState(Mutex<Option<ServerProcess>>);
|
struct ServerState(Mutex<Option<ServerProcess>>);
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DownloadHeader {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DownloadFileRequest {
|
||||||
|
url: String,
|
||||||
|
method: String,
|
||||||
|
filename: String,
|
||||||
|
headers: Vec<DownloadHeader>,
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct DownloadFileResult {
|
||||||
|
saved: bool,
|
||||||
|
path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn copy_resource<R: Runtime>(
|
fn copy_resource<R: Runtime>(
|
||||||
app: &tauri::App<R>,
|
app: &tauri::App<R>,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -186,9 +209,115 @@ fn stop_server<R: Runtime>(app: &tauri::AppHandle<R>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn download_to_file(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
window: tauri::Window,
|
||||||
|
request: DownloadFileRequest,
|
||||||
|
) -> Result<DownloadFileResult, String> {
|
||||||
|
let filename = safe_download_filename(&request.filename);
|
||||||
|
let mut dialog = app
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_file_name(&filename)
|
||||||
|
.set_parent(&window);
|
||||||
|
|
||||||
|
if let Some(extension) = Path::new(&filename)
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
dialog = dialog.add_filter(extension.to_ascii_uppercase(), &[extension]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(file_path) = dialog
|
||||||
|
.blocking_save_file()
|
||||||
|
.map(|file_path| file_path.into_path().map_err(|error| error.to_string()))
|
||||||
|
.transpose()?
|
||||||
|
else {
|
||||||
|
return Ok(DownloadFileResult {
|
||||||
|
saved: false,
|
||||||
|
path: None,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = write_download(request, &file_path).await {
|
||||||
|
let _ = fs::remove_file(&file_path);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DownloadFileResult {
|
||||||
|
saved: true,
|
||||||
|
path: Some(file_path.to_string_lossy().to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_download(request: DownloadFileRequest, file_path: &Path) -> Result<(), String> {
|
||||||
|
let method = reqwest::Method::from_bytes(request.method.as_bytes())
|
||||||
|
.map_err(|error| format!("Invalid download method: {error}"))?;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut builder = client.request(method, request.url);
|
||||||
|
|
||||||
|
for header in request.headers {
|
||||||
|
let name = reqwest::header::HeaderName::from_bytes(header.name.as_bytes())
|
||||||
|
.map_err(|error| format!("Invalid download header: {error}"))?;
|
||||||
|
let value = reqwest::header::HeaderValue::from_str(&header.value)
|
||||||
|
.map_err(|error| format!("Invalid download header value: {error}"))?;
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(body) = request.body {
|
||||||
|
builder = builder.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = builder
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Download request failed: {error}"))?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let body = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
return Err(format!("{HTTP_ERROR_PREFIX}{}:{body}", status.as_u16()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = fs::File::create(file_path)
|
||||||
|
.map_err(|error| format!("Unable to create download file: {error}"))?;
|
||||||
|
while let Some(chunk) = response
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Unable to read download stream: {error}"))?
|
||||||
|
{
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.map_err(|error| format!("Unable to write download file: {error}"))?;
|
||||||
|
}
|
||||||
|
file.flush()
|
||||||
|
.map_err(|error| format!("Unable to flush download file: {error}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_download_filename(filename: &str) -> String {
|
||||||
|
let trimmed = filename.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return "download.bin".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
Path::new(trimmed)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("download.bin")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![download_to_file])
|
||||||
.manage(ServerState(Mutex::new(None)))
|
.manage(ServerState(Mutex::new(None)))
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
start_server(app)?;
|
start_server(app)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user