refactor(desktop): consolidate app on docked client
This commit is contained in:
@@ -1,20 +1,19 @@
|
|||||||
# 森林AI 桌面客户端
|
# 森林AI App
|
||||||
|
|
||||||
桌面端提供两个独立可执行文件:
|
森林AI唯一的桌面客户端,采用窄屏停靠布局,并在 Windows 上通过系统 AppBar 预留屏幕左侧或右侧工作区。
|
||||||
|
|
||||||
- `senlinai-mini.exe`:默认启动入口。窄屏、置顶,启动后按当前显示器高度停靠在屏幕右侧,也可以切换到左侧。
|
|
||||||
- `senlinai-full.exe`:完整工作台客户端。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 默认启动 Mini
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run desktop:dev
|
||||||
# 分别启动
|
|
||||||
npm run mini:dev
|
|
||||||
npm run full:dev
|
|
||||||
|
|
||||||
# 一次构建两个 EXE
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
两个构建产物会汇总到 `apps/desktop/release`。
|
生产构建:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run desktop:build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Web 开发端口:`5180`
|
||||||
|
- 默认窗口宽度:`420px`,可调整
|
||||||
|
- Windows 会为面板预留工作区;最大化的其他应用不会遮挡它
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/senlinai-icon.svg" />
|
||||||
<title>森林AI Mini</title>
|
<title>森林AI App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
1810
apps/desktop/package-lock.json
generated
1810
apps/desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "desktop",
|
"name": "senlinai-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"visual-check": "node scripts/visual-check.mjs",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"start": "npm run mini:dev",
|
"desktop:dev": "tauri dev",
|
||||||
"dev": "npm run mini:dev",
|
"desktop:build": "tauri build --no-bundle"
|
||||||
"mini:dev": "npm --prefix ../mini run desktop:dev",
|
},
|
||||||
"full:dev": "tauri dev",
|
"dependencies": {
|
||||||
"mini:build": "npm --prefix ../mini run desktop:build",
|
"@arco-design/web-react": "^2.66.16",
|
||||||
"full:build": "tauri build --no-bundle",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"build": "node scripts/build-all.mjs",
|
"react": "^18.3.1",
|
||||||
"bundle": "tauri build --ci --no-sign"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.3"
|
"@tauri-apps/cli": "^2.9.3",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^18.3.23",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 731 B |
@@ -1,44 +0,0 @@
|
|||||||
import { copyFileSync, mkdirSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { spawnSync } from 'node:child_process'
|
|
||||||
|
|
||||||
const desktopDir = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
||||||
const repoDir = join(desktopDir, '..', '..')
|
|
||||||
const isWindows = process.platform === 'win32'
|
|
||||||
const npmCommand = isWindows ? process.env.ComSpec || 'cmd.exe' : 'npm'
|
|
||||||
|
|
||||||
function run(script) {
|
|
||||||
const args = isWindows ? ['/d', '/s', '/c', `npm run ${script}`] : ['run', script]
|
|
||||||
const result = spawnSync(npmCommand, args, {
|
|
||||||
cwd: desktopDir,
|
|
||||||
stdio: 'inherit',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result.error) throw result.error
|
|
||||||
if (result.status !== 0) {
|
|
||||||
process.exit(result.status ?? 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run('full:build')
|
|
||||||
run('mini:build')
|
|
||||||
|
|
||||||
const releaseDir = join(desktopDir, 'release')
|
|
||||||
mkdirSync(releaseDir, { recursive: true })
|
|
||||||
|
|
||||||
const artifacts = [
|
|
||||||
{
|
|
||||||
source: join(desktopDir, 'src-tauri', 'target', 'release', 'senlinai-full.exe'),
|
|
||||||
target: join(releaseDir, 'senlinai-full.exe'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: join(repoDir, 'apps', 'mini', 'src-tauri', 'target', 'release', 'senlinai-mini.exe'),
|
|
||||||
target: join(releaseDir, 'senlinai-mini.exe'),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const artifact of artifacts) {
|
|
||||||
copyFileSync(artifact.source, artifact.target)
|
|
||||||
console.log(`Created ${artifact.target}`)
|
|
||||||
}
|
|
||||||
669
apps/desktop/src-tauri/Cargo.lock
generated
669
apps/desktop/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "senlinai-full"
|
name = "senlinai-app"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "森林AI 完整版桌面客户端"
|
description = "森林AI 迷你停靠客户端"
|
||||||
authors = ["森林AI"]
|
authors = ["森林AI"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "senlinai_workbench_lib"
|
name = "senlinai_app_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
@@ -14,7 +14,6 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,66 @@
|
|||||||
|
use tauri::Window;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
use tauri::{PhysicalPosition, PhysicalSize};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod windows_appbar;
|
mod windows_appbar;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn dock_window(window: Window, side: String) -> Result<(), String> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let hwnd = window.hwnd().map_err(|error| error.to_string())?;
|
||||||
|
return windows_appbar::set_edge(hwnd, side == "left").map_err(|error| error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let monitor = window
|
||||||
|
.current_monitor()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.ok_or_else(|| "Unable to identify the current monitor".to_string())?;
|
||||||
|
let window_size = window.outer_size().map_err(|error| error.to_string())?;
|
||||||
|
let work_area = monitor.work_area();
|
||||||
|
let dock_size = PhysicalSize::new(window_size.width, work_area.size.height);
|
||||||
|
let x = if side == "left" {
|
||||||
|
work_area.position.x
|
||||||
|
} else {
|
||||||
|
work_area.position.x + work_area.size.width as i32 - dock_size.width as i32
|
||||||
|
};
|
||||||
|
|
||||||
|
window
|
||||||
|
.set_size(dock_size)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
window
|
||||||
|
.set_position(PhysicalPosition::new(x, work_area.position.y))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
window
|
||||||
|
.set_always_on_top(true)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let window = app
|
let window = app
|
||||||
.get_webview_window("main")
|
.get_webview_window("main")
|
||||||
.expect("main window must be configured");
|
.expect("main window must be configured");
|
||||||
windows_appbar::register_right_edge(window.hwnd()?)?;
|
windows_appbar::register(window.hwnd()?)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![dock_window])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("failed to build SenlinAI desktop app")
|
.expect("failed to build SenlinAI App")
|
||||||
.run(|_, event| {
|
.run(|_, event| {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
|
if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
senlinai_workbench_lib::run()
|
senlinai_app_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
//! Windows-only system AppBar support for the desktop side panel.
|
//! Windows system AppBar integration for the App side panel.
|
||||||
//!
|
|
||||||
//! An AppBar reserves desktop work area, so maximized windows do not cover the
|
|
||||||
//! right-hand SenlinAI panel. Tauri does not expose this Win32 shell feature.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
|
||||||
|
|
||||||
@@ -15,7 +12,7 @@ use windows::{
|
|||||||
UI::{
|
UI::{
|
||||||
Shell::{
|
Shell::{
|
||||||
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
||||||
ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
ABE_LEFT, ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
||||||
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
||||||
},
|
},
|
||||||
WindowsAndMessaging::{
|
WindowsAndMessaging::{
|
||||||
@@ -30,18 +27,16 @@ const SUBCLASS_ID: usize = 0x5341_4142; // "SAAB" (SenlinAI AppBar)
|
|||||||
|
|
||||||
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
||||||
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
||||||
|
static EDGE: AtomicU32 = AtomicU32::new(ABE_RIGHT);
|
||||||
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
/// Registers `hwnd` as the single right-edge application desktop toolbar.
|
/// Registers the main App window as a right-edge AppBar.
|
||||||
///
|
pub fn register(hwnd: HWND) -> Result<()> {
|
||||||
/// The width is taken from the configured window width. The shell coordinates
|
|
||||||
/// the remaining dimensions with the taskbar and any other AppBars.
|
|
||||||
pub fn register_right_edge(hwnd: HWND) -> Result<()> {
|
|
||||||
if REGISTERED_HWND.load(Ordering::Acquire) != 0 {
|
if REGISTERED_HWND.load(Ordering::Acquire) != 0 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let callback_message = unsafe { RegisterWindowMessageW(w!("SenlinAI.AppBar.Callback")) };
|
let callback_message = unsafe { RegisterWindowMessageW(w!("SenlinAI.App.AppBar.Callback")) };
|
||||||
if callback_message == 0 {
|
if callback_message == 0 {
|
||||||
return Err(Error::from_win32());
|
return Err(Error::from_win32());
|
||||||
}
|
}
|
||||||
@@ -62,7 +57,7 @@ pub fn register_right_edge(hwnd: HWND) -> Result<()> {
|
|||||||
CALLBACK_MESSAGE.store(callback_message, Ordering::Release);
|
CALLBACK_MESSAGE.store(callback_message, Ordering::Release);
|
||||||
REGISTERED_HWND.store(hwnd.0 as isize, Ordering::Release);
|
REGISTERED_HWND.store(hwnd.0 as isize, Ordering::Release);
|
||||||
|
|
||||||
if let Err(error) = layout_right_edge(hwnd) {
|
if let Err(error) = layout(hwnd) {
|
||||||
unregister();
|
unregister();
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
@@ -70,6 +65,12 @@ pub fn register_right_edge(hwnd: HWND) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repositions the AppBar after the App UI selects the left or right edge.
|
||||||
|
pub fn set_edge(hwnd: HWND, left: bool) -> Result<()> {
|
||||||
|
EDGE.store(if left { ABE_LEFT } else { ABE_RIGHT }, Ordering::Release);
|
||||||
|
layout(hwnd)
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
||||||
pub fn unregister() {
|
pub fn unregister() {
|
||||||
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
||||||
@@ -98,7 +99,7 @@ unsafe extern "system" fn appbar_subclass_proc(
|
|||||||
let callback_message = CALLBACK_MESSAGE.load(Ordering::Acquire);
|
let callback_message = CALLBACK_MESSAGE.load(Ordering::Acquire);
|
||||||
|
|
||||||
if message == callback_message && wparam.0 as u32 == ABN_POSCHANGED {
|
if message == callback_message && wparam.0 as u32 == ABN_POSCHANGED {
|
||||||
let _ = layout_right_edge(hwnd);
|
let _ = layout(hwnd);
|
||||||
} else if message == WM_ACTIVATE {
|
} else if message == WM_ACTIVATE {
|
||||||
let mut data = appbar_data(hwnd);
|
let mut data = appbar_data(hwnd);
|
||||||
SHAppBarMessage(ABM_ACTIVATE, &mut data);
|
SHAppBarMessage(ABM_ACTIVATE, &mut data);
|
||||||
@@ -106,27 +107,25 @@ unsafe extern "system" fn appbar_subclass_proc(
|
|||||||
let mut data = appbar_data(hwnd);
|
let mut data = appbar_data(hwnd);
|
||||||
SHAppBarMessage(ABM_WINDOWPOSCHANGED, &mut data);
|
SHAppBarMessage(ABM_WINDOWPOSCHANGED, &mut data);
|
||||||
|
|
||||||
// A native move/resize must be reconciled with the shell reservation.
|
|
||||||
// Calls caused by our own SetWindowPos are ignored to avoid recursion.
|
|
||||||
if !LAYOUT_IN_PROGRESS.load(Ordering::Acquire) {
|
if !LAYOUT_IN_PROGRESS.load(Ordering::Acquire) {
|
||||||
let _ = layout_right_edge(hwnd);
|
let _ = layout(hwnd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DefSubclassProc(hwnd, message, wparam, lparam)
|
DefSubclassProc(hwnd, message, wparam, lparam)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_right_edge(hwnd: HWND) -> Result<()> {
|
fn layout(hwnd: HWND) -> Result<()> {
|
||||||
if LAYOUT_IN_PROGRESS.swap(true, Ordering::AcqRel) {
|
if LAYOUT_IN_PROGRESS.swap(true, Ordering::AcqRel) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = layout_right_edge_inner(hwnd);
|
let result = layout_inner(hwnd);
|
||||||
LAYOUT_IN_PROGRESS.store(false, Ordering::Release);
|
LAYOUT_IN_PROGRESS.store(false, Ordering::Release);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn layout_right_edge_inner(hwnd: HWND) -> Result<()> {
|
fn layout_inner(hwnd: HWND) -> Result<()> {
|
||||||
let mut window_rect = RECT::default();
|
let mut window_rect = RECT::default();
|
||||||
unsafe { GetWindowRect(hwnd, &mut window_rect)? };
|
unsafe { GetWindowRect(hwnd, &mut window_rect)? };
|
||||||
let width = window_rect.right - window_rect.left;
|
let width = window_rect.right - window_rect.left;
|
||||||
@@ -138,16 +137,23 @@ fn layout_right_edge_inner(hwnd: HWND) -> Result<()> {
|
|||||||
};
|
};
|
||||||
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
||||||
|
|
||||||
|
let edge = EDGE.load(Ordering::Acquire);
|
||||||
let mut data = appbar_data(hwnd);
|
let mut data = appbar_data(hwnd);
|
||||||
data.uEdge = ABE_RIGHT;
|
data.uEdge = edge;
|
||||||
data.rc = monitor_info.rcMonitor;
|
data.rc = monitor_info.rcMonitor;
|
||||||
data.rc.left = data.rc.right - width;
|
if edge == ABE_LEFT {
|
||||||
|
data.rc.right = data.rc.left + width;
|
||||||
|
} else {
|
||||||
|
data.rc.left = data.rc.right - width;
|
||||||
|
}
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
// The shell first removes occupied AppBar/taskbar space. Restore our
|
|
||||||
// requested width, then commit the returned, conflict-free rectangle.
|
|
||||||
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
||||||
data.rc.left = data.rc.right - width;
|
if edge == ABE_LEFT {
|
||||||
|
data.rc.right = data.rc.left + width;
|
||||||
|
} else {
|
||||||
|
data.rc.left = data.rc.right - width;
|
||||||
|
}
|
||||||
SHAppBarMessage(ABM_SETPOS, &mut data);
|
SHAppBarMessage(ABM_SETPOS, &mut data);
|
||||||
SetWindowPos(
|
SetWindowPos(
|
||||||
hwnd,
|
hwnd,
|
||||||
|
|||||||
@@ -1,24 +1,27 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "森林AI Full",
|
"productName": "森林AI App",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"identifier": "ai.senlin.workbench",
|
"identifier": "ai.senlin.client",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../../web_v1/dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5180",
|
||||||
"beforeDevCommand": "cd ../web_v1 && npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"beforeBuildCommand": "cd ../web_v1 && npm run build"
|
"beforeBuildCommand": "npm run build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "森林AI Full",
|
"label": "main",
|
||||||
"width": 380,
|
"title": "森林AI App",
|
||||||
|
"width": 420,
|
||||||
"height": 820,
|
"height": 820,
|
||||||
"minWidth": 300,
|
"minWidth": 360,
|
||||||
"minHeight": 480,
|
"minHeight": 640,
|
||||||
"resizable": false,
|
"resizable": true,
|
||||||
"maximizable": false
|
"maximizable": false,
|
||||||
|
"alwaysOnTop": true,
|
||||||
|
"center": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function App() {
|
|||||||
<div className="mini-brand" data-tauri-drag-region>
|
<div className="mini-brand" data-tauri-drag-region>
|
||||||
<img src="/senlinai-icon.svg" alt="" />
|
<img src="/senlinai-icon.svg" alt="" />
|
||||||
<span>森林AI</span>
|
<span>森林AI</span>
|
||||||
<Tag size="small" color="arcoblue">Mini 1.0</Tag>
|
<Tag size="small" color="arcoblue">App 1.0</Tag>
|
||||||
</div>
|
</div>
|
||||||
<div className="window-actions">
|
<div className="window-actions">
|
||||||
<Button aria-label="停靠左侧" className={dockSide === 'left' ? 'active' : ''} type="text" size="mini" icon={<IconArrowLeft />} onClick={() => void dock('left')} />
|
<Button aria-label="停靠左侧" className={dockSide === 'left' ? 'active' : ''} type="text" size="mini" icon={<IconArrowLeft />} onClick={() => void dock('left')} />
|
||||||
@@ -301,7 +301,7 @@ function MoreView({ workspace, openAction }: ViewProps) {
|
|||||||
<div className="page-title"><div><Title heading={5}>更多</Title><Text type="secondary">项目周期安排与客户端信息</Text></div><Button size="small" icon={<IconPlus />} onClick={() => openAction('cron')}>计划任务</Button></div>
|
<div className="page-title"><div><Title heading={5}>更多</Title><Text type="secondary">项目周期安排与客户端信息</Text></div><Button size="small" icon={<IconPlus />} onClick={() => openAction('cron')}>计划任务</Button></div>
|
||||||
<div className="settings-card"><strong>计划任务</strong>{workspace.cronPlans.map((plan) => <div key={plan.id}><IconClockCircle /><span><b>{plan.title}</b><small>{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}</small></span></div>)}{!workspace.cronPlans.length && <Text type="secondary">暂无周期计划</Text>}</div>
|
<div className="settings-card"><strong>计划任务</strong>{workspace.cronPlans.map((plan) => <div key={plan.id}><IconClockCircle /><span><b>{plan.title}</b><small>{plan.schedule} · {plan.enabled ? '已启用' : '已停用'}</small></span></div>)}{!workspace.cronPlans.length && <Text type="secondary">暂无周期计划</Text>}</div>
|
||||||
<div className="settings-card"><strong>停靠说明</strong><p>顶部箭头可将桌面窗口停靠至当前屏幕左侧或右侧。Web 模式会记住布局偏好,桌面模式会同时移动实际窗口。</p></div>
|
<div className="settings-card"><strong>停靠说明</strong><p>顶部箭头可将桌面窗口停靠至当前屏幕左侧或右侧。Web 模式会记住布局偏好,桌面模式会同时移动实际窗口。</p></div>
|
||||||
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>森林AI Mini</b><small>v1.0 完整版 · 窄屏停靠客户端</small></span></div>
|
<div className="settings-card version-card"><img src="/senlinai-icon.svg" alt="" /><span><b>森林AI App</b><small>v1.0 · 停靠客户端</small></span></div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) {
|
|||||||
<div className="mini-login">
|
<div className="mini-login">
|
||||||
<form className="login-note" onSubmit={(event) => { event.preventDefault(); void submitLogin() }}>
|
<form className="login-note" onSubmit={(event) => { event.preventDefault(); void submitLogin() }}>
|
||||||
<img src="/senlinai-icon.svg" alt="" />
|
<img src="/senlinai-icon.svg" alt="" />
|
||||||
<Title heading={3}>森林AI Mini</Title>
|
<Title heading={3}>森林AI App</Title>
|
||||||
<Text type="secondary">停靠在屏幕一侧,随时记录与推进项目</Text>
|
<Text type="secondary">停靠在屏幕一侧,随时记录与推进项目</Text>
|
||||||
<label>服务器地址<Input value={server} onChange={setServer} /></label>
|
<label>服务器地址<Input value={server} onChange={setServer} /></label>
|
||||||
<div className={`mini-connection ${connection}`} role="status">
|
<div className={`mini-connection ${connection}`} role="status">
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 森林AI Mini
|
|
||||||
|
|
||||||
面向屏幕左右侧停靠场景的窄屏客户端。它使用与 `web_v1` 相同的后端 API,采用顶部导航和单列内容布局。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
npm run desktop:dev
|
|
||||||
```
|
|
||||||
|
|
||||||
- Web 开发端口:`5180`
|
|
||||||
- 桌面窗口默认宽度:`420`;启动后高度自动适配当前屏幕可用工作区,不遮挡 Windows 任务栏,并停靠在屏幕右侧
|
|
||||||
- 左右停靠状态保存在本地;在 Tauri 桌面环境中会移动实际窗口。
|
|
||||||
2047
apps/mini/package-lock.json
generated
2047
apps/mini/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "senlinai-mini",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "oxlint",
|
|
||||||
"visual-check": "node scripts/visual-check.mjs",
|
|
||||||
"tauri": "tauri",
|
|
||||||
"desktop:dev": "tauri dev",
|
|
||||||
"desktop:build": "tauri build --no-bundle"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@arco-design/web-react": "^2.66.16",
|
|
||||||
"@tauri-apps/api": "^2.9.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tauri-apps/cli": "^2.9.3",
|
|
||||||
"@types/node": "^24.13.2",
|
|
||||||
"@types/react": "^18.3.23",
|
|
||||||
"@types/react-dom": "^18.3.7",
|
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
|
||||||
"oxlint": "^1.71.0",
|
|
||||||
"playwright": "^1.61.1",
|
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"vite": "^8.1.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4395
apps/mini/src-tauri/Cargo.lock
generated
4395
apps/mini/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "senlinai-mini"
|
|
||||||
version = "1.0.0"
|
|
||||||
description = "森林AI 迷你停靠客户端"
|
|
||||||
authors = ["森林AI"]
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "senlinai_mini_lib"
|
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2", features = [] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tauri = { version = "2", features = [] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
windows = { version = "0.61", features = [
|
|
||||||
"Win32_Foundation",
|
|
||||||
"Win32_Graphics_Gdi",
|
|
||||||
"Win32_UI_Shell",
|
|
||||||
"Win32_UI_WindowsAndMessaging",
|
|
||||||
] }
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 926 B |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,70 +0,0 @@
|
|||||||
use tauri::Window;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
use tauri::Manager;
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
use tauri::{PhysicalPosition, PhysicalSize};
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
mod windows_appbar;
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
fn dock_window(window: Window, side: String) -> Result<(), String> {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let hwnd = window.hwnd().map_err(|error| error.to_string())?;
|
|
||||||
return windows_appbar::set_edge(hwnd, side == "left").map_err(|error| error.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
{
|
|
||||||
let monitor = window
|
|
||||||
.current_monitor()
|
|
||||||
.map_err(|error| error.to_string())?
|
|
||||||
.ok_or_else(|| "Unable to identify the current monitor".to_string())?;
|
|
||||||
let window_size = window.outer_size().map_err(|error| error.to_string())?;
|
|
||||||
let work_area = monitor.work_area();
|
|
||||||
let dock_size = PhysicalSize::new(window_size.width, work_area.size.height);
|
|
||||||
let x = if side == "left" {
|
|
||||||
work_area.position.x
|
|
||||||
} else {
|
|
||||||
work_area.position.x + work_area.size.width as i32 - dock_size.width as i32
|
|
||||||
};
|
|
||||||
|
|
||||||
window
|
|
||||||
.set_size(dock_size)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
window
|
|
||||||
.set_position(PhysicalPosition::new(x, work_area.position.y))
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
window
|
|
||||||
.set_always_on_top(true)
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
||||||
pub fn run() {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.setup(|app| {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let window = app
|
|
||||||
.get_webview_window("main")
|
|
||||||
.expect("main window must be configured");
|
|
||||||
windows_appbar::register(window.hwnd()?)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.invoke_handler(tauri::generate_handler![dock_window])
|
|
||||||
.build(tauri::generate_context!())
|
|
||||||
.expect("failed to build SenlinAI Mini")
|
|
||||||
.run(|_, event| {
|
|
||||||
#[cfg(windows)]
|
|
||||||
if matches!(event, tauri::RunEvent::ExitRequested { .. }) {
|
|
||||||
windows_appbar::unregister();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
senlinai_mini_lib::run();
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
//! Windows system AppBar integration for the Mini side panel.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
|
|
||||||
|
|
||||||
use windows::{
|
|
||||||
core::{w, Error, Result},
|
|
||||||
Win32::{
|
|
||||||
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
|
|
||||||
Graphics::Gdi::{
|
|
||||||
GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST,
|
|
||||||
},
|
|
||||||
UI::{
|
|
||||||
Shell::{
|
|
||||||
DefSubclassProc, RemoveWindowSubclass, SHAppBarMessage, SetWindowSubclass,
|
|
||||||
ABE_LEFT, ABE_RIGHT, ABM_ACTIVATE, ABM_NEW, ABM_QUERYPOS, ABM_REMOVE, ABM_SETPOS,
|
|
||||||
ABM_WINDOWPOSCHANGED, ABN_POSCHANGED, APPBARDATA,
|
|
||||||
},
|
|
||||||
WindowsAndMessaging::{
|
|
||||||
GetWindowRect, RegisterWindowMessageW, SetWindowPos, SWP_NOACTIVATE, SWP_NOZORDER,
|
|
||||||
WM_ACTIVATE, WM_WINDOWPOSCHANGED,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBCLASS_ID: usize = 0x5341_4D42; // "SAMB" (SenlinAI Mini AppBar)
|
|
||||||
|
|
||||||
static CALLBACK_MESSAGE: AtomicU32 = AtomicU32::new(0);
|
|
||||||
static REGISTERED_HWND: AtomicIsize = AtomicIsize::new(0);
|
|
||||||
static EDGE: AtomicU32 = AtomicU32::new(ABE_RIGHT);
|
|
||||||
static LAYOUT_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
/// Registers the main Mini window as a right-edge AppBar.
|
|
||||||
pub fn register(hwnd: HWND) -> Result<()> {
|
|
||||||
if REGISTERED_HWND.load(Ordering::Acquire) != 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let callback_message = unsafe { RegisterWindowMessageW(w!("SenlinAI.Mini.AppBar.Callback")) };
|
|
||||||
if callback_message == 0 {
|
|
||||||
return Err(Error::from_win32());
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
SetWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID, 0).ok()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut data = appbar_data(hwnd);
|
|
||||||
data.uCallbackMessage = callback_message;
|
|
||||||
if unsafe { SHAppBarMessage(ABM_NEW, &mut data) } == 0 {
|
|
||||||
unsafe {
|
|
||||||
let _ = RemoveWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID);
|
|
||||||
}
|
|
||||||
return Err(Error::from_win32());
|
|
||||||
}
|
|
||||||
|
|
||||||
CALLBACK_MESSAGE.store(callback_message, Ordering::Release);
|
|
||||||
REGISTERED_HWND.store(hwnd.0 as isize, Ordering::Release);
|
|
||||||
|
|
||||||
if let Err(error) = layout(hwnd) {
|
|
||||||
unregister();
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Repositions the AppBar after the Mini UI selects the left or right edge.
|
|
||||||
pub fn set_edge(hwnd: HWND, left: bool) -> Result<()> {
|
|
||||||
EDGE.store(if left { ABE_LEFT } else { ABE_RIGHT }, Ordering::Release);
|
|
||||||
layout(hwnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the AppBar reservation before the Tauri window is destroyed.
|
|
||||||
pub fn unregister() {
|
|
||||||
let raw_hwnd = REGISTERED_HWND.swap(0, Ordering::AcqRel);
|
|
||||||
CALLBACK_MESSAGE.store(0, Ordering::Release);
|
|
||||||
|
|
||||||
if raw_hwnd == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hwnd = HWND(raw_hwnd as *mut _);
|
|
||||||
let mut data = appbar_data(hwnd);
|
|
||||||
unsafe {
|
|
||||||
SHAppBarMessage(ABM_REMOVE, &mut data);
|
|
||||||
let _ = RemoveWindowSubclass(hwnd, Some(appbar_subclass_proc), SUBCLASS_ID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe extern "system" fn appbar_subclass_proc(
|
|
||||||
hwnd: HWND,
|
|
||||||
message: u32,
|
|
||||||
wparam: WPARAM,
|
|
||||||
lparam: LPARAM,
|
|
||||||
_subclass_id: usize,
|
|
||||||
_reference_data: usize,
|
|
||||||
) -> LRESULT {
|
|
||||||
let callback_message = CALLBACK_MESSAGE.load(Ordering::Acquire);
|
|
||||||
|
|
||||||
if message == callback_message && wparam.0 as u32 == ABN_POSCHANGED {
|
|
||||||
let _ = layout(hwnd);
|
|
||||||
} else if message == WM_ACTIVATE {
|
|
||||||
let mut data = appbar_data(hwnd);
|
|
||||||
SHAppBarMessage(ABM_ACTIVATE, &mut data);
|
|
||||||
} else if message == WM_WINDOWPOSCHANGED {
|
|
||||||
let mut data = appbar_data(hwnd);
|
|
||||||
SHAppBarMessage(ABM_WINDOWPOSCHANGED, &mut data);
|
|
||||||
|
|
||||||
if !LAYOUT_IN_PROGRESS.load(Ordering::Acquire) {
|
|
||||||
let _ = layout(hwnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DefSubclassProc(hwnd, message, wparam, lparam)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout(hwnd: HWND) -> Result<()> {
|
|
||||||
if LAYOUT_IN_PROGRESS.swap(true, Ordering::AcqRel) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = layout_inner(hwnd);
|
|
||||||
LAYOUT_IN_PROGRESS.store(false, Ordering::Release);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn layout_inner(hwnd: HWND) -> Result<()> {
|
|
||||||
let mut window_rect = RECT::default();
|
|
||||||
unsafe { GetWindowRect(hwnd, &mut window_rect)? };
|
|
||||||
let width = window_rect.right - window_rect.left;
|
|
||||||
|
|
||||||
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
|
|
||||||
let mut monitor_info = MONITORINFO {
|
|
||||||
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
unsafe { GetMonitorInfoW(monitor, &mut monitor_info).ok()? };
|
|
||||||
|
|
||||||
let edge = EDGE.load(Ordering::Acquire);
|
|
||||||
let mut data = appbar_data(hwnd);
|
|
||||||
data.uEdge = edge;
|
|
||||||
data.rc = monitor_info.rcMonitor;
|
|
||||||
if edge == ABE_LEFT {
|
|
||||||
data.rc.right = data.rc.left + width;
|
|
||||||
} else {
|
|
||||||
data.rc.left = data.rc.right - width;
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
SHAppBarMessage(ABM_QUERYPOS, &mut data);
|
|
||||||
if edge == ABE_LEFT {
|
|
||||||
data.rc.right = data.rc.left + width;
|
|
||||||
} else {
|
|
||||||
data.rc.left = data.rc.right - width;
|
|
||||||
}
|
|
||||||
SHAppBarMessage(ABM_SETPOS, &mut data);
|
|
||||||
SetWindowPos(
|
|
||||||
hwnd,
|
|
||||||
None,
|
|
||||||
data.rc.left,
|
|
||||||
data.rc.top,
|
|
||||||
data.rc.right - data.rc.left,
|
|
||||||
data.rc.bottom - data.rc.top,
|
|
||||||
SWP_NOACTIVATE | SWP_NOZORDER,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn appbar_data(hwnd: HWND) -> APPBARDATA {
|
|
||||||
APPBARDATA {
|
|
||||||
cbSize: std::mem::size_of::<APPBARDATA>() as u32,
|
|
||||||
hWnd: hwnd,
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"productName": "森林AI Mini",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"identifier": "ai.senlin.mini",
|
|
||||||
"build": {
|
|
||||||
"frontendDist": "../dist",
|
|
||||||
"devUrl": "http://localhost:5180",
|
|
||||||
"beforeDevCommand": "npm run dev",
|
|
||||||
"beforeBuildCommand": "npm run build"
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"label": "main",
|
|
||||||
"title": "森林AI Mini",
|
|
||||||
"width": 420,
|
|
||||||
"height": 820,
|
|
||||||
"minWidth": 360,
|
|
||||||
"minHeight": 640,
|
|
||||||
"resizable": true,
|
|
||||||
"maximizable": false,
|
|
||||||
"alwaysOnTop": true,
|
|
||||||
"center": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"security": {
|
|
||||||
"csp": "default-src 'self'; img-src 'self' asset: https: data:; style-src 'self' 'unsafe-inline'; connect-src http: https:"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"active": true,
|
|
||||||
"targets": "all",
|
|
||||||
"icon": [
|
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
cd "$ROOT_DIR/apps/desktop"
|
cd "$ROOT_DIR/apps/desktop"
|
||||||
npm run build
|
npm run desktop:build
|
||||||
|
|
||||||
echo "Desktop build completed."
|
echo "Desktop build completed."
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user