diff --git a/.cursorrules b/.cursorrules index 0c34212..0a434bc 100644 --- a/.cursorrules +++ b/.cursorrules @@ -17,7 +17,7 @@ - **상태 관리**: Legend State - **코드 에디터**: Monaco Editor - **Backend**: Rust, Tauri 2.0 -- **JavaScript Engine**: Deno Core 0.323 (V8 기반) +- **JavaScript Engine**: Node.js v24.12.0 (V8 기반) - **Package Manager**: pnpm - **Linting**: oxlint (JavaScript) - **Formatting**: Prettier (JavaScript), rustfmt (Rust) @@ -25,32 +25,32 @@ ### JavaScript 런타임 아키텍처 -#### Deno Core 통합 -- **엔진**: `deno_core` 0.323 (V8 JavaScript 엔진) -- **실행 방식**: `tokio::task::spawn_blocking`으로 별도 스레드에서 실행 -- **호환성**: Tauri 2.0과 완전 호환 (Send 트레이트 문제 해결) +#### Node.js 통합 +- **엔진**: Node.js v24.12.0 (V8 JavaScript 엔진) +- **실행 방식**: `tokio::process::Command`로 서브프로세스 실행 +- **호환성**: Tauri 2.0과 완전 호환 #### 핵심 컴포넌트 ``` apps/executeJS/src-tauri/src/ -├── deno_runtime.rs # Deno Core 런타임 구현 -├── bootstrap.js # JavaScript API 정의 ├── js_executor.rs # 실행 결과 관리 └── commands.rs # Tauri 명령어 + +crates/node-runtime/ +├── src/lib.rs # Node.js 런타임 구현 +└── Cargo.toml ``` #### 실행 흐름 -1. **초기화**: `DenoExecutor::new()` - 출력 버퍼 설정 -2. **실행**: `execute_script()` - 별도 스레드에서 Deno Core 실행 -3. **API 연결**: `bootstrap.js` - console.log, alert 등 커스텀 API -4. **결과 처리**: 출력 버퍼에서 결과 수집 및 반환 +1. **초기화**: `NodeExecutor::new()` - Node.js 바이너리 경로 찾기 +2. **실행**: `execute_script()` - 서브프로세스로 Node.js 실행 +3. **결과 처리**: stdout/stderr에서 결과 수집 및 반환 #### 지원 기능 -- ✅ `console.log()` - 다중 인자, 객체 직렬화 -- ✅ `alert()` - 사용자 알림 +- ✅ `console.log()` - 표준 출력 - ✅ 변수 할당 및 계산 - ✅ 문법 오류 감지 (실제 JavaScript 엔진 수준) -- ✅ Chrome DevTools 수준의 출력 +- ✅ Node.js 표준 기능 지원 ## FSD 아키텍처 규칙 @@ -196,10 +196,9 @@ chore: 빌드 설정 변경 - 네이티브 로그 확인 ### JavaScript 런타임 -- `cargo test deno_runtime::tests` - 런타임 테스트 -- `bootstrap.js` 수정 시 재컴파일 필요 -- op 함수 추가 시 `extension!` 매크로 업데이트 -- 전역 상태(`OUTPUT_BUFFER`) 주의 +- `cargo test node_runtime::tests` - 런타임 테스트 +- Node.js 바이너리 경로 확인 필요 +- 리소스 번들링 확인 (`tauri.conf.json`) ## 주의사항 @@ -227,9 +226,8 @@ chore: 빌드 설정 변경 2. `cargo fmt` 실행 3. 수동으로 규칙에 맞게 수정 -### Deno Core 관련 문제 -1. **Send 트레이트 오류**: `tokio::task::spawn_blocking` 사용 확인 -2. **op 함수 오류**: `#[op2(fast)]` 속성 및 `extension!` 매크로 확인 -3. **bootstrap.js 오류**: `include_str!` 매크로로 올바르게 포함되었는지 확인 -4. **전역 상태 충돌**: 테스트 간 격리 락 사용 -5. **V8 엔진 오류**: `deno_core` 버전 호환성 확인 +### Node.js 런타임 관련 문제 +1. **바이너리 경로 오류**: 개발/프로덕션 모드 경로 확인 +2. **리소스 번들링 오류**: `tauri.conf.json`의 `resources` 설정 확인 +3. **권한 오류**: Unix 시스템에서 실행 권한 설정 확인 +4. **서브프로세스 오류**: `tokio::process::Command` 실행 확인 diff --git a/.gitignore b/.gitignore index 9e51e73..537bc29 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,10 @@ temp/ # Tauri generated files **/src-tauri/gen + +# Node.js 바이너리 (자동 다운로드됨) +apps/executeJS/src-tauri/resources/node-runtime/**/node +apps/executeJS/src-tauri/resources/node-runtime/**/node.exe + +# 임시 다운로드 디렉토리 +.temp-node-download/ diff --git a/Cargo.lock b/Cargo.lock index 1fc8830..90aa45e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,29 +262,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "107a4e9d9cab9963e04e84bb8dee0e25f2a987f9a8bad5ed054abd439caa8f8c" -dependencies = [ - "bindgen 0.72.1", - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.6.20" @@ -375,7 +352,7 @@ dependencies = [ "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools", "log", "prettyplease", "proc-macro2", @@ -386,26 +363,6 @@ dependencies = [ "syn 2.0.108", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.108", -] - [[package]] name = "bit-set" version = "0.5.3" @@ -622,8 +579,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -709,15 +664,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" -dependencies = [ - "cc", -] - [[package]] name = "cocoa" version = "0.26.1" @@ -1434,17 +1380,8 @@ name = "executeJS" version = "0.1.0" dependencies = [ "anyhow", - "bytes", "chrono", - "deno-runtime", - "flate2", - "futures-util", - "http-body-util", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "native-tls", - "regex", + "node-runtime", "serde", "serde_json", "tauri", @@ -1458,9 +1395,6 @@ dependencies = [ "tempfile", "thiserror 2.0.17", "tokio", - "tokio-native-tls", - "tokio-rustls", - "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -1602,12 +1536,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "fslock" version = "0.2.1" @@ -2298,9 +2226,7 @@ dependencies = [ "http 1.3.1", "hyper 1.7.0", "hyper-util", - "log", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -2605,15 +2531,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.15" @@ -2665,16 +2582,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.81" @@ -3015,7 +2922,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -3094,6 +3001,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "node-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "tokio", + "tracing", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3469,9 +3385,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -3501,9 +3417,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -4024,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools", "proc-macro2", "quote", "syn 2.0.108", @@ -4496,8 +4412,6 @@ version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -4506,18 +4420,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework 3.5.1", -] - [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4534,7 +4436,6 @@ version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4640,19 +4541,6 @@ dependencies = [ "security-framework-sys", ] -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework-sys" version = "2.15.0" @@ -6374,7 +6262,7 @@ version = "130.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a511192602f7b435b0a241c1947aa743eb7717f20a9195f4b5e8ed1952e01db1" dependencies = [ - "bindgen 0.70.1", + "bindgen", "bitflags 2.10.0", "fslock", "gzip-header", diff --git a/apps/executeJS/src-tauri/Cargo.toml b/apps/executeJS/src-tauri/Cargo.toml index 7b310da..8474957 100644 --- a/apps/executeJS/src-tauri/Cargo.toml +++ b/apps/executeJS/src-tauri/Cargo.toml @@ -31,22 +31,10 @@ chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "registry", "env-filter"] } tracing-appender = "0.2" -regex = "1.0" -bytes = "1.0" -futures-util = "0.3" -http-body-util = "0.1" -hyper-util = "0.1" -hyper-tls = "0.6" -hyper-rustls = "0.27" -tokio-native-tls = "0.3" -native-tls = "0.2" -tokio-rustls = "0.26" -tokio-stream = "0.1" -flate2 = "1.1" tempfile = "3.23.0" -# JavaScript 런타임 의존성 (Deno Core) -deno-runtime = { path = "../../../crates/deno-runtime" } +# JavaScript 런타임 의존성 (Node.js) +node-runtime = { path = "../../../crates/node-runtime" } [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/apps/executeJS/src-tauri/capabilities/main.json b/apps/executeJS/src-tauri/capabilities/main.json index 294da43..a600c92 100644 --- a/apps/executeJS/src-tauri/capabilities/main.json +++ b/apps/executeJS/src-tauri/capabilities/main.json @@ -1,9 +1,16 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main", - "windows": ["main", "settings"], + "windows": [ + "main", + "settings" + ], "permissions": [ - "core:window:allow-set-title" + "core:window:allow-set-title", + "http:default", + "opener:default", + "fs:default", + "clipboard-manager:default", + "store:default" ] } - diff --git a/apps/executeJS/src-tauri/src/commands.rs b/apps/executeJS/src-tauri/src/commands.rs index e632eea..e3581ae 100644 --- a/apps/executeJS/src-tauri/src/commands.rs +++ b/apps/executeJS/src-tauri/src/commands.rs @@ -1,6 +1,7 @@ use crate::js_executor::{execute_javascript_code, JsExecutionResult}; use serde::{Deserialize, Serialize}; -use std::process::Command; +// 비활성화: Command import (lint_code가 주석 처리되어 사용 안 함) +// use std::process::Command; #[derive(Debug, Serialize, Deserialize)] pub struct AppInfo { @@ -10,6 +11,8 @@ pub struct AppInfo { pub author: String, } +// 비활성화: 린트 관련 구조체 +/* // 린트 결과를 나타내는 구조체 #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -60,6 +63,7 @@ struct OxlintSpan { #[serde(default)] length: usize, } +*/ #[tauri::command] pub async fn execute_js(code: &str) -> Result { @@ -92,7 +96,8 @@ pub fn get_app_info() -> AppInfo { } } -// JavaScript 코드를 oxlint로 린트하고 결과를 반환 +// 비활성화: JavaScript 코드를 oxlint로 린트하고 결과를 반환 +/* #[tauri::command] pub async fn lint_code(code: String) -> Result, String> { use std::io::Write; @@ -239,3 +244,4 @@ fn parse_oxlint_output(stdout: &str, stderr: &str) -> Vec { } } } +*/ diff --git a/apps/executeJS/src-tauri/src/js_executor.rs b/apps/executeJS/src-tauri/src/js_executor.rs index a5997be..4ec10c1 100644 --- a/apps/executeJS/src-tauri/src/js_executor.rs +++ b/apps/executeJS/src-tauri/src/js_executor.rs @@ -1,4 +1,4 @@ -use deno_runtime::DenoExecutor; +use node_runtime::NodeExecutor; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -24,8 +24,8 @@ pub async fn execute_javascript_code(code: &str) -> JsExecutionResult { }; } - // DenoExecutor를 사용한 실제 JavaScript 실행 - match execute_with_deno(code).await { + // NodeExecutor를 사용한 실제 JavaScript 실행 + match execute_with_node(code).await { Ok(output) => JsExecutionResult { code: code.to_string(), result: output, @@ -43,10 +43,10 @@ pub async fn execute_javascript_code(code: &str) -> JsExecutionResult { } } -/// Deno를 사용한 JavaScript 코드 실행 -async fn execute_with_deno(code: &str) -> Result> { - // DenoExecutor 생성 - let mut executor = DenoExecutor::new().await.map_err(|e| format!("{}", e))?; +/// Node.js를 사용한 JavaScript 코드 실행 +async fn execute_with_node(code: &str) -> Result> { + // NodeExecutor 생성 + let executor = NodeExecutor::new().map_err(|e| format!("{}", e))?; // 코드 실행 let result = executor diff --git a/apps/executeJS/src-tauri/src/lib.rs b/apps/executeJS/src-tauri/src/lib.rs index a1f6f00..11a3d23 100644 --- a/apps/executeJS/src-tauri/src/lib.rs +++ b/apps/executeJS/src-tauri/src/lib.rs @@ -12,167 +12,155 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_store::Builder::default().build()); + + // DevTools 플러그인 추가 (개발 빌드에서만) #[cfg(debug_assertions)] { - let mut builder = tauri::Builder::default() - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_fs::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_store::Builder::default().build()); - - // DevTools 플러그인 추가 (개발 빌드에서만) - #[cfg(debug_assertions)] - { - builder = builder.plugin(tauri_plugin_devtools::init()); - } - - builder - .setup(|app_handle| { - // Window Menu - let about_menu = SubmenuBuilder::new(app_handle, "About") - .text("about", "About ExecuteJS") - .separator() - .text("settings", "Settings...") - .separator() - .text("quit", "Quit ExecuteJS") - .build()?; - - let file_menu = SubmenuBuilder::new(app_handle, "File") - .text("new_tab", "New Tab") - .separator() - .text("close_tab", "Close Tab") - .build()?; - - let edit_menu = SubmenuBuilder::new(app_handle, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .separator() - .select_all() - .build()?; - - let view_menu = SubmenuBuilder::new(app_handle, "View") - .text("reload", "Reload") - .text("toggle_devtools", "Toggle Developer Tools") - .build()?; - - // Main Menu - let menu = MenuBuilder::new(app_handle) - .items(&[&about_menu, &file_menu, &edit_menu, &view_menu]) - .build()?; - - app_handle.set_menu(menu)?; - - // Menu Event - app_handle.on_menu_event(move |app_handle, event| { - match event.id().0.as_str() { - // About Menu - "about" => { - // TODO: About ExecuteJS 다이얼로그 표시 - eprintln!("About ExecuteJS 메뉴 클릭됨"); - } - "settings" => { - // Settings 창이 이미 열려있는지 확인 - if let Some(existing_window) = app_handle.get_webview_window("settings") - { - existing_window.set_focus().unwrap_or_default(); + builder = builder.plugin(tauri_plugin_devtools::init()); + } + + builder + .setup(|app_handle| { + // Window Menu + let about_menu = SubmenuBuilder::new(app_handle, "About") + .text("about", "About ExecuteJS") + .separator() + .text("settings", "Settings...") + .separator() + .text("quit", "Quit ExecuteJS") + .build()?; + + let file_menu = SubmenuBuilder::new(app_handle, "File") + .text("new_tab", "New Tab") + .separator() + .text("close_tab", "Close Tab") + .build()?; + + let edit_menu = SubmenuBuilder::new(app_handle, "Edit") + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .separator() + .select_all() + .build()?; + + let view_menu = SubmenuBuilder::new(app_handle, "View") + .text("reload", "Reload") + .text("toggle_devtools", "Toggle Developer Tools") + .build()?; + + // Main Menu + let menu = MenuBuilder::new(app_handle) + .items(&[&about_menu, &file_menu, &edit_menu, &view_menu]) + .build()?; + + app_handle.set_menu(menu)?; + + // Menu Event + app_handle.on_menu_event(move |app_handle, event| { + match event.id().0.as_str() { + // About Menu + "about" => { + // TODO: About ExecuteJS 다이얼로그 표시 + eprintln!("About ExecuteJS 메뉴 클릭됨"); + } + "settings" => { + // Settings 창이 이미 열려있는지 확인 + if let Some(existing_window) = app_handle.get_webview_window("settings") { + existing_window.set_focus().unwrap_or_default(); + } else { + // 새 Settings 창 생성 + // 개발 모드에서는 devUrl 사용, 프로덕션에서는 App 경로 사용 + let url = if cfg!(debug_assertions) { + WebviewUrl::External( + "http://localhost:1420/settings" + .parse() + .expect("failed to parse dev settings URL"), + ) } else { - // 새 Settings 창 생성 - // 개발 모드에서는 devUrl 사용, 프로덕션에서는 App 경로 사용 - let url = if cfg!(debug_assertions) { - WebviewUrl::External( - "http://localhost:1420/settings" - .parse() - .expect("failed to parse dev settings URL"), - ) - } else { - // TODO: 해시 라우팅을 사용하여 Settings 페이지로 이동 처리. 다른 방법으로 수정 필요 - WebviewUrl::App("index.html#/settings".into()) - }; - - match WebviewWindowBuilder::new(app_handle, "settings", url) - .title("General") - .inner_size(800.0, 600.0) - .min_inner_size(600.0, 400.0) - .resizable(true) - .build() - { - Ok(settings_window) => { - // Settings 창에서 개발자 도구 자동 열기 - #[cfg(debug_assertions)] - { - settings_window.open_devtools(); - } - } - Err(e) => { - eprintln!("Settings 창 생성 실패: {:?}", e); - } + // TODO: 해시 라우팅을 사용하여 Settings 페이지로 이동 처리. 다른 방법으로 수정 필요 + WebviewUrl::App("index.html#/settings".into()) + }; + + match WebviewWindowBuilder::new(app_handle, "settings", url) + .title("General") + .inner_size(800.0, 600.0) + .min_inner_size(600.0, 400.0) + .resizable(true) + .build() + { + Ok(_settings_window) => { + // Settings 창 생성 완료 + // DevTools는 플러그인을 통해 자동으로 관리됨 + } + Err(e) => { + eprintln!("Settings 창 생성 실패: {:?}", e); } } } - "quit" => { - app_handle.exit(0); - } + } + "quit" => { + app_handle.exit(0); + } - // File Menu - "new_tab" => { - // TODO: 새 탭 추가 - eprintln!("New Tab 메뉴 클릭됨"); - } - "close_tab" => { - // TODO: 현재 탭 닫기 - eprintln!("Close Tab 메뉴 클릭됨"); - } + // File Menu + "new_tab" => { + // TODO: 새 탭 추가 + eprintln!("New Tab 메뉴 클릭됨"); + } + "close_tab" => { + // TODO: 현재 탭 닫기 + eprintln!("Close Tab 메뉴 클릭됨"); + } - // View Menu - "reload" => { - if let Some(window) = app_handle.get_webview_window("main") { - window.eval("window.location.reload()").unwrap(); - } + // View Menu + "reload" => { + if let Some(window) = app_handle.get_webview_window("main") { + window.eval("window.location.reload()").unwrap(); } - "toggle_devtools" => { - if let Some(window) = app_handle.get_webview_window("main") { - window.open_devtools(); - window.close_devtools(); + } + "toggle_devtools" => { + // Tauri 2.0에서는 DevTools 플러그인을 통해 제어 + #[cfg(debug_assertions)] + { + if app_handle.get_webview_window("main").is_some() { + // DevTools는 플러그인을 통해 자동으로 관리됨 + eprintln!("DevTools 토글 (플러그인 사용)"); } } - _ => { - eprintln!("메뉴 이벤트: {:?}", event.id()); - } } - }); - - // JavaScript 실행기 상태 관리 - #[cfg(debug_assertions)] - { - let window = app_handle.get_webview_window("main").unwrap(); - window.open_devtools(); - window.close_devtools(); - } - - // 앱 시작 시 초기화 작업 - tauri::async_runtime::spawn(async { - tracing::info!("ExecuteJS 애플리케이션이 시작되었습니다."); - }); - - Ok(()) - }) - .on_window_event(|_window, event| { - // 앱 종료 시 정리 작업 - if let tauri::WindowEvent::CloseRequested { .. } = event { - tracing::info!("ExecuteJS 애플리케이션이 종료됩니다."); + _ => { + eprintln!("메뉴 이벤트: {:?}", event.id()); + } } - }) - .invoke_handler(tauri::generate_handler![ - execute_js, - get_app_info, - lint_code - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); - } + }); + + // JavaScript 실행기 상태 관리 + // DevTools는 플러그인을 통해 자동으로 관리됨 + + // 앱 시작 시 초기화 작업 + tauri::async_runtime::spawn(async { + tracing::info!("ExecuteJS 애플리케이션이 시작되었습니다."); + }); + + Ok(()) + }) + .on_window_event(|_window, event| { + // 앱 종료 시 정리 작업 + if let tauri::WindowEvent::CloseRequested { .. } = event { + tracing::info!("ExecuteJS 애플리케이션이 종료됩니다."); + } + }) + .invoke_handler(tauri::generate_handler![execute_js, get_app_info]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/apps/executeJS/src-tauri/tauri.conf.json b/apps/executeJS/src-tauri/tauri.conf.json index 6d7a0ce..9f34e5a 100644 --- a/apps/executeJS/src-tauri/tauri.conf.json +++ b/apps/executeJS/src-tauri/tauri.conf.json @@ -23,13 +23,24 @@ ], "security": { "csp": null, - "capabilities": ["main"] + "capabilities": [ + "main" + ] } }, "bundle": { "active": true, - "targets": ["msi", "nsis", "dmg", "app"], + "targets": [ + "msi", + "nsis", + "dmg", + "app" + ], "icon": [], + "resources": [ + "resources/node-runtime/node-v24.12.0-darwin-arm64/node", + "resources/node-runtime/node-v24.12.0-win-arm64/node.exe" + ], "macOS": { "entitlements": null, "exceptionDomain": "", diff --git a/apps/executeJS/src/widgets/code-editor/code-editor.tsx b/apps/executeJS/src/widgets/code-editor/code-editor.tsx index 046a339..4e85209 100644 --- a/apps/executeJS/src/widgets/code-editor/code-editor.tsx +++ b/apps/executeJS/src/widgets/code-editor/code-editor.tsx @@ -1,6 +1,5 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { Editor, EditorProps, Monaco } from '@monaco-editor/react'; import type { Options as PrettierOptions } from 'prettier'; import prettier from 'prettier/standalone'; @@ -8,7 +7,7 @@ import babel from 'prettier/plugins/babel'; import estree from 'prettier/plugins/estree'; import typescript from 'prettier/plugins/typescript'; -import { CodeEditorProps, LintResult, LintSeverity } from '@/shared'; +import { CodeEditorProps } from '@/shared'; const prettierOptions: PrettierOptions = { semi: true, @@ -19,6 +18,8 @@ const prettierOptions: PrettierOptions = { useTabs: false, }; +// 비활성화: lint 관련 함수 +/* const severityToMarkerSeverity = (severity: LintSeverity, monaco: Monaco) => { switch (severity) { case 'error': @@ -35,6 +36,7 @@ const severityToMarkerSeverity = (severity: LintSeverity, monaco: Monaco) => { return monaco.MarkerSeverity.Warning; } }; +*/ export const CodeEditor: React.FC = ({ value, @@ -46,62 +48,62 @@ export const CodeEditor: React.FC = ({ const editorRef = useRef(null); const monacoRef = useRef(null); const disposablesRef = useRef>([]); - const debounceTimeoutRef = useRef(null); - - const validateCode = useCallback(async (model: any, version: number) => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } - - debounceTimeoutRef.current = setTimeout(async () => { - if (!model || !monacoRef.current) return; - - try { - const code = model.getValue(); - - // Tauri 백엔드에서 oxlint 실행 - const lintResults = await invoke>('lint_code', { - code, - }); - - // setModelMarkers 사용 - const monaco = monacoRef.current; - if (monaco && model.getVersionId() === version) { - const markers = lintResults.map((result) => { - // Monaco는 1-based 인덱스 사용 - const startColumn = Math.max(1, result.column); - const endColumn = Math.max(startColumn + 1, result.end_column); - - // severity를 소문자로 비교하여 MarkerSeverity enum 사용 - const severity = severityToMarkerSeverity(result.severity, monaco); - - return { - message: `${result.message} (${result.rule_id})`, - severity, - startLineNumber: result.line, - startColumn: startColumn, - endLineNumber: result.end_line, - endColumn: endColumn, - source: 'oxlint', - code: result.rule_id, - }; - }); - - monaco.editor.setModelMarkers(model, 'oxlint', markers); - } - } catch (error) { - console.error('oxlint validation error:', error); - // 에러 발생 시 마커 초기화 - const monaco = monacoRef.current; - if (monaco) { - const model = editorRef.current?.getModel(); - if (model && model.getVersionId() === version) { - monaco.editor.setModelMarkers(model, 'oxlint', []); - } - } - } - }, 500); - }, []); + // const debounceTimeoutRef = useRef(null); + + // const validateCode = useCallback(async (model: any, version: number) => { + // if (debounceTimeoutRef.current) { + // clearTimeout(debounceTimeoutRef.current); + // } + + // debounceTimeoutRef.current = setTimeout(async () => { + // if (!model || !monacoRef.current) return; + + // try { + // const code = model.getValue(); + + // // Tauri 백엔드에서 oxlint 실행 + // const lintResults = await invoke>('lint_code', { + // code, + // }); + + // // setModelMarkers 사용 + // const monaco = monacoRef.current; + // if (monaco && model.getVersionId() === version) { + // const markers = lintResults.map((result) => { + // // Monaco는 1-based 인덱스 사용 + // const startColumn = Math.max(1, result.column); + // const endColumn = Math.max(startColumn + 1, result.end_column); + + // // severity를 소문자로 비교하여 MarkerSeverity enum 사용 + // const severity = severityToMarkerSeverity(result.severity, monaco); + + // return { + // message: `${result.message} (${result.rule_id})`, + // severity, + // startLineNumber: result.line, + // startColumn: startColumn, + // endLineNumber: result.end_line, + // endColumn: endColumn, + // source: 'oxlint', + // code: result.rule_id, + // }; + // }); + + // monaco.editor.setModelMarkers(model, 'oxlint', markers); + // } + // } catch (error) { + // console.error('oxlint validation error:', error); + // // 에러 발생 시 마커 초기화 + // const monaco = monacoRef.current; + // if (monaco) { + // const model = editorRef.current?.getModel(); + // if (model && model.getVersionId() === version) { + // monaco.editor.setModelMarkers(model, 'oxlint', []); + // } + // } + // } + // }, 500); + // }, []); // Monaco Editor 설정 const handleEditorDidMount: EditorProps['onMount'] = (editor, monaco) => { @@ -198,24 +200,8 @@ export const CodeEditor: React.FC = ({ const model = editor.getModel(); if (model) { - // 모델 변경 시 validation - const contentChangeDisposable = model.onDidChangeContent(() => { - // Reset the markers - monaco.editor.setModelMarkers(model, 'oxlint', []); - - // Send the code to the backend for validation - validateCode(model, model.getVersionId()); - }); - - // model이 있는 경우 포맷터 + 이벤트 리스너 저장 - disposablesRef.current = [ - jsDisposable, - tsDisposable, - contentChangeDisposable, - ]; - - // 초기 validation - validateCode(model, model.getVersionId()); + // model이 있는 경우 포맷터만 저장 + disposablesRef.current = [jsDisposable, tsDisposable]; } else { // model이 없는 경우 포맷터만 저장 disposablesRef.current = [jsDisposable, tsDisposable]; @@ -273,9 +259,6 @@ export const CodeEditor: React.FC = ({ // Cleanup: unmount 시 포맷터 등록 해제 useEffect(() => { return () => { - if (debounceTimeoutRef.current) { - clearTimeout(debounceTimeoutRef.current); - } // 모든 disposable 해제 disposablesRef.current.forEach((disposable) => { disposable.dispose(); diff --git a/crates/node-runtime/Cargo.toml b/crates/node-runtime/Cargo.toml new file mode 100644 index 0000000..bd73f81 --- /dev/null +++ b/crates/node-runtime/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "node-runtime" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Node.js 기반 JavaScript 런타임" + +[dependencies] +# Workspace dependencies +anyhow.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio.workspace = true + diff --git a/crates/node-runtime/src/lib.rs b/crates/node-runtime/src/lib.rs new file mode 100644 index 0000000..465b62a --- /dev/null +++ b/crates/node-runtime/src/lib.rs @@ -0,0 +1,504 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::io::{AsyncReadExt, BufReader}; +use tokio::process::Command; + +/// JavaScript 실행 결과를 저장하는 구조체 +#[derive(Debug, Clone)] +pub struct ExecutionOutput { + pub stdout: String, + pub stderr: String, +} + +impl ExecutionOutput { + pub fn new() -> Self { + Self { + stdout: String::new(), + stderr: String::new(), + } + } + + pub fn get_output(&self) -> String { + let mut output = Vec::new(); + + if !self.stdout.is_empty() { + output.push(self.stdout.clone()); + } + + if !self.stderr.is_empty() { + output.push(format!("[ERROR] {}", self.stderr)); + } + + output.join("\n") + } +} + +/// JavaScript 실행기 (Node.js 기반) +pub struct NodeExecutor { + node_path: PathBuf, +} + +impl NodeExecutor { + /// 새로운 NodeExecutor 인스턴스 생성 + pub fn new() -> Result { + let node_path = Self::find_node_binary().map_err(|e| { + tracing::error!("Node.js 바이너리를 찾을 수 없습니다: {}", e); + e + })?; + tracing::info!("NodeExecutor 초기화 완료: {}", node_path.display()); + Ok(Self { node_path }) + } + + /// OS별 Node.js 바이너리 경로 찾기 + fn find_node_binary() -> Result { + let (os_name, binary_name) = if cfg!(target_os = "windows") { + ("win-arm64", "node.exe") + } else if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + ("darwin-arm64", "node") + } else { + ("darwin-x64", "node") + } + } else if cfg!(target_os = "linux") { + if cfg!(target_arch = "aarch64") { + ("linux-arm64", "node") + } else { + ("linux-x64", "node") + } + } else { + anyhow::bail!("지원하지 않는 운영체제입니다: {}", std::env::consts::OS); + }; + + // 1. 개발 모드: src-tauri/resources/ 폴더에서 찾기 + // CARGO_MANIFEST_DIR에서 src-tauri로 이동 (crates/node-runtime -> 프로젝트 루트 -> apps/executeJS/src-tauri) + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let crate_root = Path::new(manifest_dir); + // crates/node-runtime -> 프로젝트 루트 -> apps/executeJS/src-tauri + let src_tauri_dir = crate_root + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("apps").join("executeJS").join("src-tauri")); + + if let Some(ref tauri_dir) = src_tauri_dir { + let resources_node_dir = tauri_dir + .join("resources") + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)); + let resources_node_path = resources_node_dir.join(binary_name); + + if resources_node_path.exists() { + tracing::debug!( + "개발 모드: Node.js 바이너리 경로: {}", + resources_node_path.display() + ); + return Self::set_permissions_if_needed(resources_node_path); + } + } + + // 2. 프로덕션 모드: 실행 파일 위치 기준으로 리소스 찾기 + // Tauri 앱의 경우 실행 파일과 같은 디렉토리나 리소스 디렉토리에서 찾기 + if let Ok(exe_path) = std::env::current_exe() { + eprintln!("[NodeExecutor] 실행 파일 경로: {}", exe_path.display()); + + // macOS .app 번들 구조: .app/Contents/MacOS/executeJS -> .app/Contents/Resources/ + #[cfg(target_os = "macos")] + { + // .app/Contents/Resources/ 경로 확인 + if let Some(macos_dir) = exe_path.parent() { + eprintln!("[NodeExecutor] MacOS 디렉토리: {}", macos_dir.display()); + + // MacOS 디렉토리에서 Contents로 이동 + if macos_dir.ends_with("MacOS") + || macos_dir.file_name().and_then(|n| n.to_str()) == Some("MacOS") + { + if let Some(contents_dir) = macos_dir.parent() { + eprintln!( + "[NodeExecutor] Contents 디렉토리: {}", + contents_dir.display() + ); + let resources_dir = contents_dir.join("Resources"); + eprintln!( + "[NodeExecutor] Resources 디렉토리 확인: {}", + resources_dir.display() + ); + + // Tauri가 리소스를 포함할 때의 경로 구조 확인 + // tauri.conf.json 설정: "resources/node-runtime/": "node-runtime/" + // 따라서 Resources/node-runtime/... 경로에 있음 + + // 1. node-runtime/node-v24.12.0-*/node (우선 확인 - tauri.conf.json 설정에 따라) + let resource_path = resources_dir + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)) + .join(binary_name); + eprintln!( + "[NodeExecutor] 경로 1 확인 (우선): {}", + resource_path.display() + ); + if resource_path.exists() { + eprintln!("[NodeExecutor] ✅ 경로 1에서 발견!"); + return Self::set_permissions_if_needed(resource_path); + } + + // 2. resources/node-runtime/node-v24.12.0-*/node (다른 구조) + let resource_path2 = resources_dir + .join("resources") + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)) + .join(binary_name); + eprintln!("[NodeExecutor] 경로 2 확인: {}", resource_path2.display()); + if resource_path2.exists() { + eprintln!("[NodeExecutor] ✅ 경로 2에서 발견!"); + return Self::set_permissions_if_needed(resource_path2); + } + + // 3. _up_/_up_/_up_/resources/node-runtime/... (이전 설정 호환성) + let tauri_resource_path = resources_dir + .join("_up_") + .join("_up_") + .join("_up_") + .join("resources") + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)) + .join(binary_name); + eprintln!( + "[NodeExecutor] 경로 3 확인 (이전 호환): {}", + tauri_resource_path.display() + ); + if tauri_resource_path.exists() { + eprintln!("[NodeExecutor] ✅ 경로 3에서 발견!"); + return Self::set_permissions_if_needed(tauri_resource_path); + } + + // Resources 디렉토리 전체 구조 확인 (디버깅용) + eprintln!("[NodeExecutor] Resources 디렉토리 전체 구조:"); + if let Ok(entries) = std::fs::read_dir(&resources_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + eprintln!(" [DIR] {}", path.display()); + // 하위 디렉토리도 확인 + if let Ok(sub_entries) = std::fs::read_dir(&path) { + for sub_entry in sub_entries.flatten() { + eprintln!(" - {}", sub_entry.path().display()); + } + } + } else { + eprintln!(" [FILE] {}", path.display()); + } + } + } + + // 4. 직접 Resources에 있는 경우 + let direct_resource_path = resources_dir.join(binary_name); + eprintln!( + "[NodeExecutor] 경로 4 확인: {}", + direct_resource_path.display() + ); + if direct_resource_path.exists() { + eprintln!("[NodeExecutor] ✅ 경로 4에서 발견!"); + return Self::set_permissions_if_needed(direct_resource_path); + } + + // Resources 디렉토리 내용 확인 (디버깅용) + if let Ok(entries) = std::fs::read_dir(&resources_dir) { + eprintln!("[NodeExecutor] Resources 디렉토리 내용:"); + for entry in entries.flatten() { + eprintln!(" - {}", entry.path().display()); + } + } + } + } + } + } + + // 실행 파일의 부모 디렉토리들에서 resources 폴더 찾기 + let mut search_path = exe_path.parent(); + for depth in 0..10 { + if let Some(path) = search_path { + // 일반 resources 폴더 + let resource_path = path + .join("resources") + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)) + .join(binary_name); + if resource_path.exists() { + tracing::info!( + "프로덕션 모드 (depth {}): Node.js 바이너리 경로: {}", + depth, + resource_path.display() + ); + return Self::set_permissions_if_needed(resource_path); + } + + // 리소스가 직접 있는 경우 (폴더 구조 없이) + let direct_resource_path = path + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)) + .join(binary_name); + if direct_resource_path.exists() { + tracing::info!( + "프로덕션 모드 (직접, depth {}): Node.js 바이너리 경로: {}", + depth, + direct_resource_path.display() + ); + return Self::set_permissions_if_needed(direct_resource_path); + } + + // Windows/Linux: 실행 파일과 같은 디렉토리 + let same_dir_path = path.join(binary_name); + if same_dir_path.exists() && path != exe_path.parent().unwrap() { + // 실행 파일과 같은 디렉토리가 아닌 경우만 (이미 확인했으므로) + // 이건 실제로는 필요 없을 수 있음 + } + + search_path = path.parent(); + } else { + break; + } + } + } + + // 에러 메시지용 경로 생성 + let error_path = if let Some(ref tauri_dir) = src_tauri_dir { + tauri_dir + .join("resources") + .join("node-runtime") + .join(format!("node-v24.12.0-{}", os_name)) + .join(binary_name) + } else { + PathBuf::from("apps/executeJS/src-tauri/resources/node-runtime/...") + }; + + // 디버깅을 위한 상세 정보 + let exe_info = std::env::current_exe() + .map(|p| format!("{}", p.display())) + .unwrap_or_else(|_| "알 수 없음".to_string()); + + anyhow::bail!( + "Node.js 바이너리를 찾을 수 없습니다.\n\ + - 개발 모드 경로: {}\n\ + - 실행 파일 경로: {}\n\ + - OS: {}, Arch: {}\n\ + - 바이너리 이름: {}\n\ + src-tauri/resources/node-runtime/ 폴더에 Node.js 바이너리가 있는지 확인하세요.", + error_path.display(), + exe_info, + std::env::consts::OS, + std::env::consts::ARCH, + binary_name + ); + } + + /// 실행 권한 설정 (필요한 경우) + fn set_permissions_if_needed(node_path: PathBuf) -> Result { + // 실행 권한 확인 (Unix 계열) - 이미 실행 가능한 경우 스킵하여 파일 변경 방지 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&node_path).with_context(|| { + format!( + "파일 메타데이터를 읽을 수 없습니다: {}", + node_path.display() + ) + })?; + let perms = metadata.permissions(); + // 이미 실행 권한이 있는 경우 스킵 (파일 변경 방지) + if perms.mode() & 0o111 == 0 { + // 실행 권한이 없는 경우에만 설정 + let mut new_perms = perms.clone(); + new_perms.set_mode(0o755); + std::fs::set_permissions(&node_path, new_perms).with_context(|| { + format!("실행 권한을 설정할 수 없습니다: {}", node_path.display()) + })?; + } + } + + Ok(node_path) + } + + /// JavaScript 코드 실행 + pub async fn execute_script(&self, _filename: &str, code: &str) -> Result { + tracing::debug!("Node.js 코드 실행 시작, 코드 길이: {} bytes", code.len()); + + // 임시 디렉토리를 working directory로 설정하여 프로젝트 폴더 변경 방지 + let temp_dir = std::env::temp_dir(); + + // Node.js subprocess 실행 (stdin으로 코드 전달) + let mut child = Command::new(&self.node_path) + .current_dir(&temp_dir) // 임시 디렉토리에서 실행하여 프로젝트 폴더 변경 방지 + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "Node.js 프로세스를 시작할 수 없습니다: {}", + self.node_path.display() + ) + })?; + + // stdin에 코드 쓰기 + let mut stdin = child.stdin.take().expect("stdin이 설정되지 않았습니다"); + use tokio::io::AsyncWriteExt; + stdin + .write_all(code.as_bytes()) + .await + .context("stdin에 코드 쓰기 실패")?; + drop(stdin); // stdin 닫기 + + // stdout와 stderr를 비동기로 읽기 + let stdout = child.stdout.take().expect("stdout가 설정되지 않았습니다"); + let stderr = child.stderr.take().expect("stderr가 설정되지 않았습니다"); + + let mut stdout_reader = BufReader::new(stdout); + let mut stderr_reader = BufReader::new(stderr); + + let mut stdout_buf = String::new(); + let mut stderr_buf = String::new(); + + // stdout와 stderr를 동시에 읽기 + let (stdout_result, stderr_result) = tokio::join!( + stdout_reader.read_to_string(&mut stdout_buf), + stderr_reader.read_to_string(&mut stderr_buf) + ); + + stdout_result.context("stdout 읽기 실패")?; + stderr_result.context("stderr 읽기 실패")?; + + // 프로세스 종료 대기 + let status = child.wait().await.context("프로세스 종료 대기 실패")?; + + // 출력 버퍼 생성 + let mut output = ExecutionOutput::new(); + output.stdout = stdout_buf.trim().to_string(); + output.stderr = stderr_buf.trim().to_string(); + + // 프로세스가 실패한 경우 (0이 아닌 종료 코드) + if !status.success() { + let error_msg = if !output.stderr.is_empty() { + output.stderr.clone() + } else { + format!( + "프로세스가 종료 코드 {}로 종료되었습니다", + status.code().unwrap_or(-1) + ) + }; + return Err(anyhow::anyhow!("{}", error_msg)); + } + + let result_text = output.get_output(); + + if result_text.is_empty() { + Ok("코드가 실행되었습니다.".to_string()) + } else { + Ok(result_text) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // 테스트 간 격리를 위한 락 + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[tokio::test] + async fn test_console_log() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "console.log('Hello World');") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("Hello World")); + } + + #[tokio::test] + async fn test_variable_assignment() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "let a = 5; console.log(a);") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("5")); + } + + #[tokio::test] + async fn test_calculation() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "let a = 1; let b = 2; console.log(a + b);") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("3")); + } + + #[tokio::test] + async fn test_syntax_error() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor.execute_script("test.js", "alert('adf'(;").await; + // 문법 오류는 실행 실패를 반환해야 함 + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_multiple_statements() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script( + "test.js", + "let x = 5; let y = 3; console.log('result:', x + y);", + ) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("result: 8")); + } + + #[tokio::test] + async fn test_multiple_console_logs() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script( + "test.js", + "console.log('First'); console.log('Second'); console.log('Third');", + ) + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + assert!(output.contains("First")); + assert!(output.contains("Second")); + assert!(output.contains("Third")); + } + + #[tokio::test] + async fn test_object_logging() { + let _lock = TEST_LOCK.lock().unwrap(); + let executor = NodeExecutor::new().unwrap(); + let result = executor + .execute_script("test.js", "console.log({ name: 'Test', value: 42 });") + .await; + assert!(result.is_ok()); + let output = result.unwrap(); + println!("실제 출력: '{}'", output); + // Node.js는 객체를 자동으로 직렬화하여 출력 + assert!(output.contains("name") || output.contains("Test")); + } +} diff --git a/package.json b/package.json index b293e81..853e857 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "docs:build": "pnpm --filter @executeJS/docs build", "docs:preview": "pnpm --filter @executeJS/docs preview", "clean": "pnpm -r clean && cargo clean", - "type-check": "pnpm -r type-check" + "type-check": "pnpm -r type-check", + "download-node": "node scripts/download-node-binaries.js", + "postinstall": "pnpm download-node" }, "devDependencies": { "@types/node": "latest", diff --git a/scripts/download-node-binaries.js b/scripts/download-node-binaries.js new file mode 100755 index 0000000..c620b8c --- /dev/null +++ b/scripts/download-node-binaries.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const { createWriteStream } = require('fs'); + +const NODE_VERSION = 'v24.12.0'; +const BASE_URL = `https://nodejs.org/dist/${NODE_VERSION}/`; + +// OS 및 아키텍처 매핑 +const getPlatformInfo = () => { + const platform = process.platform; + const arch = process.arch; + + if (platform === 'darwin') { + return { + os: 'darwin', + arch: arch === 'arm64' ? 'arm64' : 'x64', + extension: 'tar.xz', // 더 작은 파일 크기 + binaryName: 'node', + }; + } else if (platform === 'win32') { + return { + os: 'win', + arch: arch === 'arm64' ? 'arm64' : 'x64', + extension: 'zip', + binaryName: 'node.exe', + }; + } else { + // CI 환경이거나 지원하지 않는 플랫폼인 경우 조용히 종료 + // GitHub Actions는 Linux에서 실행되지만 바이너리는 필요 없음 + return null; + } +}; + +// 파일 다운로드 +const downloadFile = async (url, destPath) => { + return new Promise((resolve, reject) => { + console.log(`다운로드 중: ${url}`); + const file = createWriteStream(destPath); + + // 파일 스트림 정리 헬퍼 함수 + const cleanup = (callback) => { + file.close(() => { + fs.unlink(destPath, () => { + callback(); + }); + }); + }; + + https + .get(url, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // 리다이렉트 처리 - 파일 스트림 정리 후 재귀 호출 + cleanup(() => { + downloadFile(response.headers.location, destPath).then(resolve).catch(reject); + }); + return; + } + + if (response.statusCode !== 200) { + // 비-200 상태 코드 - 파일 스트림 정리 후 reject + cleanup(() => { + reject(new Error(`다운로드 실패: ${response.statusCode}`)); + }); + return; + } + + const totalSize = parseInt(response.headers['content-length'], 10); + let downloadedSize = 0; + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + const percent = totalSize ? ((downloadedSize / totalSize) * 100).toFixed(1) : '0.0'; + const downloadedMB = (downloadedSize / 1024 / 1024).toFixed(2); + const totalMB = totalSize ? (totalSize / 1024 / 1024).toFixed(2) : '?'; + process.stdout.write(`\r진행률: ${percent}% (${downloadedMB} MB / ${totalMB} MB)`); + }); + + response.pipe(file); + + file.on('finish', () => { + file.close(); + console.log('\n다운로드 완료!'); + resolve(); + }); + + file.on('error', (err) => { + file.close(() => { + fs.unlink(destPath, () => { + reject(err); + }); + }); + }); + }) + .on('error', (err) => { + // HTTP 요청 에러 - 파일 스트림 정리 후 reject + cleanup(() => { + reject(err); + }); + }); + }); +}; + +// 압축 해제 +const extractArchive = async (archivePath, extractDir) => { + const ext = path.extname(archivePath); + const platform = process.platform; + + console.log(`압축 해제 중: ${archivePath}`); + + if (ext === '.zip') { + // Windows: unzip 사용 + try { + execSync(`unzip -q "${archivePath}" -d "${extractDir}"`, { stdio: 'inherit' }); + } catch (error) { + // unzip이 없으면 PowerShell 사용 + execSync(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${extractDir}' -Force"`, { + stdio: 'inherit', + }); + } + } else if (ext === '.xz' || archivePath.endsWith('.tar.xz')) { + // tar.xz 압축 해제 + execSync(`tar -xJf "${archivePath}" -C "${extractDir}"`, { stdio: 'inherit' }); + } else if (ext === '.gz' || archivePath.endsWith('.tar.gz')) { + // tar.gz 압축 해제 + execSync(`tar -xzf "${archivePath}" -C "${extractDir}"`, { stdio: 'inherit' }); + } + + console.log('압축 해제 완료!'); +}; + +// 바이너리 복사 +const copyBinary = async (platformInfo, extractDir) => { + const { os, arch, binaryName } = platformInfo; + const nodeDir = `node-${NODE_VERSION}-${os}-${arch}`; + // Windows는 루트에, macOS/Linux는 bin/ 디렉토리에 있습니다 + const sourcePath = + os === 'win' ? path.join(extractDir, nodeDir, binaryName) : path.join(extractDir, nodeDir, 'bin', binaryName); + // src-tauri/resources/에만 복사 (개발/빌드 모두 동일 경로 사용) + const targetDirs = [ + path.join(__dirname, '..', 'apps', 'executeJS', 'src-tauri', 'resources', 'node-runtime', nodeDir), + ]; + + // 소스 파일 확인 + if (!fs.existsSync(sourcePath)) { + throw new Error(`바이너리 파일을 찾을 수 없습니다: ${sourcePath}`); + } + + // 각 타겟 디렉토리에 복사 + for (const targetDir of targetDirs) { + fs.mkdirSync(targetDir, { recursive: true }); + const targetPath = path.join(targetDir, binaryName); + fs.copyFileSync(sourcePath, targetPath); + + // Unix 시스템에서 실행 권한 설정 + if (process.platform !== 'win32') { + fs.chmodSync(targetPath, 0o755); + } + + console.log(`복사 완료: ${targetPath}`); + } +}; + +// 메인 함수 +const main = async () => { + try { + const platformInfo = getPlatformInfo(); + + // 지원하지 않는 플랫폼이거나 CI 환경인 경우 조용히 종료 + if (!platformInfo) { + console.log(`현재 플랫폼(${process.platform})에서는 Node.js 바이너리 다운로드가 필요하지 않습니다.`); + return; + } + + const { os, arch, extension } = platformInfo; + + const fileName = `node-${NODE_VERSION}-${os}-${arch}.${extension}`; + const downloadUrl = `${BASE_URL}${fileName}`; + + console.log(`Node.js ${NODE_VERSION} 바이너리 다운로드 시작`); + console.log(`플랫폼: ${os}-${arch}`); + console.log(`파일: ${fileName}`); + console.log(`URL: ${downloadUrl}\n`); + + // 임시 디렉토리 생성 + const tempDir = path.join(__dirname, '..', '.temp-node-download'); + fs.mkdirSync(tempDir, { recursive: true }); + const archivePath = path.join(tempDir, fileName); + const extractDir = path.join(tempDir, 'extracted'); + + // 다운로드 + await downloadFile(downloadUrl, archivePath); + + // 압축 해제 + fs.mkdirSync(extractDir, { recursive: true }); + await extractArchive(archivePath, extractDir); + + // 바이너리 복사 + await copyBinary(platformInfo, extractDir); + + // 임시 파일 정리 + console.log('\n임시 파일 정리 중...'); + fs.rmSync(tempDir, { recursive: true, force: true }); + + console.log('\n✅ 모든 작업 완료!'); + } catch (error) { + console.error('\n❌ 오류 발생:', error.message); + process.exit(1); + } +}; + +main();