@steerable/agent-shell-tauri 0.6.51 → 0.6.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.toml +8 -0
- package/package.json +1 -1
- package/src/clipboard.rs +206 -0
- package/src/commands.rs +7 -4
- package/src/host.rs +49 -5
- package/src/lib.rs +117 -15
package/Cargo.toml
CHANGED
|
@@ -26,3 +26,11 @@ tauri-plugin-single-instance = "2.4.5"
|
|
|
26
26
|
tauri-plugin-updater = "2.12.0"
|
|
27
27
|
tokio = { version = "1", features = ["io-util", "macros", "process", "sync", "time"] }
|
|
28
28
|
url = "2"
|
|
29
|
+
|
|
30
|
+
[target.'cfg(windows)'.dependencies]
|
|
31
|
+
clipboard-win = "5.4.1"
|
|
32
|
+
|
|
33
|
+
[target.'cfg(target_os = "macos")'.dependencies]
|
|
34
|
+
objc2 = "0.6.4"
|
|
35
|
+
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSPasteboard"] }
|
|
36
|
+
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSURL", "NSString"] }
|
package/package.json
CHANGED
package/src/clipboard.rs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
//! OS pasteboard reads for the chat composer.
|
|
2
|
+
//!
|
|
3
|
+
//! WKWebView does not deliver copied files or screenshots to the page.
|
|
4
|
+
//! File lists are read on macOS and Windows. Image data is encoded as PNG
|
|
5
|
+
//! on every desktop.
|
|
6
|
+
|
|
7
|
+
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
8
|
+
use image::codecs::png::PngEncoder;
|
|
9
|
+
use image::{ExtendedColorType, ImageEncoder};
|
|
10
|
+
use serde::Serialize;
|
|
11
|
+
|
|
12
|
+
const MAX_CLIPBOARD_IMAGE_BYTES: usize = 32 * 1024 * 1024;
|
|
13
|
+
const MAX_CLIPBOARD_FILES: usize = 100;
|
|
14
|
+
|
|
15
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
16
|
+
#[serde(rename_all = "camelCase")]
|
|
17
|
+
pub struct ClipboardFile {
|
|
18
|
+
pub name: String,
|
|
19
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
20
|
+
pub path: Option<String>,
|
|
21
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
22
|
+
pub data_base64: Option<String>,
|
|
23
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
24
|
+
pub mime: Option<String>,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
28
|
+
#[serde(rename_all = "camelCase")]
|
|
29
|
+
pub struct ClipboardContents {
|
|
30
|
+
pub text: String,
|
|
31
|
+
pub files: Vec<ClipboardFile>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Files win over text, and text wins over a bare image.
|
|
35
|
+
///
|
|
36
|
+
/// The file manager copies a path string next to the file list. Returning
|
|
37
|
+
/// that string would also paste the path into the composer. A screenshot
|
|
38
|
+
/// has no text.
|
|
39
|
+
pub fn read_clipboard() -> ClipboardContents {
|
|
40
|
+
let files = clipboard_file_paths()
|
|
41
|
+
.into_iter()
|
|
42
|
+
.take(MAX_CLIPBOARD_FILES)
|
|
43
|
+
.map(|path| ClipboardFile {
|
|
44
|
+
name: file_name(&path),
|
|
45
|
+
path: Some(path),
|
|
46
|
+
data_base64: None,
|
|
47
|
+
mime: None,
|
|
48
|
+
})
|
|
49
|
+
.collect::<Vec<_>>();
|
|
50
|
+
if !files.is_empty() {
|
|
51
|
+
return ClipboardContents {
|
|
52
|
+
text: String::new(),
|
|
53
|
+
files,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let text = read_clipboard_text();
|
|
57
|
+
if !text.is_empty() {
|
|
58
|
+
return ClipboardContents {
|
|
59
|
+
text,
|
|
60
|
+
files: Vec::new(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
ClipboardContents {
|
|
64
|
+
text: String::new(),
|
|
65
|
+
files: clipboard_image().into_iter().collect(),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub fn file_name(path: &str) -> String {
|
|
70
|
+
std::path::Path::new(path)
|
|
71
|
+
.file_name()
|
|
72
|
+
.map(|name| name.to_string_lossy().into_owned())
|
|
73
|
+
.filter(|name| !name.is_empty())
|
|
74
|
+
.unwrap_or_else(|| path.to_string())
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub fn read_clipboard_text() -> String {
|
|
78
|
+
let Ok(mut clipboard) = arboard::Clipboard::new() else {
|
|
79
|
+
return String::new();
|
|
80
|
+
};
|
|
81
|
+
clipboard.get_text().unwrap_or_default()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn clipboard_image() -> Option<ClipboardFile> {
|
|
85
|
+
let mut clipboard = arboard::Clipboard::new().ok()?;
|
|
86
|
+
let image = clipboard.get_image().ok()?;
|
|
87
|
+
let png = png_from_rgba(image.width, image.height, image.bytes.as_ref())?;
|
|
88
|
+
Some(ClipboardFile {
|
|
89
|
+
name: "image.png".to_string(),
|
|
90
|
+
path: None,
|
|
91
|
+
data_base64: Some(STANDARD.encode(png)),
|
|
92
|
+
mime: Some("image/png".to_string()),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
pub fn png_from_rgba(width: usize, height: usize, bytes: &[u8]) -> Option<Vec<u8>> {
|
|
97
|
+
let width = u32::try_from(width).ok()?;
|
|
98
|
+
let height = u32::try_from(height).ok()?;
|
|
99
|
+
let expected = (width as usize)
|
|
100
|
+
.checked_mul(height as usize)?
|
|
101
|
+
.checked_mul(4)?;
|
|
102
|
+
if bytes.len() != expected || expected > MAX_CLIPBOARD_IMAGE_BYTES {
|
|
103
|
+
return None;
|
|
104
|
+
}
|
|
105
|
+
let mut png = std::io::Cursor::new(Vec::new());
|
|
106
|
+
PngEncoder::new(&mut png)
|
|
107
|
+
.write_image(bytes, width, height, ExtendedColorType::Rgba8)
|
|
108
|
+
.ok()?;
|
|
109
|
+
let encoded = png.into_inner();
|
|
110
|
+
if encoded.len() > MAX_CLIPBOARD_IMAGE_BYTES {
|
|
111
|
+
return None;
|
|
112
|
+
}
|
|
113
|
+
Some(encoded)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn clipboard_file_paths() -> Vec<String> {
|
|
117
|
+
#[cfg(target_os = "macos")]
|
|
118
|
+
{
|
|
119
|
+
macos_file_paths()
|
|
120
|
+
}
|
|
121
|
+
#[cfg(target_os = "windows")]
|
|
122
|
+
{
|
|
123
|
+
windows_file_paths()
|
|
124
|
+
}
|
|
125
|
+
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
126
|
+
{
|
|
127
|
+
Vec::new()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Explorer stores a copied selection as `CF_HDROP`.
|
|
132
|
+
#[cfg(target_os = "windows")]
|
|
133
|
+
fn windows_file_paths() -> Vec<String> {
|
|
134
|
+
let files: Vec<String> =
|
|
135
|
+
clipboard_win::get_clipboard(clipboard_win::formats::FileList).unwrap_or_default();
|
|
136
|
+
files.into_iter().filter(|path| !path.is_empty()).collect()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[cfg(target_os = "macos")]
|
|
140
|
+
fn macos_file_paths() -> Vec<String> {
|
|
141
|
+
use objc2::ClassType;
|
|
142
|
+
use objc2_app_kit::NSPasteboard;
|
|
143
|
+
use objc2_foundation::{NSArray, NSURL};
|
|
144
|
+
|
|
145
|
+
let pasteboard = NSPasteboard::generalPasteboard();
|
|
146
|
+
let classes = NSArray::from_slice(&[NSURL::class()]);
|
|
147
|
+
// `class_array` is `NSURL`. `options` is nil, then non-file URLs are dropped.
|
|
148
|
+
let from_urls = unsafe { pasteboard.readObjectsForClasses_options(&classes, None) }
|
|
149
|
+
.map(|items| {
|
|
150
|
+
items
|
|
151
|
+
.to_vec()
|
|
152
|
+
.into_iter()
|
|
153
|
+
.filter_map(|item| item.downcast::<NSURL>().ok())
|
|
154
|
+
.filter(|url| url.isFileURL())
|
|
155
|
+
.filter_map(|url| url.path())
|
|
156
|
+
.map(|path| path.to_string())
|
|
157
|
+
.filter(|path| !path.is_empty())
|
|
158
|
+
.collect::<Vec<_>>()
|
|
159
|
+
})
|
|
160
|
+
.unwrap_or_default();
|
|
161
|
+
if !from_urls.is_empty() {
|
|
162
|
+
return from_urls;
|
|
163
|
+
}
|
|
164
|
+
legacy_filename_paths(&pasteboard)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Finder still writes this deprecated list on some copies.
|
|
168
|
+
#[cfg(target_os = "macos")]
|
|
169
|
+
#[allow(deprecated)]
|
|
170
|
+
fn legacy_filename_paths(pasteboard: &objc2_app_kit::NSPasteboard) -> Vec<String> {
|
|
171
|
+
use objc2_app_kit::NSFilenamesPboardType;
|
|
172
|
+
use objc2_foundation::{NSArray, NSString};
|
|
173
|
+
|
|
174
|
+
let Some(list) = pasteboard.propertyListForType(unsafe { NSFilenamesPboardType }) else {
|
|
175
|
+
return Vec::new();
|
|
176
|
+
};
|
|
177
|
+
let Ok(array) = list.downcast::<NSArray>() else {
|
|
178
|
+
return Vec::new();
|
|
179
|
+
};
|
|
180
|
+
array
|
|
181
|
+
.to_vec()
|
|
182
|
+
.into_iter()
|
|
183
|
+
.filter_map(|item| item.downcast::<NSString>().ok())
|
|
184
|
+
.map(|path| path.to_string())
|
|
185
|
+
.filter(|path| !path.is_empty())
|
|
186
|
+
.collect()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#[cfg(test)]
|
|
190
|
+
mod tests {
|
|
191
|
+
use super::*;
|
|
192
|
+
|
|
193
|
+
#[test]
|
|
194
|
+
fn file_name_uses_the_last_path_segment() {
|
|
195
|
+
assert_eq!(file_name("/tmp/纪要.docx"), "纪要.docx");
|
|
196
|
+
assert_eq!(file_name("notes.txt"), "notes.txt");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#[test]
|
|
200
|
+
fn png_from_rgba_encodes_one_pixel_and_rejects_a_bad_buffer() {
|
|
201
|
+
let png = png_from_rgba(1, 1, &[255, 0, 0, 255]).expect("1x1 rgba encodes");
|
|
202
|
+
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
|
|
203
|
+
assert!(png_from_rgba(2, 2, &[0, 0, 0, 0]).is_none());
|
|
204
|
+
assert!(png_from_rgba(usize::MAX, usize::MAX, &[]).is_none());
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/commands.rs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
use crate::clipboard::{self, ClipboardContents};
|
|
1
2
|
use arboard::{Clipboard, ImageData};
|
|
2
3
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
3
4
|
use serde::{Deserialize, Serialize};
|
|
@@ -108,12 +109,14 @@ pub async fn host_save_text_file(
|
|
|
108
109
|
})
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
#[tauri::command]
|
|
113
|
+
pub fn host_read_clipboard() -> ClipboardContents {
|
|
114
|
+
clipboard::read_clipboard()
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
#[tauri::command]
|
|
112
118
|
pub fn host_read_clipboard_text() -> String {
|
|
113
|
-
|
|
114
|
-
return String::new();
|
|
115
|
-
};
|
|
116
|
-
clipboard.get_text().unwrap_or_default()
|
|
119
|
+
clipboard::read_clipboard_text()
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
#[tauri::command]
|
package/src/host.rs
CHANGED
|
@@ -9,7 +9,7 @@ use std::io::{BufRead, BufReader, Write};
|
|
|
9
9
|
use std::path::{Path, PathBuf};
|
|
10
10
|
use std::process::{Command, Stdio};
|
|
11
11
|
use std::sync::mpsc;
|
|
12
|
-
use std::sync::Mutex;
|
|
12
|
+
use std::sync::{Arc, Mutex};
|
|
13
13
|
use std::thread;
|
|
14
14
|
use std::time::{Duration, Instant};
|
|
15
15
|
use tauri::{AppHandle, Manager};
|
|
@@ -35,6 +35,34 @@ fn write_diag(dir: &Path, name: &str, body: &str) {
|
|
|
35
35
|
let _ = std::fs::write(dir.join(name), body);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
fn remember_line(recent: &Mutex<Vec<String>>, line: &str) {
|
|
39
|
+
let Ok(mut lines) = recent.lock() else {
|
|
40
|
+
return;
|
|
41
|
+
};
|
|
42
|
+
if lines.len() == 40 {
|
|
43
|
+
lines.remove(0);
|
|
44
|
+
}
|
|
45
|
+
lines.push(line.to_string());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fn format_host_exit(reason: &str, recent: &Mutex<Vec<String>>) -> String {
|
|
49
|
+
let detail = recent.lock().ok().and_then(|lines| {
|
|
50
|
+
lines
|
|
51
|
+
.iter()
|
|
52
|
+
.rev()
|
|
53
|
+
.find(|line| {
|
|
54
|
+
line.contains("failed to start")
|
|
55
|
+
|| line.contains("StoreAlreadyOwnedError")
|
|
56
|
+
|| line.contains("web build not found")
|
|
57
|
+
})
|
|
58
|
+
.cloned()
|
|
59
|
+
});
|
|
60
|
+
match detail {
|
|
61
|
+
Some(line) => format!("{reason}: {line}"),
|
|
62
|
+
None => reason.to_string(),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
38
66
|
fn append_diag(dir: &Path, name: &str, line: &str) {
|
|
39
67
|
if let Ok(mut file) = std::fs::OpenOptions::new()
|
|
40
68
|
.create(true)
|
|
@@ -137,12 +165,16 @@ impl HostProcess {
|
|
|
137
165
|
.ok_or_else(|| "Node host stderr was not piped".to_string())?;
|
|
138
166
|
|
|
139
167
|
let (ready_tx, ready_rx) = mpsc::channel();
|
|
168
|
+
let (stderr_done_tx, stderr_done_rx) = mpsc::channel();
|
|
169
|
+
let recent = Arc::new(Mutex::new(Vec::<String>::new()));
|
|
140
170
|
let stdout_dir = user_data.clone();
|
|
171
|
+
let stdout_recent = Arc::clone(&recent);
|
|
141
172
|
thread::spawn(move || {
|
|
142
173
|
for line in BufReader::new(stdout).lines() {
|
|
143
174
|
match line {
|
|
144
175
|
Ok(line) => {
|
|
145
176
|
println!("[node-host] {line}");
|
|
177
|
+
remember_line(&stdout_recent, &line);
|
|
146
178
|
append_diag(&stdout_dir, "host-node.log", &line);
|
|
147
179
|
if let Some(record) = line.strip_prefix(READY_PREFIX) {
|
|
148
180
|
let parsed = serde_json::from_str::<ReadyRecord>(record)
|
|
@@ -163,11 +195,14 @@ impl HostProcess {
|
|
|
163
195
|
}
|
|
164
196
|
});
|
|
165
197
|
let stderr_dir = user_data.clone();
|
|
198
|
+
let stderr_recent = Arc::clone(&recent);
|
|
166
199
|
thread::spawn(move || {
|
|
167
200
|
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
|
168
201
|
eprintln!("[node-host] {line}");
|
|
202
|
+
remember_line(&stderr_recent, &line);
|
|
169
203
|
append_diag(&stderr_dir, "host-node.log", &line);
|
|
170
204
|
}
|
|
205
|
+
let _ = stderr_done_tx.send(());
|
|
171
206
|
});
|
|
172
207
|
|
|
173
208
|
let started = Instant::now();
|
|
@@ -175,19 +210,28 @@ impl HostProcess {
|
|
|
175
210
|
match ready_rx.recv_timeout(Duration::from_millis(100)) {
|
|
176
211
|
Ok(result) => break result?,
|
|
177
212
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
|
213
|
+
let _ = stderr_done_rx.recv_timeout(Duration::from_millis(500));
|
|
178
214
|
let _ = child.kill();
|
|
179
|
-
return Err(
|
|
215
|
+
return Err(format_host_exit(
|
|
216
|
+
"Node host exited before reporting readiness",
|
|
217
|
+
&recent,
|
|
218
|
+
));
|
|
180
219
|
}
|
|
181
220
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
|
182
221
|
}
|
|
183
222
|
if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
|
|
184
|
-
|
|
185
|
-
|
|
223
|
+
let _ = stderr_done_rx.recv_timeout(Duration::from_millis(500));
|
|
224
|
+
return Err(format_host_exit(
|
|
225
|
+
&format!("Node host exited before reporting readiness: {status}"),
|
|
226
|
+
&recent,
|
|
186
227
|
));
|
|
187
228
|
}
|
|
188
229
|
if started.elapsed() >= START_TIMEOUT {
|
|
189
230
|
let _ = child.kill();
|
|
190
|
-
return Err(
|
|
231
|
+
return Err(format_host_exit(
|
|
232
|
+
"Node host did not become ready within 30 seconds",
|
|
233
|
+
&recent,
|
|
234
|
+
));
|
|
191
235
|
}
|
|
192
236
|
};
|
|
193
237
|
write_diag(
|
package/src/lib.rs
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
|
+
mod clipboard;
|
|
1
2
|
mod commands;
|
|
2
3
|
mod host;
|
|
3
4
|
mod python_runner;
|
|
4
5
|
mod update;
|
|
5
6
|
|
|
6
7
|
use commands::{
|
|
7
|
-
host_capture_screenshot, host_read_clipboard_text, host_save_text_file,
|
|
8
|
+
host_capture_screenshot, host_read_clipboard, host_read_clipboard_text, host_save_text_file,
|
|
9
|
+
host_select_directory,
|
|
8
10
|
};
|
|
9
11
|
use host::HostProcess;
|
|
10
12
|
use python_runner::{
|
|
11
13
|
python_runner_cancel, python_runner_download, python_runner_pick_local, python_runner_restart,
|
|
12
14
|
python_runner_snapshot, python_runner_use_default, python_runner_use_local,
|
|
13
15
|
};
|
|
16
|
+
use std::io::Write;
|
|
14
17
|
use std::path::PathBuf;
|
|
18
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
15
19
|
use std::thread;
|
|
20
|
+
use std::time::Duration;
|
|
16
21
|
use tauri::{
|
|
17
22
|
menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder},
|
|
18
23
|
Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
|
|
@@ -100,6 +105,89 @@ fn install_menu(app: &tauri::App) -> tauri::Result<()> {
|
|
|
100
105
|
Ok(())
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
static REVEAL_WHEN_READY: AtomicBool = AtomicBool::new(false);
|
|
109
|
+
static INSTANCE_PING: AtomicBool = AtomicBool::new(false);
|
|
110
|
+
|
|
111
|
+
/// Shows the running desktop window.
|
|
112
|
+
///
|
|
113
|
+
/// Closing the macOS window hides it. `set_focus` then does nothing until
|
|
114
|
+
/// `show` has run on the main thread, so the activation is posted again
|
|
115
|
+
/// after that show.
|
|
116
|
+
fn reveal_main_window(app: &tauri::AppHandle) {
|
|
117
|
+
if app.get_webview_window("main").is_none() {
|
|
118
|
+
REVEAL_WHEN_READY.store(true, Ordering::SeqCst);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
focus_main_window(app);
|
|
122
|
+
let later = app.clone();
|
|
123
|
+
let _ = app.run_on_main_thread(move || focus_main_window(&later));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
fn focus_main_window(app: &tauri::AppHandle) {
|
|
127
|
+
let Some(window) = app.get_webview_window("main") else {
|
|
128
|
+
return;
|
|
129
|
+
};
|
|
130
|
+
let _ = window.unminimize();
|
|
131
|
+
let _ = window.show();
|
|
132
|
+
let _ = window.set_focus();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fn is_database_in_use(error: &str) -> bool {
|
|
136
|
+
error.contains("StoreAlreadyOwnedError") || error.contains("store already owned")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn database_in_use_message(product_name: &str) -> String {
|
|
140
|
+
format!(
|
|
141
|
+
"再打开一次{product_name}时,会回到已经打开的窗口。\n\n\
|
|
142
|
+
这次没有回到那个窗口,是因为本地数据库正被另一个进程占用。\
|
|
143
|
+
如果终端里还在跑 pnpm dev:bs,先在那个终端按 Ctrl+C 停掉,再重新打开{product_name}。\n\n\
|
|
144
|
+
重新启动后的开发服务会使用单独的数据目录,之后可以和桌面版同时开。"
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Asks an already running desktop app to reveal its window.
|
|
149
|
+
///
|
|
150
|
+
/// A ping that this process receives itself means the database is held by
|
|
151
|
+
/// something other than a second copy of the app.
|
|
152
|
+
fn wake_other_desktop_app(app: &tauri::AppHandle) -> bool {
|
|
153
|
+
#[cfg(unix)]
|
|
154
|
+
{
|
|
155
|
+
INSTANCE_PING.store(false, Ordering::SeqCst);
|
|
156
|
+
if ping_single_instance(&app.config().identifier).is_err() {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
for _ in 0..10 {
|
|
160
|
+
if INSTANCE_PING.load(Ordering::SeqCst) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
thread::sleep(Duration::from_millis(20));
|
|
164
|
+
}
|
|
165
|
+
true
|
|
166
|
+
}
|
|
167
|
+
#[cfg(not(unix))]
|
|
168
|
+
{
|
|
169
|
+
let _ = app;
|
|
170
|
+
false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[cfg(unix)]
|
|
175
|
+
fn ping_single_instance(identifier: &str) -> std::io::Result<()> {
|
|
176
|
+
let socket = PathBuf::from(format!(
|
|
177
|
+
"/tmp/{}_si.sock",
|
|
178
|
+
identifier.replace(['.', '-'], "_")
|
|
179
|
+
));
|
|
180
|
+
let mut stream = std::os::unix::net::UnixStream::connect(socket)?;
|
|
181
|
+
stream.set_write_timeout(Some(Duration::from_millis(200)))?;
|
|
182
|
+
let cwd = std::env::current_dir().unwrap_or_default();
|
|
183
|
+
let args = std::env::args().collect::<Vec<_>>().join("\0");
|
|
184
|
+
stream.write_all(cwd.to_string_lossy().as_bytes())?;
|
|
185
|
+
stream.write_all(b"\0\0")?;
|
|
186
|
+
stream.write_all(args.as_bytes())?;
|
|
187
|
+
stream.flush()?;
|
|
188
|
+
Ok(())
|
|
189
|
+
}
|
|
190
|
+
|
|
103
191
|
/// Runs a Tauri desktop shell around the shared Node HostRuntime.
|
|
104
192
|
pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
105
193
|
let product_name = config.product_name.clone();
|
|
@@ -107,11 +195,8 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
107
195
|
.manage(update::UpdateState::default())
|
|
108
196
|
.manage(python_runner::PythonRunnerState::default())
|
|
109
197
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
let _ = window.unminimize();
|
|
113
|
-
let _ = window.set_focus();
|
|
114
|
-
}
|
|
198
|
+
INSTANCE_PING.store(true, Ordering::SeqCst);
|
|
199
|
+
reveal_main_window(app);
|
|
115
200
|
}))
|
|
116
201
|
.plugin(tauri_plugin_dialog::init())
|
|
117
202
|
.plugin(tauri_plugin_process::init())
|
|
@@ -120,6 +205,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
120
205
|
host_select_directory,
|
|
121
206
|
host_save_text_file,
|
|
122
207
|
host_capture_screenshot,
|
|
208
|
+
host_read_clipboard,
|
|
123
209
|
host_read_clipboard_text,
|
|
124
210
|
app_release_snapshot,
|
|
125
211
|
app_release_check,
|
|
@@ -139,12 +225,30 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
139
225
|
let (host, url) = match HostProcess::spawn(&handle, &config) {
|
|
140
226
|
Ok(started) => started,
|
|
141
227
|
Err(error) => {
|
|
228
|
+
if is_database_in_use(&error) && wake_other_desktop_app(&handle) {
|
|
229
|
+
handle.exit(0);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let database_in_use = is_database_in_use(&error);
|
|
233
|
+
let message = if database_in_use {
|
|
234
|
+
database_in_use_message(&config.product_name)
|
|
235
|
+
} else {
|
|
236
|
+
error
|
|
237
|
+
};
|
|
142
238
|
let exit_handle = handle.clone();
|
|
143
239
|
handle
|
|
144
240
|
.dialog()
|
|
145
|
-
.message(
|
|
146
|
-
.title(
|
|
147
|
-
|
|
241
|
+
.message(message)
|
|
242
|
+
.title(if database_in_use {
|
|
243
|
+
format!("{} 无法再开一个", config.product_name)
|
|
244
|
+
} else {
|
|
245
|
+
format!("{} 无法启动", config.product_name)
|
|
246
|
+
})
|
|
247
|
+
.kind(if database_in_use {
|
|
248
|
+
MessageDialogKind::Warning
|
|
249
|
+
} else {
|
|
250
|
+
MessageDialogKind::Error
|
|
251
|
+
})
|
|
148
252
|
.show(move |_| exit_handle.exit(1));
|
|
149
253
|
return;
|
|
150
254
|
}
|
|
@@ -170,6 +274,9 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
170
274
|
.show(move |_| exit_handle.exit(1));
|
|
171
275
|
return;
|
|
172
276
|
}
|
|
277
|
+
if REVEAL_WHEN_READY.swap(false, Ordering::SeqCst) {
|
|
278
|
+
focus_main_window(&handle);
|
|
279
|
+
}
|
|
173
280
|
python_runner::maybe_prompt(&handle);
|
|
174
281
|
update::start(&handle);
|
|
175
282
|
});
|
|
@@ -204,12 +311,7 @@ pub fn run(context: tauri::Context<tauri::Wry>, config: DesktopConfig) {
|
|
|
204
311
|
app.exit(0);
|
|
205
312
|
}
|
|
206
313
|
#[cfg(target_os = "macos")]
|
|
207
|
-
RunEvent::Reopen { .. } =>
|
|
208
|
-
if let Some(window) = app.get_webview_window("main") {
|
|
209
|
-
let _ = window.show();
|
|
210
|
-
let _ = window.set_focus();
|
|
211
|
-
}
|
|
212
|
-
}
|
|
314
|
+
RunEvent::Reopen { .. } => reveal_main_window(app),
|
|
213
315
|
_ => {}
|
|
214
316
|
});
|
|
215
317
|
}
|