juc500 정상동작

This commit is contained in:
Macbook
2026-07-21 21:04:25 +09:00
parent a423db0556
commit bb9874b931
2917 changed files with 708025 additions and 668 deletions
+7
View File
@@ -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
+4918
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
+3
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 B

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

+117
View File
@@ -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();
}
}
+279
View File
@@ -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;
}
}
});
}
+6
View File
@@ -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()
}
+27
View File
@@ -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
}
+27
View File
@@ -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"
]
}
}