juc500 정상동작
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "juc500-app"
|
||||
version = "0.1.0"
|
||||
description = "JUC500/JUC700 Tauri shell — cable plug auto-launch"
|
||||
authors = ["juc500"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "juc500_app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusb = "0.9"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "JUC500 main capability",
|
||||
"windows": ["main", "splash"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-focus",
|
||||
"core:tray:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 298 B |
|
After Width: | Height: | Size: 663 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 345 B |
|
After Width: | Height: | Size: 760 B |
|
After Width: | Height: | Size: 102 B |
|
After Width: | Height: | Size: 851 B |
|
After Width: | Height: | Size: 119 B |
|
After Width: | Height: | Size: 170 B |
|
After Width: | Height: | Size: 198 B |
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 277 B |
|
After Width: | Height: | Size: 171 B |
|
After Width: | Height: | Size: 238 B |
|
After Width: | Height: | Size: 273 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 484 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 901 B |
|
After Width: | Height: | Size: 714 B |
|
After Width: | Height: | Size: 562 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 901 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 91 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 144 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 193 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 183 B |
|
After Width: | Height: | Size: 183 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 417 B |
|
After Width: | Height: | Size: 178 B |
|
After Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 386 B |
@@ -0,0 +1,117 @@
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const GUI_PORT: u16 = 8765;
|
||||
|
||||
/// Project root containing `juc500_app.py` (parent of `juc500-app/` in dev).
|
||||
pub fn project_root() -> PathBuf {
|
||||
if let Ok(root) = std::env::var("JUC500_ROOT") {
|
||||
let p = PathBuf::from(root);
|
||||
if p.join("juc500_app.py").is_file() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
if cwd.join("juc500_app.py").is_file() {
|
||||
return cwd;
|
||||
}
|
||||
let parent = cwd.parent().map(Path::to_path_buf);
|
||||
if let Some(ref p) = parent {
|
||||
if p.join("juc500_app.py").is_file() {
|
||||
return p.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let mut dir = exe.parent().map(Path::to_path_buf);
|
||||
for _ in 0..10 {
|
||||
let Some(ref d) = dir else { break };
|
||||
if d.join("juc500_app.py").is_file() {
|
||||
return d.clone();
|
||||
}
|
||||
if d.join("..").join("juc500_app.py").exists() {
|
||||
if let Ok(canon) = d.join("..").canonicalize() {
|
||||
if canon.join("juc500_app.py").is_file() {
|
||||
return canon;
|
||||
}
|
||||
}
|
||||
}
|
||||
dir = d.parent().map(Path::to_path_buf);
|
||||
}
|
||||
}
|
||||
PathBuf::from(".")
|
||||
}
|
||||
|
||||
pub fn resolve_python(root: &Path) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
let venv = root.join(".venv").join("Scripts").join("python.exe");
|
||||
if venv.is_file() {
|
||||
return venv;
|
||||
}
|
||||
} else {
|
||||
let venv = root.join(".venv").join("bin").join("python");
|
||||
if venv.is_file() {
|
||||
return venv;
|
||||
}
|
||||
}
|
||||
if cfg!(windows) {
|
||||
PathBuf::from("python")
|
||||
} else {
|
||||
PathBuf::from("python3")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill_wormhole(root: &Path) {
|
||||
let py = resolve_python(root);
|
||||
let _ = Command::new(&py)
|
||||
.current_dir(root)
|
||||
.env("PYTHONPATH", root)
|
||||
.args([
|
||||
"-c",
|
||||
"from juc500_xfer.wormhole_kill import kill_wormhole_processes; \
|
||||
kill_wormhole_processes(force=True)",
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
pub fn backend_running(port: u16) -> bool {
|
||||
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
|
||||
}
|
||||
|
||||
pub fn wait_backend(port: u16, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if backend_running(port) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn start_backend(root: &Path) -> std::io::Result<Child> {
|
||||
kill_wormhole(root);
|
||||
let py = resolve_python(root);
|
||||
let app = root.join("juc500_app.py");
|
||||
Command::new(&py)
|
||||
.current_dir(root)
|
||||
.env("PYTHONPATH", root)
|
||||
.env("JUC500_TAURI", "1")
|
||||
.arg(&app)
|
||||
.arg("--backend")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
}
|
||||
|
||||
pub fn stop_backend(child: &mut Option<Child>) {
|
||||
if let Some(mut c) = child.take() {
|
||||
let _ = c.kill();
|
||||
let _ = c.wait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
mod backend;
|
||||
mod usb;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
ActivationPolicy, AppHandle, Manager, RunEvent, State, WebviewUrl, WebviewWindowBuilder,
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const MAIN_LABEL: &str = "main";
|
||||
const SPLASH_LABEL: &str = "splash";
|
||||
|
||||
fn cable_autolaunch_enabled() -> bool {
|
||||
std::env::var("JUC500_CABLE_AUTOLAUNCH")
|
||||
.map(|v| {
|
||||
matches!(
|
||||
v.trim().to_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
backend: Option<std::process::Child>,
|
||||
cable_was_present: bool,
|
||||
window_open: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
backend: None,
|
||||
cable_was_present: usb::cable_present(),
|
||||
window_open: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_dock_visible(app: &AppHandle, visible: bool) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let policy = if visible {
|
||||
ActivationPolicy::Regular
|
||||
} else {
|
||||
ActivationPolicy::Accessory
|
||||
};
|
||||
let _ = app.set_activation_policy(policy);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_splash(app: &AppHandle) {
|
||||
if app.get_webview_window(SPLASH_LABEL).is_some() {
|
||||
return;
|
||||
}
|
||||
let _ = WebviewWindowBuilder::new(app, SPLASH_LABEL, WebviewUrl::App("index.html".into()))
|
||||
.title("JUC500")
|
||||
.inner_size(420.0, 220.0)
|
||||
.resizable(false)
|
||||
.center()
|
||||
.build();
|
||||
}
|
||||
|
||||
fn close_splash(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window(SPLASH_LABEL) {
|
||||
let _ = w.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &AppHandle) -> Result<(), String> {
|
||||
let url = format!("http://127.0.0.1:{}/", backend::GUI_PORT);
|
||||
if let Some(w) = app.get_webview_window(MAIN_LABEL) {
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
close_splash(app);
|
||||
set_dock_visible(app, true);
|
||||
return Ok(());
|
||||
}
|
||||
WebviewWindowBuilder::new(
|
||||
app,
|
||||
MAIN_LABEL,
|
||||
WebviewUrl::External(
|
||||
url.parse()
|
||||
.map_err(|e| format!("invalid GUI url: {e}"))?,
|
||||
),
|
||||
)
|
||||
.title("JUC500")
|
||||
.inner_size(1280.0, 820.0)
|
||||
.min_inner_size(900.0, 600.0)
|
||||
.center()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
close_splash(app);
|
||||
set_dock_visible(app, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hide_main_window(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window(MAIN_LABEL) {
|
||||
let _ = w.hide();
|
||||
}
|
||||
set_dock_visible(app, false);
|
||||
}
|
||||
|
||||
fn ensure_backend(state: &State<'_, Mutex<AppState>>) -> Result<(), String> {
|
||||
let root = backend::project_root();
|
||||
if !root.join("juc500_app.py").is_file() {
|
||||
return Err(format!(
|
||||
"juc500_app.py 없음 (JUC500_ROOT={})",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
let mut guard = state.lock().map_err(|e| e.to_string())?;
|
||||
if backend::backend_running(backend::GUI_PORT) {
|
||||
return Ok(());
|
||||
}
|
||||
backend::stop_backend(&mut guard.backend);
|
||||
let child = backend::start_backend(&root).map_err(|e| e.to_string())?;
|
||||
guard.backend = Some(child);
|
||||
drop(guard);
|
||||
if !backend::wait_backend(backend::GUI_PORT, Duration::from_secs(45)) {
|
||||
return Err("Python 백엔드 기동 시간 초과 (./scripts/setup_macos.sh 확인)".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_all(app: &AppHandle, state: &State<'_, Mutex<AppState>>) {
|
||||
hide_main_window(app);
|
||||
if let Ok(mut guard) = state.lock() {
|
||||
guard.window_open = false;
|
||||
backend::stop_backend(&mut guard.backend);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_cable_present(app: &AppHandle, state: &State<'_, Mutex<AppState>>) {
|
||||
show_splash(app);
|
||||
match ensure_backend(state) {
|
||||
Ok(()) => {
|
||||
if let Err(e) = show_main_window(app) {
|
||||
eprintln!("window error: {e}");
|
||||
close_splash(app);
|
||||
} else if let Ok(mut guard) = state.lock() {
|
||||
guard.window_open = true;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("backend error: {e}");
|
||||
close_splash(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_cable_absent(app: &AppHandle, state: &State<'_, Mutex<AppState>>) {
|
||||
stop_all(app, state);
|
||||
}
|
||||
|
||||
fn cable_watch_loop(app: AppHandle) {
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(1500));
|
||||
let present = usb::cable_present();
|
||||
let state = app.state::<Mutex<AppState>>();
|
||||
let Ok(mut guard) = state.lock() else {
|
||||
continue;
|
||||
};
|
||||
let was = guard.cable_was_present;
|
||||
guard.cable_was_present = present;
|
||||
drop(guard);
|
||||
|
||||
if present && !was {
|
||||
on_cable_present(&app, &state);
|
||||
} else if !present && was {
|
||||
on_cable_absent(&app, &state);
|
||||
} else if present && was {
|
||||
// Keep backend alive; reopen window if user closed it but cable still in
|
||||
if backend::backend_running(backend::GUI_PORT) {
|
||||
if app.get_webview_window(MAIN_LABEL).is_none() {
|
||||
let _ = show_main_window(&app);
|
||||
}
|
||||
} else {
|
||||
on_cable_present(&app, &state);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cable_status() -> bool {
|
||||
usb::cable_present()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_gui(state: State<'_, Mutex<AppState>>, app: AppHandle) -> Result<(), String> {
|
||||
on_cable_present(&app, &state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(Mutex::new(AppState::new()))
|
||||
.setup(|app| {
|
||||
let show_item = MenuItem::with_id(app, "show", "JUC500 열기", true, None::<&str>)?;
|
||||
let open_root_item =
|
||||
MenuItem::with_id(app, "open_root", "프로젝트 폴더", true, None::<&str>)?;
|
||||
let quit_item = MenuItem::with_id(app, "quit", "종료", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_item, &open_root_item, &quit_item])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.menu(&menu)
|
||||
.tooltip("JUC500 — 케이블 연결 시 자동 실행")
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
"show" => {
|
||||
let state = app.state::<Mutex<AppState>>();
|
||||
on_cable_present(app, &state);
|
||||
}
|
||||
"open_root" => {
|
||||
let root = backend::project_root();
|
||||
let _ = app.opener().open_path(root.display().to_string(), None::<&str>);
|
||||
}
|
||||
"quit" => {
|
||||
let state = app.state::<Mutex<AppState>>();
|
||||
stop_all(app, &state);
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let app = tray.app_handle();
|
||||
let state = app.state::<Mutex<AppState>>();
|
||||
on_cable_present(&app, &state);
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
set_dock_visible(app.handle(), false);
|
||||
|
||||
// 개발 중: 케이블 감지 자동 실행 비활성 (JUC500_CABLE_AUTOLAUNCH=1 일 때만)
|
||||
if cable_autolaunch_enabled() {
|
||||
cable_watch_loop(app.handle().clone());
|
||||
if usb::cable_present() {
|
||||
let state = app.state::<Mutex<AppState>>();
|
||||
on_cable_present(app.handle(), &state);
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"JUC500: 케이블 자동 실행 비활성 (개발 모드). \
|
||||
./start.sh 로 수동 실행하거나 트레이 메뉴 'JUC500 열기' 사용."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![cable_status, open_gui])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|app, event| {
|
||||
if let RunEvent::ExitRequested { api, .. } = event {
|
||||
// Keep tray watcher alive when the user closes the window
|
||||
api.prevent_exit();
|
||||
hide_main_window(app);
|
||||
if let Ok(mut guard) = app.state::<Mutex<AppState>>().lock() {
|
||||
guard.window_open = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
juc500_app_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! JUC500/JUC700 cable presence (VID 0711, PID 7500 or 7000–700F).
|
||||
|
||||
const VID: u16 = 0x0711;
|
||||
const PID_JUC500: u16 = 0x7500;
|
||||
const PID_JUC700_MIN: u16 = 0x7000;
|
||||
const PID_JUC700_MAX: u16 = 0x700f;
|
||||
|
||||
fn is_juc_cable(vid: u16, pid: u16) -> bool {
|
||||
vid == VID
|
||||
&& (pid == PID_JUC500 || (pid >= PID_JUC700_MIN && pid <= PID_JUC700_MAX))
|
||||
}
|
||||
|
||||
/// True when a Smart Data Link / JUC700 DSS composite device is plugged in.
|
||||
pub fn cable_present() -> bool {
|
||||
let Ok(devices) = rusb::devices() else {
|
||||
return false;
|
||||
};
|
||||
for device in devices.iter() {
|
||||
let Ok(desc) = device.device_descriptor() else {
|
||||
continue;
|
||||
};
|
||||
if is_juc_cable(desc.vendor_id(), desc.product_id()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JUC500",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.juc500.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||