feat: 添加安装更新功能,支持下载并安装更新包;优化双屏控制逻辑,增强错误处理
This commit is contained in:
+50
-17
@@ -24,29 +24,36 @@ fn set_dual_screen(app: AppHandle, on: bool) -> Result<String, String> {
|
|||||||
let _ = w.set_focus();
|
let _ = w.set_focus();
|
||||||
return Ok("on".into());
|
return Ok("on".into());
|
||||||
}
|
}
|
||||||
let mut builder = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::default())
|
// 主屏位置(用于排除主屏;必须把副窗放到主屏之外)
|
||||||
.title("OPC 运营中心 · 双屏");
|
|
||||||
// 定位到第二块屏(非主屏;无第二屏则用第一块)
|
|
||||||
if let Ok(monitors) = app.available_monitors() {
|
|
||||||
eprintln!("[dual] available_monitors={}", monitors.len());
|
|
||||||
let primary_pos = app
|
let primary_pos = app
|
||||||
.primary_monitor()
|
.primary_monitor()
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|pm| { let p = pm.position(); (p.x, p.y) });
|
.map(|pm| { let p = pm.position(); (p.x, p.y) });
|
||||||
eprintln!("[dual] primary_pos={:?}", primary_pos);
|
// 获取全部显示器,寻找一块与主屏位置不同的副屏
|
||||||
let target = monitors
|
let monitors = app.available_monitors().map_err(|e| format!("读取显示器失败: {e}"))?;
|
||||||
.iter()
|
eprintln!("[dual] monitors={} primary_pos={:?}", monitors.len(), primary_pos);
|
||||||
.find(|m| { let p = m.position(); Some((p.x, p.y)) != primary_pos })
|
let mut target: Option<(i32, i32, u32, u32)> = None; // (x, y, w, h)
|
||||||
.or_else(|| monitors.first());
|
for m in &monitors {
|
||||||
if let Some(mon) = target {
|
let p = m.position();
|
||||||
let p = mon.position();
|
if Some((p.x, p.y)) != primary_pos {
|
||||||
let s = mon.size();
|
let s = m.size();
|
||||||
eprintln!("[dual] target {}x{} @ {},{}", s.width, s.height, p.x, p.y);
|
target = Some((p.x, p.y, s.width, s.height));
|
||||||
builder = builder.position(p.x as f64, p.y as f64).inner_size(s.width as f64, s.height as f64);
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let win = builder.build().map_err(|e| e.to_string())?;
|
// 绝对不允许在主屏桌面上开启;没有副屏则拒绝
|
||||||
|
let (x, y, w, h) = match target {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Err("未检测到副屏(需连接第二块显示器),无法开启双屏".into()),
|
||||||
|
};
|
||||||
|
eprintln!("[dual] secondary {}x{} @ {},{}", w, h, x, y);
|
||||||
|
let win = WebviewWindowBuilder::new(&app, "secondary", WebviewUrl::default())
|
||||||
|
.title("OPC 运营中心 · 双屏")
|
||||||
|
.position(x as f64, y as f64)
|
||||||
|
.inner_size(w as f64, h as f64)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("创建副窗失败: {e}"))?;
|
||||||
// 等窗口内容就绪后再全屏(避免过早全屏导致白屏/布局异常)
|
// 等窗口内容就绪后再全屏(避免过早全屏导致白屏/布局异常)
|
||||||
let win2 = win.clone();
|
let win2 = win.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -57,6 +64,28 @@ fn set_dual_screen(app: AppHandle, on: bool) -> Result<String, String> {
|
|||||||
Ok("on".into())
|
Ok("on".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 安装更新:把前端已下载的安装包字节写入磁盘 → 启动安装程序 → 退出应用。
|
||||||
|
/// bytes 由前端 fetch /download 得到;启动安装程序后进程独立,应用退出不影响它。
|
||||||
|
#[tauri::command]
|
||||||
|
fn install_update(app: AppHandle, bytes: Vec<u8>, filename: String) -> Result<String, String> {
|
||||||
|
if bytes.len() < 1000 {
|
||||||
|
return Err("下载内容无效或为空".into());
|
||||||
|
}
|
||||||
|
let dest = std::env::temp_dir().join(filename);
|
||||||
|
std::fs::write(&dest, &bytes).map_err(|e| format!("写入安装包失败: {e}"))?;
|
||||||
|
// 启动安装程序(Windows 下 Command::spawn 为独立进程,应用退出后仍在运行)
|
||||||
|
std::process::Command::new(&dest)
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("启动安装程序失败: {e}"))?;
|
||||||
|
// 稍后退出应用,让安装程序接管
|
||||||
|
let app2 = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(800));
|
||||||
|
app2.exit(0);
|
||||||
|
});
|
||||||
|
Ok(dest.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
/// 读取运行时配置(部署用,免重新打包):
|
/// 读取运行时配置(部署用,免重新打包):
|
||||||
/// 1) exe 同目录 config.json / dpm.config.json(最高优先)
|
/// 1) exe 同目录 config.json / dpm.config.json(最高优先)
|
||||||
/// 2) 应用配置目录 config.json
|
/// 2) 应用配置目录 config.json
|
||||||
@@ -98,7 +127,11 @@ pub fn run() {
|
|||||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||||
Some(vec![]),
|
Some(vec![]),
|
||||||
))
|
))
|
||||||
.invoke_handler(tauri::generate_handler![read_runtime_config, set_dual_screen])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
read_runtime_config,
|
||||||
|
set_dual_screen,
|
||||||
|
install_update
|
||||||
|
])
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
// 仅主窗口关闭=最小化(进程常驻);双屏副窗口可正常关闭
|
// 仅主窗口关闭=最小化(进程常驻);双屏副窗口可正常关闭
|
||||||
|
|||||||
@@ -1,41 +1,51 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import Icon from './Icons';
|
import Icon from './Icons';
|
||||||
import { checkForUpdate, openUpdate } from '../utils/updateCheck';
|
import { checkForUpdate, downloadAndInstall } from '../utils/updateCheck';
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
UpdateNotify —— 检测新版本(Windows)
|
UpdateNotify —— 检测新版本(Windows)
|
||||||
· 启动静默检查一次 + 每 30 分钟一次(仅在有更新时提示)
|
· 启动静默检查一次 + 每 30 分钟一次(仅在有更新时提示)
|
||||||
· 页眉 logo 点击 → dpm:check-update → 主动检测:
|
· 页眉 logo 点击 → dpm:check-update → 主动检测
|
||||||
有更新 → 弹更新提示;无更新 → 临时提示「已是最新版本」
|
· 「下载更新」→ 下载安装包 → 启动安装程序 → 应用退出
|
||||||
========================================================= */
|
========================================================= */
|
||||||
export default function UpdateNotify() {
|
export default function UpdateNotify() {
|
||||||
const [upd, setUpd] = useState(null); // 更新提示
|
const [upd, setUpd] = useState(null); // 更新提示
|
||||||
const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败)
|
const [msg, setMsg] = useState(null); // 临时提示(已是最新 / 失败)
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(null); // 'downloading' | 'installing'
|
||||||
|
const [errText, setErrText] = useState('');
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
const run = useCallback(async (manual) => {
|
const run = useCallback(async (manual) => {
|
||||||
const u = await checkForUpdate();
|
const u = await checkForUpdate();
|
||||||
if (u) {
|
if (u) {
|
||||||
if (u.available) {
|
if (u.available) { setUpd(u); setDismissed(false); }
|
||||||
setUpd(u);
|
else if (manual) { setMsg(`已是最新版本 v${u.latest}`); flash(); }
|
||||||
setDismissed(false);
|
} else if (manual) { setMsg('更新检测失败'); flash(); }
|
||||||
} else if (manual) {
|
|
||||||
setMsg(`已是最新版本 v${u.latest}`);
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setMsg(null), 3000);
|
|
||||||
}
|
|
||||||
} else if (manual) {
|
|
||||||
setMsg('更新检测失败');
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = setTimeout(() => setMsg(null), 3000);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const flash = () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setMsg(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (!upd || busy) return;
|
||||||
|
setBusy('downloading');
|
||||||
|
setErrText('');
|
||||||
|
try {
|
||||||
|
await downloadAndInstall(upd.url, upd.name);
|
||||||
|
setBusy('installing'); // 应用即将退出
|
||||||
|
} catch (e) {
|
||||||
|
setErrText(String((e && e.message) || e));
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}, [upd, busy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onCheck = () => run(true);
|
const onCheck = () => run(true);
|
||||||
window.addEventListener('dpm:check-update', onCheck);
|
window.addEventListener('dpm:check-update', onCheck);
|
||||||
run(false); // 启动静默检测
|
run(false);
|
||||||
const iv = setInterval(() => run(false), 30 * 60 * 1000);
|
const iv = setInterval(() => run(false), 30 * 60 * 1000);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('dpm:check-update', onCheck);
|
window.removeEventListener('dpm:check-update', onCheck);
|
||||||
@@ -44,25 +54,38 @@ export default function UpdateNotify() {
|
|||||||
};
|
};
|
||||||
}, [run]);
|
}, [run]);
|
||||||
|
|
||||||
|
if (!upd || dismissed) {
|
||||||
|
if (msg) {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
{upd && !dismissed && (
|
|
||||||
<div className="upd-toast" role="status">
|
|
||||||
<div className="upd-ico"><Icon name="rocket" size={15} /></div>
|
|
||||||
<div className="upd-body">
|
|
||||||
<b>发现新版本 v{upd.latest}</b>
|
|
||||||
<span>当前 v{upd.current} · 点击下载更新安装包(Windows)</span>
|
|
||||||
</div>
|
|
||||||
<button className="upd-btn" onClick={() => openUpdate(upd.url)}>下载更新</button>
|
|
||||||
<button className="upd-close" onClick={() => setDismissed(true)} aria-label="关闭">×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{msg && (
|
|
||||||
<div className="upd-toast upd-msg" role="status">
|
<div className="upd-toast upd-msg" role="status">
|
||||||
<div className="upd-ico"><Icon name="check" size={15} /></div>
|
<div className="upd-ico"><Icon name="check" size={15} /></div>
|
||||||
<div className="upd-body"><b>{msg}</b></div>
|
<div className="upd-body"><b>{msg}</b></div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upd-toast" role="status">
|
||||||
|
<div className="upd-ico"><Icon name="rocket" size={15} /></div>
|
||||||
|
<div className="upd-body">
|
||||||
|
{busy === 'downloading' && <b>正在下载更新 v{upd.latest}…</b>}
|
||||||
|
{busy === 'installing' && <b>正在启动安装程序,应用即将退出…</b>}
|
||||||
|
{!busy && <b>发现新版本 v{upd.latest}</b>}
|
||||||
|
{!busy && <span>当前 v{upd.current} · 点击下载更新并安装(Windows)</span>}
|
||||||
|
{errText && <span style={{ color: '#f04438' }}>{errText}</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="upd-btn"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
{busy ? '处理中…' : '下载更新'}
|
||||||
|
</button>
|
||||||
|
{!busy && (
|
||||||
|
<button className="upd-close" onClick={() => setDismissed(true)} aria-label="关闭">×</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-11
@@ -1,10 +1,10 @@
|
|||||||
/* =========================================================
|
/* =========================================================
|
||||||
客户端更新检测(仅 Windows)
|
客户端更新检测与安装(仅 Windows)
|
||||||
后端 POST /api/update/check 返回最新版本 + 安装包下载地址;
|
后端 POST /api/update/check 返回最新版本 + 安装包下载地址;
|
||||||
前端比对后提示,并提供下载(opener 打开安装包下载链接)。
|
前端 fetch 下载安装包字节 → invoke install_update(写盘 → 启动安装程序 → 退出应用)。
|
||||||
========================================================= */
|
========================================================= */
|
||||||
import { getApiBase } from '../config';
|
import { getApiBase } from '../config';
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
// 当前版本:发布新版本时同步 package.json 的 version
|
// 当前版本:发布新版本时同步 package.json 的 version
|
||||||
export const APP_VERSION = '0.1.0';
|
export const APP_VERSION = '0.1.0';
|
||||||
@@ -23,18 +23,21 @@ export async function checkForUpdate() {
|
|||||||
latest: data.latest_version,
|
latest: data.latest_version,
|
||||||
available: !!data.update_available,
|
available: !!data.update_available,
|
||||||
url: `${getApiBase()}${data.download_url || '/download'}`,
|
url: `${getApiBase()}${data.download_url || '/download'}`,
|
||||||
name: data.installer_name || '',
|
name: data.installer_name || 'dpm_update_setup.exe',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch { /* 后端不可达时忽略 */ }
|
} catch { /* 后端不可达时忽略 */ }
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打开安装包下载地址(优先系统浏览器下载)。 */
|
/** 下载安装包并交给后端安装(写盘 → 启动安装程序 → 退出应用)。 */
|
||||||
export function openUpdate(url) {
|
export async function downloadAndInstall(url, filename) {
|
||||||
try {
|
const res = await fetch(url);
|
||||||
openUrl(url);
|
if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`);
|
||||||
} catch {
|
const buf = new Uint8Array(await res.arrayBuffer());
|
||||||
window.open(url, '_blank');
|
if (buf.length < 1000) throw new Error('安装包内容无效');
|
||||||
}
|
return invoke('install_update', {
|
||||||
|
bytes: Array.from(buf),
|
||||||
|
filename: filename || 'dpm_update_setup.exe',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ export function useMqttControl() {
|
|||||||
// 双屏:admin 控制第二块屏全屏展示(Tauri 命令)——仅主窗口执行
|
// 双屏:admin 控制第二块屏全屏展示(Tauri 命令)——仅主窗口执行
|
||||||
if (WINDOW_LABEL !== 'main') break;
|
if (WINDOW_LABEL !== 'main') break;
|
||||||
aiStatus(cmd.params?.on === false ? '正在关闭双屏' : '正在启动双屏', { icon: 'page' });
|
aiStatus(cmd.params?.on === false ? '正在关闭双屏' : '正在启动双屏', { icon: 'page' });
|
||||||
invoke('set_dual_screen', { on: cmd.params?.on !== false }).catch(() => {});
|
invoke('set_dual_screen', { on: cmd.params?.on !== false })
|
||||||
|
.catch((err) => {
|
||||||
|
const msg = String((err && err.message) || err || '操作失败');
|
||||||
|
aiStatus(`双屏失败:${msg}`, { state: 'error', icon: 'error' });
|
||||||
|
console.error('[dual]', err);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case 'alert':
|
case 'alert':
|
||||||
// 通知本身即展示,无需再弹「正在发送通知」状态提示
|
// 通知本身即展示,无需再弹「正在发送通知」状态提示
|
||||||
|
|||||||
Reference in New Issue
Block a user