11
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aiclient",
|
"name": "aiclient",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
1380
src-tauri/Cargo.lock
generated
1380
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tauri-app"
|
name = "tauri-app"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -97,3 +97,5 @@ windows = { version = "0.58", features = [
|
|||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
] }
|
] }
|
||||||
|
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow"] }
|
||||||
|
egui = "0.31"
|
||||||
|
|||||||
@@ -2,13 +2,53 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER";
|
pub const ENV_FROM_UPDATER: &str = "AICLIENT_FROM_UPDATER";
|
||||||
const MAIN_EXE: &str = "aiclient.exe";
|
pub const MAIN_EXE: &str = "aiclient.exe";
|
||||||
const CHECK_UPDATE_URL: &str = "http://81.71.163.140:8001/api/checkupdate";
|
pub const CHECK_UPDATE_URL: &str = "http://81.71.163.140:8001/api/checkupdate";
|
||||||
const DOWNLOAD_URL: &str = "http://81.71.163.140:80/images/mainfile/main.exe";
|
pub const DOWNLOAD_URL: &str = "http://81.71.163.140:80/images/mainfile/main.exe";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum UpdaterMessage {
|
||||||
|
Status(String),
|
||||||
|
/// 0.0 ~ 1.0;无总大小时为 None(界面显示不确定进度)
|
||||||
|
DownloadProgress { fraction: Option<f32>, detail: String },
|
||||||
|
Launching,
|
||||||
|
Finished(Result<(), String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ProgressSink {
|
||||||
|
tx: Sender<UpdaterMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressSink {
|
||||||
|
pub fn new(tx: Sender<UpdaterMessage>) -> Self {
|
||||||
|
Self { tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, msg: UpdaterMessage) {
|
||||||
|
let _ = self.tx.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self, text: impl Into<String>) {
|
||||||
|
self.send(UpdaterMessage::Status(text.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn download_progress(&self, fraction: Option<f32>, detail: impl Into<String>) {
|
||||||
|
self.send(UpdaterMessage::DownloadProgress {
|
||||||
|
fraction,
|
||||||
|
detail: detail.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn launching(&self) {
|
||||||
|
self.send(UpdaterMessage::Launching);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn exe_dir() -> Result<PathBuf, String> {
|
fn exe_dir() -> Result<PathBuf, String> {
|
||||||
std::env::current_exe()
|
std::env::current_exe()
|
||||||
@@ -50,6 +90,13 @@ fn parse_remote_version(body: &str) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
|
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
|
||||||
|
if value.get("ok").and_then(|v| v.as_bool()) == Some(false) {
|
||||||
|
let msg = value
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("检查更新失败");
|
||||||
|
return Err(msg.to_string());
|
||||||
|
}
|
||||||
for key in ["version", "latest_version", "latestVersion", "app_version"] {
|
for key in ["version", "latest_version", "latestVersion", "app_version"] {
|
||||||
if let Some(v) = value.get(key).and_then(|x| x.as_str()) {
|
if let Some(v) = value.get(key).and_then(|x| x.as_str()) {
|
||||||
if !v.trim().is_empty() {
|
if !v.trim().is_empty() {
|
||||||
@@ -190,7 +237,23 @@ fn read_exe_version(path: &Path) -> Result<String, String> {
|
|||||||
Err("当前平台不支持读取可执行文件版本".to_string())
|
Err("当前平台不支持读取可执行文件版本".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_and_replace(client: &reqwest::Client, main_path: &Path) -> Result<(), String> {
|
fn format_bytes(n: u64) -> String {
|
||||||
|
const KB: u64 = 1024;
|
||||||
|
const MB: u64 = KB * 1024;
|
||||||
|
if n >= MB {
|
||||||
|
format!("{:.1} MB", n as f64 / MB as f64)
|
||||||
|
} else if n >= KB {
|
||||||
|
format!("{:.1} KB", n as f64 / KB as f64)
|
||||||
|
} else {
|
||||||
|
format!("{n} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_and_replace(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
main_path: &Path,
|
||||||
|
sink: &ProgressSink,
|
||||||
|
) -> Result<(), String> {
|
||||||
let response = client
|
let response = client
|
||||||
.get(DOWNLOAD_URL)
|
.get(DOWNLOAD_URL)
|
||||||
.send()
|
.send()
|
||||||
@@ -201,17 +264,48 @@ async fn download_and_replace(client: &reqwest::Client, main_path: &Path) -> Res
|
|||||||
return Err(format!("下载主程序 HTTP {}", response.status()));
|
return Err(format!("下载主程序 HTTP {}", response.status()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = response
|
let total = response.content_length();
|
||||||
.bytes()
|
let temp_path = main_path.with_extension("exe.download");
|
||||||
.await
|
let mut file = std::fs::File::create(&temp_path)
|
||||||
.map_err(|e| format!("读取下载内容失败: {e}"))?;
|
.map_err(|e| format!("创建临时文件失败: {e}"))?;
|
||||||
|
|
||||||
if bytes.is_empty() {
|
let mut downloaded: u64 = 0;
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|e| format!("下载数据失败: {e}"))?;
|
||||||
|
use std::io::Write;
|
||||||
|
file.write_all(&chunk)
|
||||||
|
.map_err(|e| format!("写入临时文件失败: {e}"))?;
|
||||||
|
downloaded += chunk.len() as u64;
|
||||||
|
|
||||||
|
let fraction = total.map(|t| {
|
||||||
|
if t == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some((downloaded as f32 / t as f32).clamp(0.0, 1.0))
|
||||||
|
}
|
||||||
|
}).flatten();
|
||||||
|
|
||||||
|
let detail = match total {
|
||||||
|
Some(t) => format!(
|
||||||
|
"{} / {}",
|
||||||
|
format_bytes(downloaded),
|
||||||
|
format_bytes(t)
|
||||||
|
),
|
||||||
|
None => format_bytes(downloaded),
|
||||||
|
};
|
||||||
|
sink.download_progress(fraction, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
if downloaded == 0 {
|
||||||
|
let _ = std::fs::remove_file(&temp_path);
|
||||||
return Err("下载的主程序为空".to_string());
|
return Err("下载的主程序为空".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let temp_path = main_path.with_extension("exe.download");
|
sink.download_progress(Some(1.0), "正在替换主程序…".to_string());
|
||||||
std::fs::write(&temp_path, &bytes).map_err(|e| format!("写入临时文件失败: {e}"))?;
|
|
||||||
|
|
||||||
if main_path.exists() {
|
if main_path.exists() {
|
||||||
std::fs::remove_file(main_path).map_err(|e| format!("删除旧主程序失败: {e}"))?;
|
std::fs::remove_file(main_path).map_err(|e| format!("删除旧主程序失败: {e}"))?;
|
||||||
@@ -238,45 +332,36 @@ fn launch_main_app(main_path: &Path, dir: &Path) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_updater() -> Result<(), String> {
|
pub async fn run_updater(sink: ProgressSink) -> Result<(), String> {
|
||||||
|
sink.status("检查更新中…");
|
||||||
|
|
||||||
let dir = exe_dir()?;
|
let dir = exe_dir()?;
|
||||||
let main_path = dir.join(MAIN_EXE);
|
let main_path = dir.join(MAIN_EXE);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
.map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?;
|
||||||
|
|
||||||
let remote_version = fetch_latest_version(&client).await?;
|
let remote_version = fetch_latest_version(&client).await?;
|
||||||
let needs_update = if main_path.is_file() {
|
let needs_update = if main_path.is_file() {
|
||||||
match read_exe_version(&main_path) {
|
match read_exe_version(&main_path) {
|
||||||
Ok(local_version) => {
|
Ok(local_version) => !versions_match(&local_version, &remote_version),
|
||||||
let matched = versions_match(&local_version, &remote_version);
|
Err(_) => true,
|
||||||
if matched {
|
|
||||||
eprintln!(
|
|
||||||
"当前版本 {local_version} 已是最新 ({remote_version}),跳过下载"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"版本不一致: 本地 {local_version},远端 {remote_version},开始更新"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
!matched
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("读取本地版本失败 ({e}),将尝试下载主程序");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!("主程序不存在,将下载: {}", main_path.display());
|
|
||||||
true
|
true
|
||||||
};
|
};
|
||||||
|
|
||||||
if needs_update {
|
if needs_update {
|
||||||
download_and_replace(&client, &main_path).await?;
|
sink.status("下载中…");
|
||||||
|
sink.download_progress(None, "准备下载…".to_string());
|
||||||
|
download_and_replace(&client, &main_path, &sink).await?;
|
||||||
|
} else {
|
||||||
|
sink.status("当前已是最新版本");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sink.launching();
|
||||||
launch_main_app(&main_path, &dir)?;
|
launch_main_app(&main_path, &dir)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Release 使用 GUI,不弹出控制台窗口
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(all(not(debug_assertions), windows), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod engine;
|
mod engine;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() {
|
||||||
|
ui::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
if let Err(e) = engine::run_updater().await {
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let sink = engine::ProgressSink::new(tx);
|
||||||
|
let result = engine::run_updater(sink).await;
|
||||||
|
drop(rx);
|
||||||
|
if let Err(e) = result {
|
||||||
eprintln!("updater 运行失败: {e}");
|
eprintln!("updater 运行失败: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
219
src-tauri/src/bin/updater/ui.rs
Normal file
219
src-tauri/src/bin/updater/ui.rs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//! updater 图形界面(Windows)
|
||||||
|
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::engine::{ProgressSink, UpdaterMessage};
|
||||||
|
|
||||||
|
const WINDOW_WIDTH: f32 = 420.0;
|
||||||
|
const WINDOW_HEIGHT: f32 = 160.0;
|
||||||
|
|
||||||
|
pub fn run() -> ! {
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT])
|
||||||
|
.with_min_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT])
|
||||||
|
.with_max_inner_size([WINDOW_WIDTH, WINDOW_HEIGHT])
|
||||||
|
.with_resizable(false)
|
||||||
|
.with_title("aiclient 更新")
|
||||||
|
.with_always_on_top(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = eframe::run_native(
|
||||||
|
"aiclient 更新",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| Ok(Box::new(UpdaterApp::new(cc)))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("updater 界面启动失败: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UpdaterApp {
|
||||||
|
rx: Receiver<UpdaterMessage>,
|
||||||
|
status: String,
|
||||||
|
detail: String,
|
||||||
|
progress: Option<f32>,
|
||||||
|
show_bar: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
done: bool,
|
||||||
|
should_close: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdaterApp {
|
||||||
|
fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
setup_chinese_fonts(&cc.egui_ctx);
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let sink = ProgressSink::new(tx.clone());
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(UpdaterMessage::Finished(Err(format!(
|
||||||
|
"初始化运行时失败: {e}"
|
||||||
|
))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = rt.block_on(crate::engine::run_updater(sink));
|
||||||
|
let _ = tx.send(UpdaterMessage::Finished(result));
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
rx,
|
||||||
|
status: "检查更新中…".to_string(),
|
||||||
|
detail: String::new(),
|
||||||
|
progress: None,
|
||||||
|
show_bar: false,
|
||||||
|
error: None,
|
||||||
|
done: false,
|
||||||
|
should_close: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_messages(&mut self) {
|
||||||
|
while let Ok(msg) = self.rx.try_recv() {
|
||||||
|
match msg {
|
||||||
|
UpdaterMessage::Status(text) => {
|
||||||
|
self.status = text;
|
||||||
|
if !self.show_bar {
|
||||||
|
self.detail.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdaterMessage::DownloadProgress { fraction, detail } => {
|
||||||
|
self.show_bar = true;
|
||||||
|
self.status = "下载中…".to_string();
|
||||||
|
self.progress = fraction;
|
||||||
|
self.detail = detail;
|
||||||
|
}
|
||||||
|
UpdaterMessage::Launching => {
|
||||||
|
self.show_bar = false;
|
||||||
|
self.status = "正在启动主程序…".to_string();
|
||||||
|
self.detail.clear();
|
||||||
|
}
|
||||||
|
UpdaterMessage::Finished(result) => {
|
||||||
|
self.done = true;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.should_close = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error = Some(e);
|
||||||
|
self.status = "更新失败".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for UpdaterApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.poll_messages();
|
||||||
|
|
||||||
|
if self.should_close {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.heading(&self.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
if self.show_bar {
|
||||||
|
let mut bar = egui::ProgressBar::new(self.progress.unwrap_or(0.0))
|
||||||
|
.animate(self.progress.is_none());
|
||||||
|
if self.progress.is_some() {
|
||||||
|
bar = bar.show_percentage();
|
||||||
|
}
|
||||||
|
ui.add(bar);
|
||||||
|
|
||||||
|
if !self.detail.is_empty() {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&self.detail)
|
||||||
|
.size(13.0)
|
||||||
|
.color(ui.visuals().weak_text_color()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if !self.detail.is_empty() {
|
||||||
|
ui.label(&self.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(err) = &self.error {
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(err)
|
||||||
|
.color(egui::Color32::from_rgb(220, 80, 80)),
|
||||||
|
);
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
if ui.button("关闭").clicked() {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if !self.done {
|
||||||
|
ctx.request_repaint_after(Duration::from_millis(80));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||||
|
if self.error.is_some() {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_chinese_fonts(ctx: &egui::Context) {
|
||||||
|
let mut fonts = egui::FontDefinitions::default();
|
||||||
|
|
||||||
|
let candidates = [
|
||||||
|
"C:\\Windows\\Fonts\\msyh.ttc",
|
||||||
|
"C:\\Windows\\Fonts\\msyhbd.ttc",
|
||||||
|
"C:\\Windows\\Fonts\\simhei.ttf",
|
||||||
|
"C:\\Windows\\Fonts\\simsun.ttc",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in candidates {
|
||||||
|
if let Ok(bytes) = std::fs::read(path) {
|
||||||
|
fonts.font_data.insert(
|
||||||
|
"chinese".to_owned(),
|
||||||
|
egui::FontData::from_owned(bytes).into(),
|
||||||
|
);
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(egui::FontFamily::Proportional)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "chinese".to_owned());
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.entry(egui::FontFamily::Monospace)
|
||||||
|
.or_default()
|
||||||
|
.insert(0, "chinese".to_owned());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set_fonts(fonts);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "aiclient",
|
"productName": "aiclient",
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"identifier": "com.aiclient.desktop",
|
"identifier": "com.aiclient.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user