desktop 앱 적용

This commit is contained in:
Macbook
2026-06-14 10:15:54 +09:00
parent 7a2f65088c
commit 86b99d8c10
81 changed files with 8164 additions and 37 deletions
+3
View File
@@ -23,5 +23,8 @@
# Deploy / local # Deploy / local
*.tar.gz *.tar.gz
dist-desktop/
desktop/updater.key
**/.tauri/
data/mobile-releases/*.apk data/mobile-releases/*.apk
scripts/deploy-target.local.env scripts/deploy-target.local.env
+18
View File
@@ -672,6 +672,24 @@ ssh aidev@exdev.co.kr "docker info"
--- ---
## PC 데스크톱 클라이언트 (Tauri)
웹 Docker 배포와 **별도 Jenkins Job** `goldenChart-Desktop-Pipeline` 으로 macOS `.dmg` + Windows NSIS `.exe` 를 빌드합니다.
```bash
# 서버 최초 1회
./scripts/install-desktop-build-deps.sh
./scripts/setup-desktop-updater-keys.sh
./scripts/server-install-desktop-jenkins.sh
# 수동 빌드·배포
./scripts/jenkins-desktop-pipeline.sh
```
상세: [docs/desktop-pilot.md](docs/desktop-pilot.md)
---
## 13. 부록: deploy.sh · post-receive · Jenkinsfile ## 13. 부록: deploy.sh · post-receive · Jenkinsfile
### 13-1. deploy.sh (서버용) ### 13-1. deploy.sh (서버용)
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=https://exdev.co.kr/api
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+65
View File
@@ -0,0 +1,65 @@
# GoldenChart Desktop (Tauri)
PC용 클라이언트 — [`frontend/src`](../frontend/src)를 `@frontend` alias로 재사용합니다.
API·DB는 exdev 서버에 연결하며, 웹 브라우저와 병행 사용 가능합니다.
## 개발
```bash
# 루트에서 — 최초 1회 (Homebrew + Rust + NSIS/LLVM)
npm run install:desktop:deps # 전체 (Windows 크로스 포함)
npm run install:desktop:deps -- --mac-only # macOS 빌드만
# PATH (새 터미널 또는)
source ~/.zprofile
# 또는
source scripts/desktop-dev-path.sh
```
사전 요구: [Tauri prerequisites](https://tauri.app/start/prerequisites/) (Rust, Xcode CLI 등)
## 빌드
```bash
npm run build:desktop:mac # macOS .dmg
npm run build:desktop:win # Windows NSIS (macOS에서 cargo-xwin 크로스 빌드)
./scripts/build-desktop.sh --all
```
산출물: `desktop/src-tauri/target/release/bundle/dmg/GoldenChart_*.dmg`
### macOS dmg 설치
1. dmg 더블클릭 → **GoldenChart.app****Applications(응용 프로그램)****드래그** (자동 설치 아님)
2. **Finder → 응용 프로그램 → GoldenChart** 실행 (`goldenChart` 아님, **GoldenChart**)
3. Launchpad에 없으면 Spotlight(`⌘+Space`)에 `GoldenChart` 검색
4. 창을 닫아도 **메뉴 막대 트레이**에 남음 — 트레이 아이콘 클릭으로 다시 열기
5. 최초 실행: **시스템 설정 → 개인정보 보호 → 확인 없이 열기**
## 주요 기능
- **위젯 OS 창**: `WebviewWindow` + `widget.html`
- **트레이 상주**: 닫기 → 숨김, STOMP 백그라운드 유지
- **OS 알림**: 매매 시그널 → `tauri-plugin-notification`
- **업데이트**: 설정 → PC 앱 → 업데이트 확인 (`tauri-plugin-updater`)
자세한 배포·파일럿: [docs/desktop-pilot.md](../docs/desktop-pilot.md)
## 운영 스크립트
| 스크립트 | 용도 |
|---------|------|
| `scripts/install-desktop-build-deps.sh` | Rust, NSIS, cargo-xwin (macOS Jenkins) |
| `scripts/setup-desktop-updater-keys.sh` | updater minisign 키 생성 |
| `scripts/build-desktop.sh` | dmg + Windows NSIS + updater bundles |
| `scripts/publish-desktop-update.sh` | 서명 + latest.json + static 배포 |
| `scripts/jenkins-desktop-pipeline.sh` | Jenkins 전체 파이프라인 |
| `scripts/server-install-desktop-jenkins.sh` | Jenkins Job XML 생성 |
| `scripts/smoke-test-desktop.sh` | CI smoke test |
```bash
npm run install:desktop:deps
npm run setup:desktop:keys
npm run publish:desktop
npm run smoke:desktop
```
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1b26" />
<title>GoldenChart</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<script>var global = globalThis;</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@goldenchart/desktop",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev:desktop": "tauri dev",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:build:mac": "bash -c 'source ../scripts/prepare-tauri-build.sh && exec tauri build'",
"tauri:build:win": "bash -c 'source ../scripts/prepare-tauri-build.sh && exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis'"
},
"dependencies": {
"@goldenchart/shared": "*",
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
"@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+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
+5658
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "goldenchart-desktop"
version = "0.1.0"
description = "GoldenChart Desktop Client"
authors = ["GoldenChart"]
edition = "2021"
[lib]
name = "goldenchart_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-notification = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSApplicationCategoryType</key>
<string>public.app-category.finance</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>exdev.co.kr</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,27 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "GoldenChart desktop main + widget windows",
"windows": ["main", "widget-*"],
"permissions": [
"core:default",
"core:window:allow-create",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-start-dragging",
"core:webview:allow-create-webview-window",
"core:webview:allow-webview-close",
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
"updater:default",
"updater:allow-check",
"updater:allow-download-and-install",
"process:allow-restart",
"process:allow-exit",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+81
View File
@@ -0,0 +1,81 @@
use tauri::{
menu::{MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, RunEvent,
};
fn show_main_window(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let show_i = MenuItemBuilder::with_id("show", "GoldenChart 열기").build(app)?;
let quit_i = MenuItemBuilder::with_id("quit", "종료").build(app)?;
let menu = MenuBuilder::new(app)
.items(&[&show_i, &quit_i])
.build()?;
let icon = app
.default_window_icon()
.expect("missing tray icon")
.clone();
let _tray = TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.tooltip("GoldenChart")
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => app.exit(0),
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(tray.app_handle());
}
})
.build(app)?;
show_main_window(app.handle());
Ok(())
})
.on_window_event(|window, event| {
if window.label() != "main" {
return;
}
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => show_main_window(&app_handle),
RunEvent::ExitRequested { api, .. } => {
api.prevent_exit();
if let Some(w) = app_handle.get_webview_window("main") {
let _ = w.hide();
}
}
_ => {}
});
}
+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() {
goldenchart_desktop_lib::run()
}
+58
View File
@@ -0,0 +1,58 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GoldenChart",
"version": "0.1.0",
"identifier": "com.goldenchart.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:5175",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "GoldenChart",
"width": 1440,
"height": 900,
"minWidth": 960,
"minHeight": 640,
"center": true,
"visible": true
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; connect-src 'self' http://localhost:8080 http://127.0.0.1:8080 http://exdev.co.kr https://exdev.co.kr ws://exdev.co.kr wss://exdev.co.kr ws://localhost:8080 https://api.upbit.com wss://api.upbit.com https://fonts.googleapis.com https://fonts.gstatic.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;"
},
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": false,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"exceptionDomain": "exdev.co.kr",
"infoPlist": "Info.plist"
}
},
"plugins": {
"updater": {
"endpoints": [
"https://exdev.co.kr/desktop/updates/latest.json"
],
"pubkey": "REPLACE_WITH_TAURI_SIGNER_PUBKEY"
}
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,17 @@
import { openWidgetWindow, closeWidgetWindow, focusWidgetWindow } from './widgetWindows';
import { showNativeNotification } from './notifications';
import { checkForUpdates, getAppVersion } from './updater';
import type { DesktopBridge } from './types';
/** frontend App 로드 전 호출 — window.__goldenDesktopBridge 등록 */
export function installDesktopBridge(): void {
const bridge: DesktopBridge = {
openWidgetWindow,
closeWidgetWindow,
focusWidgetWindow,
showNativeNotification,
checkForUpdates,
getAppVersion,
};
window.__goldenDesktopBridge = bridge;
}
+19
View File
@@ -0,0 +1,19 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification';
export async function showNativeNotification(title: string, body: string): Promise<void> {
let granted = await isPermissionGranted();
if (!granted) {
const perm = await requestPermission();
granted = perm === 'granted';
}
if (!granted) return;
await sendNotification({
title,
body,
});
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 데스크톱(Tauri) 브릿지 타입 — frontend에서 window.__goldenDesktopBridge 로 호출
*/
import type { FloatingWidgetInstance } from '@frontend/types/floatingWidget';
export interface DesktopBridge {
openWidgetWindow: (instance: FloatingWidgetInstance) => Promise<void>;
closeWidgetWindow: (id: string) => Promise<void>;
focusWidgetWindow: (id: string) => Promise<void>;
showNativeNotification: (title: string, body: string) => Promise<void>;
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
getAppVersion: () => Promise<string>;
}
declare global {
interface Window {
__goldenDesktopBridge?: DesktopBridge;
}
}
export {};
+26
View File
@@ -0,0 +1,26 @@
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { getVersion } from '@tauri-apps/api/app';
export async function getAppVersion(): Promise<string> {
return getVersion();
}
export async function checkForUpdates(): Promise<{
available: boolean;
version?: string;
message?: string;
}> {
try {
const update = await check();
if (!update) {
return { available: false, message: '최신 버전입니다.' };
}
await update.downloadAndInstall();
await relaunch();
return { available: true, version: update.version, message: '업데이트 설치 후 재시작합니다.' };
} catch (e) {
const msg = e instanceof Error ? e.message : '업데이트 확인 실패';
return { available: false, message: msg };
}
}
+63
View File
@@ -0,0 +1,63 @@
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import type { FloatingWidgetInstance } from '@frontend/types/floatingWidget';
const widgetWindows = new Map<string, WebviewWindow>();
function widgetUrl(instance: FloatingWidgetInstance): string {
const params = new URLSearchParams({
id: instance.id,
rows: String(instance.rows),
cols: String(instance.cols),
width: String(instance.width),
height: String(instance.height),
});
if (instance.rowFr?.length) params.set('rowFr', instance.rowFr.join(','));
if (instance.colFr?.length) params.set('colFr', instance.colFr.join(','));
params.set('slots', JSON.stringify(instance.slots));
return `widget.html?${params.toString()}`;
}
export async function openWidgetWindow(instance: FloatingWidgetInstance): Promise<void> {
const label = `widget-${instance.id}`;
const existing = widgetWindows.get(instance.id) ?? (await WebviewWindow.getByLabel(label));
if (existing) {
await existing.setFocus();
widgetWindows.set(instance.id, existing);
return;
}
const win = new WebviewWindow(label, {
url: widgetUrl(instance),
title: 'GoldenChart Widget',
width: instance.width,
height: instance.height,
minWidth: 320,
minHeight: 240,
decorations: true,
resizable: true,
center: false,
x: instance.position.x,
y: instance.position.y,
});
widgetWindows.set(instance.id, win);
void win.once('tauri://destroyed', () => {
widgetWindows.delete(instance.id);
});
}
export async function closeWidgetWindow(id: string): Promise<void> {
const label = `widget-${id}`;
const win = widgetWindows.get(id) ?? (await WebviewWindow.getByLabel(label));
if (win) {
await win.close();
widgetWindows.delete(id);
}
}
export async function focusWidgetWindow(id: string): Promise<void> {
const label = `widget-${id}`;
const win = widgetWindows.get(id) ?? (await WebviewWindow.getByLabel(label));
if (win) await win.setFocus();
}
+6
View File
@@ -0,0 +1,6 @@
import { DESKTOP_API_BASE, setApiBase } from '@goldenchart/shared';
/** Tauri desktop — 항상 https exdev (localhost·http 금지) */
export function ensureDesktopApiBase(): void {
setApiBase(DESKTOP_API_BASE);
}
+4
View File
@@ -0,0 +1,4 @@
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
/** App·backendApi import 전 API base 고정 (localhost 금지) */
ensureDesktopApiBase();
+30
View File
@@ -0,0 +1,30 @@
import './forceServerApi';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
import { installDesktopBridge } from './bridge/installDesktopBridge';
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
import App from '@frontend/App';
installDesktopBridge();
/** 시작 시 백그라운드 업데이트 확인 (실패 무시) */
void import('./bridge/updater').then(({ checkForUpdates }) => {
window.setTimeout(() => {
void checkForUpdates().catch(() => { /* offline 등 */ });
}, 8000);
});
async function bootstrap() {
await initStorage();
ensureDesktopApiBase();
refreshApiBaseFromStorage();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
}
void bootstrap();
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+158
View File
@@ -0,0 +1,158 @@
import './forceServerApi';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { initStorage, refreshApiBaseFromStorage } from '@goldenchart/shared';
import { ensureDesktopApiBase } from './ensureDesktopApiBase';
import FloatingWidgetWindow from '@frontend/components/floatingWidgets/FloatingWidgetWindow';
import {
defaultGridFr,
type FloatingWidgetInstance,
} from '@frontend/types/floatingWidget';
import type { Theme } from '@frontend/types';
import type { WidgetSlot } from '@frontend/types/widgetDashboard';
import { useMarketTicker } from '@frontend/hooks/useMarketTicker';
import {
loadPaperSummary,
loadPaperTrades,
loadStrategies,
type PaperSummaryDto,
type PaperTradeDto,
type StrategyDto,
} from '@frontend/utils/backendApi';
import { PAPER_TRADES_CHANGED_EVENT } from '@frontend/utils/paperTradeEvents';
import { WidgetDashboardProvider } from '@frontend/widgets/WidgetDashboardContext';
import { readDesktopSync, subscribeDesktopSync } from '@frontend/utils/desktopSync';
import '@frontend/styles/paperDashboard.css';
import '@frontend/styles/widgetDashboard.css';
import '@frontend/styles/floatingWidget.css';
function parseInstanceFromUrl(): FloatingWidgetInstance | null {
const params = new URLSearchParams(window.location.search);
const id = params.get('id');
if (!id) return null;
const rows = Number(params.get('rows') ?? 1);
const cols = Number(params.get('cols') ?? 1);
const width = Number(params.get('width') ?? 640);
const height = Number(params.get('height') ?? 480);
let slots: WidgetSlot[] = [];
try {
slots = JSON.parse(params.get('slots') ?? '[]') as WidgetSlot[];
} catch {
slots = [];
}
const rowFrRaw = params.get('rowFr');
const colFrRaw = params.get('colFr');
const rowFr = rowFrRaw ? rowFrRaw.split(',').map(Number) : defaultGridFr(rows);
const colFr = colFrRaw ? colFrRaw.split(',').map(Number) : defaultGridFr(cols);
return {
id,
rows,
cols,
slots,
rowFr,
colFr,
position: { x: 0, y: 0 },
width,
height,
zIndex: 1,
};
}
const WidgetApp: React.FC = () => {
const initial = useMemo(() => parseInstanceFromUrl(), []);
const [instance, setInstance] = useState<FloatingWidgetInstance | null>(initial);
const sync = readDesktopSync();
const [theme, setTheme] = useState<Theme>((sync?.theme as Theme | undefined) ?? 'dark');
const [defaultMarket, setDefaultMarket] = useState(sync?.selectedMarket ?? 'KRW-BTC');
const { tickers, marketInfos, loading: marketLoading, usdRate } = useMarketTicker(
useMemo(() => ({ enabled: true, loadFull: true }), []),
);
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const refreshPaperData = useCallback(async () => {
try {
const [sum, tr] = await Promise.all([loadPaperSummary(), loadPaperTrades()]);
setSummary(sum);
setTrades(tr ?? []);
} catch { /* ignore */ }
}, []);
useEffect(() => {
void loadStrategies().then(list => setStrategies(list ?? []));
void refreshPaperData();
}, [refreshPaperData]);
useEffect(() => {
const onChanged = () => { void refreshPaperData(); };
window.addEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
}, [refreshPaperData]);
useEffect(() => {
return subscribeDesktopSync(state => {
if (state.theme) setTheme(state.theme as Theme);
if (state.selectedMarket) setDefaultMarket(state.selectedMarket);
});
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const handleClose = useCallback(() => {
void getCurrentWindow().close();
}, []);
if (!instance) {
return <p className="wd-widget-empty"> .</p>;
}
return (
<WidgetDashboardProvider
theme={theme}
tickers={tickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
usdRate={usdRate}
defaultMarket={defaultMarket}
strategies={strategies}
summary={summary}
trades={trades}
refreshPaperData={() => { void refreshPaperData(); }}
paperTradingEnabled
paperAutoTradeEnabled={false}
chartRealtimeSource="BACKEND_STOMP"
>
<div className="fw-widget-native-root">
<FloatingWidgetWindow
instance={instance}
focused
onClose={handleClose}
onFocus={() => {}}
onUpdateSlots={slots => setInstance(prev => (prev ? { ...prev, slots } : prev))}
onUpdateSize={(width, height) => setInstance(prev => (prev ? { ...prev, width, height } : prev))}
onUpdateGridFr={(rowFr, colFr) => setInstance(prev => (prev ? { ...prev, rowFr, colFr } : prev))}
/>
</div>
</WidgetDashboardProvider>
);
};
async function bootstrap() {
await initStorage();
ensureDesktopApiBase();
refreshApiBaseFromStorage();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<WidgetApp />
</React.StrictMode>,
);
}
void bootstrap();
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"paths": {
"@goldenchart/shared": ["../packages/shared/src/index.ts"],
"@goldenchart/shared/*": ["../packages/shared/src/*"],
"@frontend/*": ["../frontend/src/*"]
}
},
"include": ["src", "../frontend/src", "../packages/shared/src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+5
View File
@@ -0,0 +1,5 @@
# Tauri updater public key (minisign, 한 줄)
# 생성: ./scripts/setup-desktop-updater-keys.sh
# 실제 키는 desktop/updater.pub (git 커밋) — private key는 ~/.tauri/goldenchart.key (커밋 금지)
REPLACE_WITH_TAURI_SIGNER_PUBKEY
+84
View File
@@ -0,0 +1,84 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const frontendRoot = path.resolve(__dirname, '../frontend/src');
const sharedRoot = path.resolve(__dirname, '../packages/shared/src');
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
/** Tauri 패키지 앱 — 절대 경로(/assets) 사용 시 빈 화면 */
base: './',
/** sockjs-client 등 Node 전역 참조 — Tauri WebView에는 global 없음 */
define: {
global: 'globalThis',
/** desktop은 항상 exdev 서버 (localhost 금지) */
'import.meta.env.VITE_API_BASE_URL': JSON.stringify('https://exdev.co.kr/api'),
__DESKTOP_CLIENT__: 'true',
},
envDir: __dirname,
plugins: [react()],
resolve: {
dedupe: ['react', 'react-dom', 'lightweight-charts'],
alias: {
'@goldenchart/shared': sharedRoot,
'@frontend': frontendRoot,
'@frontend/utils/backendApi': path.resolve(sharedRoot, 'api/backendApi.ts'),
[path.resolve(frontendRoot, 'utils/backendApi.ts')]: path.resolve(
sharedRoot,
'api/backendApi.ts',
),
},
},
optimizeDeps: {
include: ['react', 'react-dom', '@stomp/stompjs', 'sockjs-client'],
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
input: {
main: path.resolve(__dirname, 'index.html'),
widget: path.resolve(__dirname, 'widget.html'),
},
},
},
clearScreen: false,
server: {
port: 5175,
strictPort: true,
host: host || false,
hmr: host
? { protocol: 'ws', host, port: 5176 }
: undefined,
watch: {
ignored: ['**/src-tauri/**'],
},
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
'/upbit-api': {
target: 'https://api.upbit.com',
changeOrigin: true,
rewrite: p => p.replace(/^\/upbit-api/, ''),
},
'/upbit-ws': {
target: 'wss://api.upbit.com',
changeOrigin: true,
ws: true,
rewrite: () => '/websocket/v1',
},
},
},
});
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1b26" />
<title>GoldenChart Widget</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<script>var global = globalThis;</script>
<div id="root"></div>
<script type="module" src="/src/widget-main.tsx"></script>
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
# GoldenChart Desktop — 내부 파일럿 배포·운영 가이드 (10명 이내)
## 1. 서버 최초 설정 (macOS Jenkins, 1회)
```bash
cd /Users/aidev/apps/goldenChart # WORK_TREE
# Rust · NSIS · cargo-xwin
./scripts/install-desktop-build-deps.sh
# updater 서명 키 (private → ~/.tauri/, public → desktop/updater.pub)
./scripts/setup-desktop-updater-keys.sh
git add desktop/updater.pub desktop/src-tauri/tauri.conf.json
git commit -m "chore(desktop): add updater public key"
# Jenkins Job 등록
./scripts/server-install-desktop-jenkins.sh
```
Jenkins Credential (Secret file): `tauri-signer-key``/Users/aidev/.tauri/goldenchart.key`
Global PATH에 추가:
```
/opt/homebrew/opt/llvm/bin
/opt/homebrew/bin
$HOME/.cargo/bin
```
## 2. Jenkins Job: goldenChart-Desktop-Pipeline
| 항목 | 값 |
|------|-----|
| SCM | `file:///Volumes/ADATA/git/goldenChart.git` main |
| 스케줄 | 30분마다 SCM poll (또는 수동) |
| 빌드 | `scripts/jenkins-desktop-pipeline.sh` |
| Artifact | `dist-desktop/**` |
환경 변수 (선택):
| 변수 | 설명 |
|------|------|
| `TAURI_SIGNER_KEY` | private key 경로 |
| `DESKTOP_DEPLOY_WEB=1` | 빌드 후 frontend Docker 재배포 |
| `BUMP_VERSION=patch` | 빌드 시 버전 bump |
| `DESKTOP_UPDATE_BASE_URL` | latest.json URL base |
수동 실행:
```bash
./scripts/jenkins-desktop-pipeline.sh
# 또는
./scripts/build-desktop.sh --all
./scripts/publish-desktop-update.sh
```
## 3. 사용자 설치
### macOS
1. Jenkins → `dist-desktop/*.dmg` 또는 updates URL에서 다운로드
2. DMG → Applications
3. 최초 실행: **시스템 설정 → 개인정보 보호 → 확인 없이 열기**
### Windows
1. `*-setup.exe` (NSIS) 다운로드
2. SmartScreen → **추가 정보 → 실행**
## 4. 앱 기능
- **서버**: `http://exdev.co.kr/api` (웹과 동일 계정·DB)
- **위젯**: OS 별도 창 (`WebviewWindow`)
- **알림**: 창 닫기 → 트레이 상주, STOMP + OS 알림
- **업데이트**: 설정 → 일반 → PC 앱 → **업데이트 확인**
## 5. 업데이트 배포 흐름
```
git push main
→ Jenkins goldenChart-Desktop-Pipeline
→ build-desktop.sh (dmg + exe + updater bundles)
→ publish-desktop-update.sh (sign + latest.json)
→ frontend/public/desktop/updates/
→ (DESKTOP_DEPLOY_WEB=1) nginx 재배포
→ 클라이언트 앱 내 업데이트
```
## 6. Smoke Test
```bash
npm run smoke:desktop
```
## 로컬 개발 (Mac)
```bash
npm install
# 최초 1회 — Homebrew(~/homebrew) + Rust + NSIS/LLVM
npm run install:desktop:deps -- --mac-only # macOS 빌드만 (빠름)
# npm run install:desktop:deps # Windows 크로스 빌드 포함
source ~/.zprofile # PATH 적용
npm run dev:desktop
npm run build:desktop:mac
```
산출물: `desktop/src-tauri/target/release/bundle/dmg/GoldenChart_*.dmg`
관리자 권한이 있으면 `/opt/homebrew` 정식 설치를 권장합니다 (bottle 사용, LLVM 빌드 시간 단축).
+7
View File
@@ -141,6 +141,13 @@ server {
add_header Cache-Control "no-store, no-cache, must-revalidate"; add_header Cache-Control "no-store, no-cache, must-revalidate";
} }
# GoldenChart Desktop updater manifest + bundles
location /desktop/updates/ {
alias /usr/share/nginx/html/desktop/updates/;
add_header Cache-Control "no-cache, no-store, must-revalidate";
default_type application/json;
}
# gzip 압축 # gzip 압축
gzip on; gzip on;
gzip_vary on; gzip_vary on;
@@ -0,0 +1,6 @@
{
"version": "0.1.0",
"notes": "Initial desktop release placeholder",
"pub_date": "2026-06-11T00:00:00Z",
"platforms": {}
}
+7
View File
@@ -35,6 +35,8 @@ import { clearAdminUnlock } from './utils/adminUnlock';
import type { LoginResponse } from './utils/backendApi'; import type { LoginResponse } from './utils/backendApi';
import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateAppSettingsCache } from './hooks/useAppSettings';
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import { isDesktop } from './utils/platform';
import { writeDesktopSync } from './utils/desktopSync';
const LAST_MENU_KEY = 'gc_last_menu'; const LAST_MENU_KEY = 'gc_last_menu';
const CHART_ONLY_INITIAL: ReadonlySet<MenuPage> = new Set(['dashboard', 'settings', 'verification-board']); const CHART_ONLY_INITIAL: ReadonlySet<MenuPage> = new Set(['dashboard', 'settings', 'verification-board']);
@@ -171,6 +173,11 @@ function AppMainContent({
const [floatingWidgetCount, setFloatingWidgetCount] = useState(0); const [floatingWidgetCount, setFloatingWidgetCount] = useState(0);
const showFloatingWidgets = canMenu('widget-dashboard'); const showFloatingWidgets = canMenu('widget-dashboard');
useEffect(() => {
if (!isDesktop()) return;
writeDesktopSync({ theme, selectedMarket: symbol });
}, [theme, symbol]);
const handleFullscreen = () => { const handleFullscreen = () => {
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {}); if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
else document.exitFullscreen().catch(() => {}); else document.exitFullscreen().catch(() => {});
@@ -0,0 +1,62 @@
import React, { useCallback, useEffect, useState } from 'react';
import { isDesktop } from '../utils/platform';
import {
checkDesktopUpdates,
getDesktopAppVersion,
} from '../utils/desktopBridge';
/** PC(Tauri) 앱 — 버전 표시 및 업데이트 확인 */
const DesktopUpdatePanel: React.FC = () => {
const [version, setVersion] = useState<string>('—');
const [status, setStatus] = useState<string>('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!isDesktop()) return;
void getDesktopAppVersion().then(v => {
if (v) setVersion(v);
});
}, []);
const handleCheck = useCallback(async () => {
setBusy(true);
setStatus('업데이트 확인 중…');
try {
const res = await checkDesktopUpdates();
setStatus(res.message ?? (res.available ? `새 버전 ${res.version}` : '최신 버전입니다.'));
} finally {
setBusy(false);
}
}, []);
if (!isDesktop()) return null;
return (
<div className="stg-section">
<h3 className="stg-section-title">PC (GoldenChart Desktop)</h3>
<div className="stg-row">
<div className="stg-row-label">
<strong> </strong>
<p className="stg-row-desc"> .</p>
</div>
<div className="stg-row-control">
<span>{version}</span>
</div>
</div>
<div className="stg-row">
<div className="stg-row-label">
<strong></strong>
<p className="stg-row-desc"> .</p>
</div>
<div className="stg-row-control">
<button type="button" className="stg-btn-secondary" disabled={busy} onClick={() => { void handleCheck(); }}>
{busy ? '확인 중…' : '업데이트 확인'}
</button>
{status && <span className="stg-hint" style={{ marginLeft: 8 }}>{status}</span>}
</div>
</div>
</div>
);
};
export default DesktopUpdatePanel;
+3
View File
@@ -17,6 +17,7 @@ import type { IndicatorConfig } from '../types';
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry'; import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
import type { IchimokuCloudColors } from '../utils/ichimokuConfig'; import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
import ChartTimeFormatPicker from './ChartTimeFormatPicker'; import ChartTimeFormatPicker from './ChartTimeFormatPicker';
import DesktopUpdatePanel from './DesktopUpdatePanel';
import { import {
TRADE_ALERT_SOUND_OPTIONS, TRADE_ALERT_SOUND_OPTIONS,
normalizeTradeAlertSoundId, normalizeTradeAlertSoundId,
@@ -911,6 +912,8 @@ const GeneralPanel: React.FC<{
</label> </label>
</SettingRow> </SettingRow>
</SettingSection> </SettingSection>
<DesktopUpdatePanel />
</> </>
); );
}; };
@@ -1,6 +1,3 @@
/**
* 플로팅 위젯 레이어 — 메뉴바에서 다중 팝업 위젯 실행
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { Theme } from '../../types'; import type { Theme } from '../../types';
@@ -24,6 +21,9 @@ import {
import type { WidgetSlot } from '../../types/widgetDashboard'; import type { WidgetSlot } from '../../types/widgetDashboard';
import FloatingWidgetLayoutPicker from './FloatingWidgetLayoutPicker'; import FloatingWidgetLayoutPicker from './FloatingWidgetLayoutPicker';
import FloatingWidgetWindow from './FloatingWidgetWindow'; import FloatingWidgetWindow from './FloatingWidgetWindow';
import { isDesktop } from '../../utils/platform';
import { getDesktopBridge } from '../../utils/desktopBridge';
import { writeDesktopSync } from '../../utils/desktopSync';
import '../../styles/paperDashboard.css'; import '../../styles/paperDashboard.css';
import '../../styles/widgetDashboard.css'; import '../../styles/widgetDashboard.css';
import '../../styles/floatingWidget.css'; import '../../styles/floatingWidget.css';
@@ -40,6 +40,7 @@ interface Props {
onPaperOrderFilled?: () => void; onPaperOrderFilled?: () => void;
chartRealtimeSource?: ChartRealtimeSource; chartRealtimeSource?: ChartRealtimeSource;
onInstancesChange?: (count: number) => void; onInstancesChange?: (count: number) => void;
selectedStrategyId?: number | null;
} }
const FloatingWidgetLayer: React.FC<Props> = ({ const FloatingWidgetLayer: React.FC<Props> = ({
@@ -52,7 +53,9 @@ const FloatingWidgetLayer: React.FC<Props> = ({
onPaperOrderFilled, onPaperOrderFilled,
chartRealtimeSource = 'BACKEND_STOMP', chartRealtimeSource = 'BACKEND_STOMP',
onInstancesChange, onInstancesChange,
selectedStrategyId = null,
}) => { }) => {
const desktopMode = isDesktop();
const [instances, setInstances] = useState<FloatingWidgetInstance[]>([]); const [instances, setInstances] = useState<FloatingWidgetInstance[]>([]);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const zCounterRef = useRef(BASE_Z + 100); const zCounterRef = useRef(BASE_Z + 100);
@@ -83,14 +86,28 @@ const FloatingWidgetLayer: React.FC<Props> = ({
return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged); return () => window.removeEventListener(PAPER_TRADES_CHANGED_EVENT, onChanged);
}, [refreshPaperData]); }, [refreshPaperData]);
useEffect(() => {
if (!desktopMode) return;
writeDesktopSync({
selectedMarket: defaultMarket,
selectedStrategyId,
theme,
});
}, [desktopMode, defaultMarket, selectedStrategyId, theme]);
const bringToFront = useCallback((id: string) => { const bringToFront = useCallback((id: string) => {
if (desktopMode) {
void getDesktopBridge()?.focusWidgetWindow(id);
setFocusedId(id);
return;
}
zCounterRef.current += 1; zCounterRef.current += 1;
const nextZ = zCounterRef.current; const nextZ = zCounterRef.current;
setFocusedId(id); setFocusedId(id);
setInstances(prev => prev.map(inst => setInstances(prev => prev.map(inst =>
inst.id === id ? { ...inst, zIndex: nextZ } : inst, inst.id === id ? { ...inst, zIndex: nextZ } : inst,
)); ));
}, []); }, [desktopMode]);
const handleLayoutSelect = useCallback((preset: FloatingWidgetLayoutPreset) => { const handleLayoutSelect = useCallback((preset: FloatingWidgetLayoutPreset) => {
zCounterRef.current += 1; zCounterRef.current += 1;
@@ -102,14 +119,25 @@ const FloatingWidgetLayer: React.FC<Props> = ({
zCounterRef.current, zCounterRef.current,
); );
inst.zIndex = zCounterRef.current; inst.zIndex = zCounterRef.current;
if (desktopMode) {
void getDesktopBridge()?.openWidgetWindow(inst);
setInstances(prev => [...prev, inst]); setInstances(prev => [...prev, inst]);
setFocusedId(inst.id); setFocusedId(inst.id);
}, [instances.length]); return;
}
setInstances(prev => [...prev, inst]);
setFocusedId(inst.id);
}, [instances.length, desktopMode]);
const handleClose = useCallback((id: string) => { const handleClose = useCallback((id: string) => {
if (desktopMode) {
void getDesktopBridge()?.closeWidgetWindow(id);
}
setInstances(prev => prev.filter(i => i.id !== id)); setInstances(prev => prev.filter(i => i.id !== id));
setFocusedId(prev => (prev === id ? null : prev)); setFocusedId(prev => (prev === id ? null : prev));
}, []); }, [desktopMode]);
const handleUpdateSlots = useCallback((id: string, slots: WidgetSlot[]) => { const handleUpdateSlots = useCallback((id: string, slots: WidgetSlot[]) => {
setInstances(prev => prev.map(inst => (inst.id === id ? { ...inst, slots } : inst))); setInstances(prev => prev.map(inst => (inst.id === id ? { ...inst, slots } : inst)));
@@ -122,9 +150,9 @@ const FloatingWidgetLayer: React.FC<Props> = ({
}, []); }, []);
const handleUpdateGridFr = useCallback((id: string, rowFr: number[], colFr: number[]) => { const handleUpdateGridFr = useCallback((id: string, rowFr: number[], colFr: number[]) => {
setInstances(prev => prev.map(inst => ( setInstances(prev => prev.map(inst =>
inst.id === id ? { ...inst, rowFr, colFr } : inst inst.id === id ? { ...inst, rowFr, colFr } : inst,
))); ));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -133,6 +161,42 @@ const FloatingWidgetLayer: React.FC<Props> = ({
if (instances.length === 0 && !layoutPickerOpen) return null; if (instances.length === 0 && !layoutPickerOpen) return null;
const picker = (
<FloatingWidgetLayoutPicker
open={layoutPickerOpen}
onClose={() => onLayoutPickerOpenChange(false)}
onSelect={handleLayoutSelect}
/>
);
if (desktopMode) {
return (
<WidgetDashboardProvider
theme={theme}
tickers={tickers}
marketInfos={marketInfos}
marketLoading={marketLoading}
usdRate={usdRate}
defaultMarket={defaultMarket}
strategies={strategies}
summary={summary}
trades={trades}
refreshPaperData={() => { void refreshPaperData(); }}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={onPaperOrderFilled}
chartRealtimeSource={chartRealtimeSource}
>
{layoutPickerOpen && createPortal(
<div className="fw-layer fw-layer--picker-only" aria-label="위젯 레이아웃 선택">
{picker}
</div>,
document.body,
)}
</WidgetDashboardProvider>
);
}
return ( return (
<WidgetDashboardProvider <WidgetDashboardProvider
theme={theme} theme={theme}
@@ -165,11 +229,7 @@ const FloatingWidgetLayer: React.FC<Props> = ({
/> />
))} ))}
<FloatingWidgetLayoutPicker {picker}
open={layoutPickerOpen}
onClose={() => onLayoutPickerOpenChange(false)}
onSelect={handleLayoutSelect}
/>
</div>, </div>,
document.body, document.body,
)} )}
@@ -27,6 +27,8 @@ import {
type TradeAlertSoundId, type TradeAlertSoundId,
} from '../utils/tradeAlertSound'; } from '../utils/tradeAlertSound';
import { normalizeStrategyId } from '../utils/resolveNotificationStrategy'; import { normalizeStrategyId } from '../utils/resolveNotificationStrategy';
import { showDesktopNativeNotification } from '../utils/desktopBridge';
import { getKoreanName } from '../utils/marketNameCache';
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb'; import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
import { useAppSettings } from '../hooks/useAppSettings'; import { useAppSettings } from '../hooks/useAppSettings';
@@ -428,6 +430,13 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
const rest = prev.filter(n => n.id !== id); const rest = prev.filter(n => n.id !== id);
return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE); return [toastItem, ...rest].slice(0, MAX_TOAST_QUEUE);
}); });
const sideLabel = signal.signalType === 'BUY' ? '매수' : '매도';
const marketLabel = getKoreanName(signal.market);
void showDesktopNativeNotification(
`GoldenChart ${sideLabel} 시그널`,
`${marketLabel} · ₩${Math.round(signal.price ?? 0).toLocaleString()}`,
);
}, [popupEnabled, soundEnabled, alertSoundId]); }, [popupEnabled, soundEnabled, alertSoundId]);
const dismissToast = useCallback((id: string) => { const dismissToast = useCallback((id: string) => {
+23
View File
@@ -593,3 +593,26 @@
position: relative; position: relative;
display: inline-flex; display: inline-flex;
} }
/* Tauri 네이티브 위젯 창 */
.fw-layer--picker-only {
pointer-events: auto;
}
.fw-widget-native-root {
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fw-widget-native-root .fw-window {
position: static !important;
width: 100% !important;
height: 100% !important;
max-width: none !important;
max-height: none !important;
box-shadow: none;
border: none;
}
+47
View File
@@ -0,0 +1,47 @@
import { isDesktop } from './platform';
export interface DesktopBridge {
openWidgetWindow: (instance: import('../types/floatingWidget').FloatingWidgetInstance) => Promise<void>;
closeWidgetWindow: (id: string) => Promise<void>;
focusWidgetWindow: (id: string) => Promise<void>;
showNativeNotification: (title: string, body: string) => Promise<void>;
checkForUpdates: () => Promise<{ available: boolean; version?: string; message?: string }>;
getAppVersion: () => Promise<string>;
}
declare global {
interface Window {
__goldenDesktopBridge?: DesktopBridge;
}
}
export function getDesktopBridge(): DesktopBridge | null {
if (!isDesktop()) return null;
return window.__goldenDesktopBridge ?? null;
}
export async function showDesktopNativeNotification(title: string, body: string): Promise<void> {
const bridge = getDesktopBridge();
if (!bridge) return;
try {
await bridge.showNativeNotification(title, body);
} catch {
/* ignore */
}
}
export async function checkDesktopUpdates(): Promise<{ available: boolean; version?: string; message?: string }> {
const bridge = getDesktopBridge();
if (!bridge) return { available: false, message: '데스크톱 앱에서만 사용 가능합니다.' };
return bridge.checkForUpdates();
}
export async function getDesktopAppVersion(): Promise<string | null> {
const bridge = getDesktopBridge();
if (!bridge) return null;
try {
return await bridge.getAppVersion();
} catch {
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
/** 데스크톱 메인↔위젯 창 상태 동기화 (localStorage) */
export const DESKTOP_SYNC_KEY = 'gc_desktop_sync';
export interface DesktopSyncState {
selectedMarket?: string;
selectedStrategyId?: number | null;
theme?: string;
updatedAt: number;
}
export function readDesktopSync(): DesktopSyncState | null {
try {
const raw = localStorage.getItem(DESKTOP_SYNC_KEY);
if (!raw) return null;
return JSON.parse(raw) as DesktopSyncState;
} catch {
return null;
}
}
export function writeDesktopSync(partial: Omit<DesktopSyncState, 'updatedAt'>): void {
const prev = readDesktopSync() ?? { updatedAt: 0 };
const next: DesktopSyncState = {
...prev,
...partial,
updatedAt: Date.now(),
};
try {
localStorage.setItem(DESKTOP_SYNC_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
}
export function subscribeDesktopSync(cb: (state: DesktopSyncState) => void): () => void {
const onStorage = (e: StorageEvent) => {
if (e.key !== DESKTOP_SYNC_KEY) return;
const state = readDesktopSync();
if (state) cb(state);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}
+26
View File
@@ -0,0 +1,26 @@
/** 런타임 플랫폼 — web · desktop(Tauri) · mobile */
export type RuntimePlatform = 'web' | 'desktop' | 'mobile';
export function isDesktop(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
export function isMobileCapacitor(): boolean {
if (typeof window === 'undefined') return false;
try {
const cap = (window as Window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
return cap?.isNativePlatform?.() === true;
} catch {
return false;
}
}
export function getRuntimePlatform(): RuntimePlatform {
if (isDesktop()) return 'desktop';
if (isMobileCapacitor()) return 'mobile';
return 'web';
}
export function isWeb(): boolean {
return getRuntimePlatform() === 'web';
}
+337 -18
View File
@@ -8,7 +8,8 @@
"workspaces": [ "workspaces": [
"packages/shared", "packages/shared",
"app", "app",
"frontend" "frontend",
"desktop"
] ]
}, },
"app": { "app": {
@@ -41,6 +42,38 @@
"vite": "^5.4.0" "vite": "^5.4.0"
} }
}, },
"desktop": {
"name": "@goldenchart/desktop",
"version": "0.1.0",
"dependencies": {
"@goldenchart/shared": "*",
"@stomp/stompjs": "^7.3.0",
"@tanstack/react-virtual": "^3.14.2",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2",
"@tauri-apps/plugin-updater": "^2",
"@xyflow/react": "^12.10.2",
"lightweight-charts": "^5.2.0",
"lightweight-charts-indicators": "^0.4.1",
"oakscriptjs": "^0.2.8",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@types/sockjs-client": "^1.5.4",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.0"
}
},
"frontend": { "frontend": {
"name": "react-trading-chart", "name": "react-trading-chart",
"version": "0.1.0", "version": "0.1.0",
@@ -67,23 +100,6 @@
"vite": "^5.4.0" "vite": "^5.4.0"
} }
}, },
"frontend/node_modules/lightweight-charts-indicators": {
"version": "0.4.1",
"license": "MIT",
"peerDependencies": {
"oakscriptjs": "^0.2.8"
}
},
"frontend/node_modules/oakscriptjs": {
"version": "0.2.8",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"lightweight-charts": "^5.0.0"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -886,6 +902,10 @@
"resolved": "app", "resolved": "app",
"link": true "link": true
}, },
"node_modules/@goldenchart/desktop": {
"resolved": "desktop",
"link": true
},
"node_modules/@goldenchart/shared": { "node_modules/@goldenchart/shared": {
"resolved": "packages/shared", "resolved": "packages/shared",
"link": true "link": true
@@ -1530,6 +1550,284 @@
"url": "https://github.com/sponsors/tannerlinsley" "url": "https://github.com/sponsors/tannerlinsley"
} }
}, },
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-process": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz",
"integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2550,6 +2848,15 @@
"fancy-canvas": "2.1.0" "fancy-canvas": "2.1.0"
} }
}, },
"node_modules/lightweight-charts-indicators": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/lightweight-charts-indicators/-/lightweight-charts-indicators-0.4.2.tgz",
"integrity": "sha512-UITNFEaKYqvRPm+67Mz7nXMZIE+dilqdeQsIcFqwe3I4n0U3BQm95/OKSlWIpBqiqftJff9iq3i4rtOg/p2tLQ==",
"license": "MIT",
"peerDependencies": {
"oakscriptjs": "^0.2.8"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2684,6 +2991,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/oakscriptjs": {
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/oakscriptjs/-/oakscriptjs-0.2.8.tgz",
"integrity": "sha512-SRLR1QpnO3k8MNFzT5ZLEauWeULAcDTJ+0L0JXtshMvfDc5f8ezXVwgQ3D9Lv2g4aiUbJNmK+sNuZ8NKeC1I7Q==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"lightweight-charts": "^5.0.0"
}
},
"node_modules/open": { "node_modules/open": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
+10 -1
View File
@@ -4,10 +4,19 @@
"workspaces": [ "workspaces": [
"packages/shared", "packages/shared",
"app", "app",
"frontend" "frontend",
"desktop"
], ],
"scripts": { "scripts": {
"build:frontend": "npm run build -w react-trading-chart", "build:frontend": "npm run build -w react-trading-chart",
"dev:desktop": "npm run tauri:dev -w @goldenchart/desktop",
"build:desktop": "npm run build -w @goldenchart/desktop && npm run tauri:build -w @goldenchart/desktop",
"build:desktop:mac": "npm run build -w @goldenchart/desktop && npm run tauri:build:mac -w @goldenchart/desktop",
"build:desktop:win": "npm run build -w @goldenchart/desktop && npm run tauri:build:win -w @goldenchart/desktop",
"install:desktop:deps": "./scripts/install-desktop-build-deps.sh",
"setup:desktop:keys": "./scripts/setup-desktop-updater-keys.sh",
"publish:desktop": "./scripts/publish-desktop-update.sh",
"smoke:desktop": "./scripts/smoke-test-desktop.sh",
"dev:app": "npm run dev -w @goldenchart/app", "dev:app": "npm run dev -w @goldenchart/app",
"build:app": "npm run build -w @goldenchart/app", "build:app": "npm run build -w @goldenchart/app",
"cap:sync": "npm run cap:sync -w @goldenchart/app", "cap:sync": "npm run cap:sync -w @goldenchart/app",
+2 -1
View File
@@ -8,7 +8,8 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./api": "./src/api/backendApi.ts", "./api": "./src/api/backendApi.ts",
"./storage": "./src/storage/index.ts" "./storage": "./src/storage/index.ts",
"./platform": "./src/platform.ts"
}, },
"dependencies": { "dependencies": {
"@capacitor/preferences": "^7.0.0" "@capacitor/preferences": "^7.0.0"
+23 -3
View File
@@ -6,13 +6,17 @@
*/ */
import { storageGetSync, storageSetSync } from '../storage/index.js'; import { storageGetSync, storageSetSync } from '../storage/index.js';
import { getRuntimePlatform } from '../platform.js';
/** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */ /** Flow layout stored with strategy (matches frontend StrategyFlowLayoutStore). */
export type StrategyFlowLayoutStore = Record<string, unknown>; export type StrategyFlowLayoutStore = Record<string, unknown>;
/** 배포 APK·exdev 기본 (HTTPS 443 미개방 — 반드시 http) */ /** 배포 APK·exdev (모바일 APK — http) */
export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api'; export const PRODUCTION_API_BASE = 'http://exdev.co.kr/api';
/** Tauri desktop — macOS ATS 호환 (https 필수) */
export const DESKTOP_API_BASE = 'https://exdev.co.kr/api';
function viteEnv(): Record<string, string> | undefined { function viteEnv(): Record<string, string> | undefined {
return typeof import.meta !== 'undefined' return typeof import.meta !== 'undefined'
? (import.meta as ImportMeta & { env?: Record<string, string> }).env ? (import.meta as ImportMeta & { env?: Record<string, string> }).env
@@ -30,13 +34,29 @@ export function normalizeApiBaseUrl(url: string | null | undefined): string | nu
return u; return u;
} }
/** desktop 빌드 — vite define __DESKTOP_CLIENT__ (localhost API 사용 금지) */
declare const __DESKTOP_CLIENT__: boolean | undefined;
function isDesktopClient(): boolean {
if (typeof __DESKTOP_CLIENT__ !== 'undefined' && __DESKTOP_CLIENT__) return true;
return typeof window !== 'undefined' && getRuntimePlatform() === 'desktop';
}
function resolveApiBase(): string { function resolveApiBase(): string {
// Desktop(Tauri): https exdev (macOS ATS — http 차단)
if (isDesktopClient()) {
return DESKTOP_API_BASE;
}
const env = viteEnv(); const env = viteEnv();
const fromStorage = normalizeApiBaseUrl(storageGetSync('gc_api_base_url')); const fromStorage = normalizeApiBaseUrl(storageGetSync('gc_api_base_url'));
const fromEnv = normalizeApiBaseUrl(env?.VITE_API_BASE_URL); const fromEnv = normalizeApiBaseUrl(env?.VITE_API_BASE_URL);
if (fromStorage) return fromStorage; if (fromStorage) return fromStorage;
if (fromEnv) return fromEnv; if (fromEnv) return fromEnv;
if (env?.PROD) return PRODUCTION_API_BASE; const platform = typeof window !== 'undefined' ? getRuntimePlatform() : 'web';
if (platform === 'mobile' || env?.PROD) {
return PRODUCTION_API_BASE;
}
return 'http://localhost:8080/api'; return 'http://localhost:8080/api';
} }
@@ -109,7 +129,7 @@ const DEFAULT_FETCH_TIMEOUT_MS = 45_000;
const LOGIN_FETCH_TIMEOUT_MS = 180_000; const LOGIN_FETCH_TIMEOUT_MS = 180_000;
function wrapNetworkError(e: unknown): Error { function wrapNetworkError(e: unknown): Error {
if (e instanceof TypeError || (e instanceof Error && /failed to fetch/i.test(e.message))) { if (e instanceof TypeError || (e instanceof Error && /failed to fetch|load failed/i.test(e.message))) {
return new Error( return new Error(
`서버에 연결할 수 없습니다 (${API_BASE}). Wi‑Fi·API 주소·앱 재설치 후 설정의 API URL을 확인하세요.`, `서버에 연결할 수 없습니다 (${API_BASE}). Wi‑Fi·API 주소·앱 재설치 후 설정의 API URL을 확인하세요.`,
); );
+1
View File
@@ -1,4 +1,5 @@
export * from './api/backendApi'; export * from './api/backendApi';
export * from './platform';
export { export {
initStorage, initStorage,
storageGet, storageGet,
+27
View File
@@ -0,0 +1,27 @@
/** 런타임 플랫폼 — web · desktop(Tauri) · mobile(Capacitor) */
export type RuntimePlatform = 'web' | 'desktop' | 'mobile';
export function isDesktop(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
export function isMobileCapacitor(): boolean {
if (typeof window === 'undefined') return false;
try {
// Capacitor injects on native; avoid hard dependency
const cap = (window as Window & { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
return cap?.isNativePlatform?.() === true;
} catch {
return false;
}
}
export function getRuntimePlatform(): RuntimePlatform {
if (isDesktop()) return 'desktop';
if (isMobileCapacitor()) return 'mobile';
return 'web';
}
export function isWeb(): boolean {
return getRuntimePlatform() === 'web';
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# GoldenChart Desktop — macOS Jenkins 빌드 (macOS dmg + Windows NSIS cross-build)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# shellcheck disable=SC1091
source "$ROOT/scripts/desktop-dev-path.sh"
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
BUILD_MAC=1
BUILD_WIN=1
BUMP_VERSION="${BUMP_VERSION:-}"
while [[ $# -gt 0 ]]; do
case "$1" in
--mac) BUILD_WIN=0; shift ;;
--win) BUILD_MAC=0; shift ;;
--all) BUILD_MAC=1; BUILD_WIN=1; shift ;;
--bump-version) BUMP_VERSION="$2"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
echo "=== GoldenChart Desktop Build ==="
echo "Root: $ROOT"
echo "Git: $(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo 'n/a')"
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing: $1 — run ./scripts/install-desktop-build-deps.sh"
exit 1
fi
}
need_cmd node
need_cmd npm
need_cmd cargo
need_cmd rustc
if [[ "$BUILD_WIN" -eq 1 ]]; then
need_cmd cargo-xwin
rustup target add x86_64-pc-windows-msvc 2>/dev/null || true
fi
CONF="$ROOT/desktop/src-tauri/tauri.conf.json"
if [[ -n "$BUMP_VERSION" ]]; then
node -e "
const fs=require('fs'); const p=process.argv[1]; const b=process.argv[2];
const c=JSON.parse(fs.readFileSync(p,'utf8'));
const [a,x,y]=c.version.split('.').map(Number);
let nv=c.version;
if(b==='patch') nv=[a,x,y+1].join('.');
else if(b==='minor') nv=[a,x+1,0].join('.');
else if(b==='major') nv=[a+1,0,0].join('.');
else nv=b;
c.version=nv; fs.writeFileSync(p, JSON.stringify(c,null,2)+'\n');
console.log('version', nv);
" "$CONF" "$BUMP_VERSION"
fi
VERSION="$(node -p "require('$CONF').version")"
echo "Version: $VERSION"
if [[ -f "$ROOT/scripts/patch-tauri-updater-pubkey.sh" ]]; then
chmod +x "$ROOT/scripts/patch-tauri-updater-pubkey.sh"
fi
chmod +x "$ROOT/scripts/prepare-tauri-build.sh" 2>/dev/null || true
# shellcheck disable=SC1091
source "$ROOT/scripts/prepare-tauri-build.sh"
npm install
echo "--- Vite build (desktop) ---"
npm run build -w @goldenchart/desktop
DESKTOP_DIR="$ROOT/desktop"
TAURI_TARGET="$DESKTOP_DIR/src-tauri/target"
ARTIFACT_DIR="$ROOT/dist-desktop"
UPDATES_DIR="$ARTIFACT_DIR/updates"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR" "$UPDATES_DIR"
collect_updater_artifacts() {
find "$TAURI_TARGET" \( \
-name '*.app.tar.gz' -o \
-name '*.tar.gz' -path '*/bundle/macos/*' -o \
-name '*.nsis.zip' -o \
-name '*-setup.nsis.zip' \
\) -type f 2>/dev/null | while read -r f; do
cp -f "$f" "$UPDATES_DIR/"
echo " updater bundle: $(basename "$f")"
done
}
if [[ "$BUILD_MAC" -eq 1 ]]; then
echo "--- Tauri build (macOS) ---"
(cd "$DESKTOP_DIR" && npm run tauri:build:mac)
find "$TAURI_TARGET/release/bundle" -name '*.dmg' -exec cp -f {} "$ARTIFACT_DIR/" \; 2>/dev/null || true
collect_updater_artifacts
fi
if [[ "$BUILD_WIN" -eq 1 ]]; then
echo "--- Tauri build (Windows NSIS cross) ---"
(cd "$DESKTOP_DIR" && npm run tauri:build:win)
find "$TAURI_TARGET" -path '*x86_64-pc-windows-msvc*' -name '*-setup.exe' -exec cp -f {} "$ARTIFACT_DIR/" \; 2>/dev/null || true
collect_updater_artifacts
fi
GIT_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo '')"
GIT_SHORT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo '')"
cat > "$ARTIFACT_DIR/build-info.json" <<EOF
{
"version": "$VERSION",
"gitSha": "$GIT_SHA",
"gitShort": "$GIT_SHORT",
"builtAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"buildNumber": "${BUILD_NUMBER:-local}"
}
EOF
echo "--- Artifacts ($ARTIFACT_DIR) ---"
ls -la "$ARTIFACT_DIR" || true
echo "--- Updater bundles ($UPDATES_DIR) ---"
ls -la "$UPDATES_DIR" 2>/dev/null || true
echo "Done."
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# GoldenChart desktop 빌드용 PATH — source scripts/desktop-dev-path.sh
prepend_path() {
case ":${PATH}:" in
*":$1:"*) ;;
*) export PATH="$1:$PATH" ;;
esac
}
prepend_path "$HOME/.cargo/bin"
[[ -x "$HOME/homebrew/bin/brew" ]] && eval "$("$HOME/homebrew/bin/brew" shellenv)"
[[ -x /opt/homebrew/bin/brew ]] && eval "$(/opt/homebrew/bin/brew shellenv)"
for llvm in /opt/homebrew/opt/llvm/bin "$HOME/homebrew/opt/llvm/bin"; do
[[ -d "$llvm" ]] && prepend_path "$llvm"
done
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* dist-desktop/updates → latest.json (Tauri updater v2)
*
* Usage:
* node scripts/generate-desktop-latest-json.mjs \
* --version 0.1.0 \
* --dir dist-desktop/updates \
* --base-url http://exdev.co.kr/desktop/updates \
* --notes "Release notes"
*/
import fs from 'node:fs';
import path from 'node:path';
function arg(name, fallback) {
const i = process.argv.indexOf(name);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const version = arg('--version', '0.1.0');
const dir = path.resolve(arg('--dir', 'dist-desktop/updates'));
const baseUrl = (arg('--base-url', 'http://exdev.co.kr/desktop/updates')).replace(/\/$/, '');
const notes = arg('--notes', `GoldenChart Desktop ${version}`);
if (!fs.existsSync(dir)) {
console.error('Directory not found:', dir);
process.exit(1);
}
const files = fs.readdirSync(dir);
/** @type {Record<string, { url: string; signature: string }>} */
const platforms = {};
function addPlatform(platformKey, bundleName) {
const bundlePath = path.join(dir, bundleName);
if (!fs.existsSync(bundlePath)) return;
const sigPath = `${bundlePath}.sig`;
let signature = '';
if (fs.existsSync(sigPath)) {
signature = fs.readFileSync(sigPath, 'utf8').trim();
} else {
console.warn(`[warn] missing signature: ${sigPath}`);
return;
}
platforms[platformKey] = {
url: `${baseUrl}/${encodeURIComponent(bundleName)}`,
signature,
};
}
for (const f of files) {
if (f.endsWith('.sig')) continue;
const lower = f.toLowerCase();
if (lower.includes('aarch64') && (lower.endsWith('.tar.gz') || lower.endsWith('.app.tar.gz'))) {
addPlatform('darwin-aarch64', f);
} else if ((lower.includes('x64') || lower.includes('x86_64')) && lower.endsWith('.tar.gz')) {
addPlatform('darwin-x86_64', f);
} else if (lower.includes('aarch64') && lower.endsWith('.app.tar.gz')) {
addPlatform('darwin-aarch64', f);
} else if (lower.endsWith('.nsis.zip') || (lower.includes('setup') && lower.endsWith('.zip'))) {
addPlatform('windows-x86_64', f);
} else if (lower.endsWith('.app.tar.gz')) {
// universal mac fallback
if (!platforms['darwin-aarch64']) addPlatform('darwin-aarch64', f);
}
}
const manifest = {
version,
notes,
pub_date: new Date().toISOString(),
platforms,
};
const outPath = path.join(dir, 'latest.json');
fs.writeFileSync(outPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log('Wrote', outPath);
console.log('Platforms:', Object.keys(platforms).join(', ') || '(none — sign updater bundles first)');
if (Object.keys(platforms).length === 0) {
process.exit(2);
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# macOS Jenkins / 개발 Mac — GoldenChart Desktop 빌드 의존성 설치
set -euo pipefail
MAC_ONLY=0
while [[ $# -gt 0 ]]; do
case "$1" in
--mac-only) MAC_ONLY=1; shift ;;
*) echo "Unknown arg: $1 (supported: --mac-only)"; exit 1 ;;
esac
done
log() { echo "[install-desktop-deps] $*"; }
if [[ "$(uname -s)" != "Darwin" ]]; then
log "이 스크립트는 macOS용입니다."
exit 1
fi
# Homebrew / Rust PATH (표준 prefix + 사용자 prefix)
prepend_path() {
case ":${PATH}:" in
*":$1:"*) ;;
*) export PATH="$1:$PATH" ;;
esac
}
for prefix in /opt/homebrew "$HOME/homebrew" /usr/local; do
[[ -x "$prefix/bin/brew" ]] && prepend_path "$prefix/bin" && prepend_path "$prefix/sbin"
[[ -d "$prefix/opt/llvm/bin" ]] && prepend_path "$prefix/opt/llvm/bin"
done
prepend_path "$HOME/.cargo/bin"
install_homebrew_user() {
local prefix="$HOME/homebrew"
if [[ -x "$prefix/bin/brew" ]]; then
return 0
fi
log "Homebrew 사용자 설치 ($prefix) — sudo 불필요"
mkdir -p "$prefix"
curl -fsSL https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C "$prefix"
prepend_path "$prefix/bin"
prepend_path "$prefix/sbin"
}
install_homebrew_system() {
if [[ -x /opt/homebrew/bin/brew ]] || [[ -x /usr/local/bin/brew ]]; then
return 0
fi
if [[ "${NONINTERACTIVE:-}" == "1" ]] && ! sudo -n true 2>/dev/null; then
log "sudo 없음 — $HOME/homebrew 로 설치합니다."
install_homebrew_user
return 0
fi
log "Homebrew 시스템 설치 (/opt/homebrew)..."
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
}
ensure_homebrew() {
if command -v brew >/dev/null 2>&1; then
return 0
fi
install_homebrew_system
if ! command -v brew >/dev/null 2>&1; then
install_homebrew_user
fi
if ! command -v brew >/dev/null 2>&1; then
log "Homebrew 설치 실패. https://brew.sh 수동 설치 후 재실행."
exit 1
fi
# shellcheck disable=SC1091
eval "$(brew shellenv)"
}
ensure_rust() {
if command -v rustc >/dev/null 2>&1; then
log "Rust 이미 설치됨: $(rustc --version)"
return 0
fi
log "Rust 설치 (rustup)..."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
# shellcheck disable=SC1091
source "$HOME/.cargo/env"
}
setup_shell_profile() {
local marker="# GoldenChart desktop build PATH"
local profile="${ZDOTDIR:-$HOME}/.zprofile"
if [[ -f "$profile" ]] && grep -qF "$marker" "$profile" 2>/dev/null; then
log "shell profile 이미 설정됨: $profile"
return 0
fi
log "shell profile에 PATH 추가: $profile"
{
echo ""
echo "$marker"
echo 'export PATH="$HOME/.cargo/bin:$PATH"'
if [[ -x "$HOME/homebrew/bin/brew" ]]; then
echo 'eval "$("$HOME/homebrew/bin/brew" shellenv)"'
elif [[ -x /opt/homebrew/bin/brew ]]; then
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"'
fi
echo 'export PATH="/opt/homebrew/opt/llvm/bin:$HOME/homebrew/opt/llvm/bin:$PATH"'
} >> "$profile"
}
ensure_homebrew
setup_shell_profile
if [[ "$MAC_ONLY" -eq 1 ]]; then
log "macOS 전용 모드 — NSIS/LLVM/cargo-xwin 생략"
else
log "Homebrew 패키지 (NSIS, LLVM)..."
brew install nsis llvm 2>/dev/null || brew upgrade nsis llvm 2>/dev/null || true
fi
ensure_rust
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
if [[ "$MAC_ONLY" -eq 0 ]]; then
log "Windows 크로스 컴파일 타겟..."
rustup target add x86_64-pc-windows-msvc 2>/dev/null || true
if ! command -v cargo-xwin >/dev/null 2>&1; then
log "cargo-xwin 설치 (시간 소요)..."
cargo install cargo-xwin --locked
else
log "cargo-xwin 이미 설치됨"
fi
fi
log "완료. 검증:"
node --version
npm --version
rustc --version
cargo --version
command -v makensis && makensis -VERSION || log "[INFO] makensis — Windows 빌드 시 필요"
command -v cargo-xwin && cargo-xwin --version || log "[INFO] cargo-xwin — Windows 빌드 시 필요"
log "새 터미널을 열거나: source ~/.zprofile"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# @deprecated — scripts/jenkins-desktop-pipeline.sh + scripts/server-install-desktop-jenkins.sh 사용
exec "$(dirname "$0")/jenkins-desktop-pipeline.sh" "$@"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Jenkins goldenChart-Desktop-Pipeline — 전체 빌드·배포 진입점
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}"
export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
for prefix in "$HOME/homebrew" /opt/homebrew; do
[[ -x "$prefix/bin/brew" ]] && eval "$("$prefix/bin/brew" shellenv)"
done
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
log() { echo "[desktop-pipeline $(date '+%H:%M:%S')] $*"; }
log "=== goldenChart Desktop Pipeline #${BUILD_NUMBER:-local} ==="
log "WORK_TREE=$WORK_TREE"
cd "$WORK_TREE"
# Git 동기화 (Jenkins SCM checkout 후에도 file:// bare 와 맞춤)
if [[ -d "${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}" ]]; then
git --git-dir="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}" \
--work-tree="$WORK_TREE" checkout -f main 2>/dev/null || true
fi
chmod +x "$ROOT/scripts/"*.sh 2>/dev/null || true
# 의존성 (최초 1회 — 이미 있으면 빠르게 통과)
if ! command -v cargo-xwin >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; then
log "Desktop build deps 설치..."
"$ROOT/scripts/install-desktop-build-deps.sh"
fi
# updater pubkey (repo에 desktop/updater.pub 있으면 patch만)
if [[ -f "$ROOT/desktop/updater.pub" ]]; then
"$ROOT/scripts/patch-tauri-updater-pubkey.sh"
elif [[ -f "${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" ]]; then
"$ROOT/scripts/setup-desktop-updater-keys.sh"
fi
BUILD_ARGS=(--all)
if [[ -n "${DESKTOP_BUILD_MAC_ONLY:-}" ]]; then BUILD_ARGS=(--mac); fi
if [[ -n "${DESKTOP_BUILD_WIN_ONLY:-}" ]]; then BUILD_ARGS=(--win); fi
if [[ -n "${BUMP_VERSION:-}" ]]; then BUILD_ARGS+=(--bump-version "$BUMP_VERSION"); fi
"$ROOT/scripts/build-desktop.sh" "${BUILD_ARGS[@]}"
WORK_TREE="$WORK_TREE" \
ARTIFACT_DIR="$ROOT/dist-desktop" \
DESKTOP_UPDATE_BASE_URL="${DESKTOP_UPDATE_BASE_URL:-http://exdev.co.kr/desktop/updates}" \
TAURI_SIGNER_KEY="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}" \
DESKTOP_RELEASE_NOTES="${DESKTOP_RELEASE_NOTES:-Build ${BUILD_NUMBER:-local}}" \
"$ROOT/scripts/publish-desktop-update.sh"
# Jenkins artifact archive 경로
if [[ -d "$ROOT/dist-desktop" ]]; then
log "Artifacts ready: $ROOT/dist-desktop"
fi
# 웹 frontend Docker 재배포 (latest.json static 포함) — 선택
if [[ "${DESKTOP_DEPLOY_WEB:-0}" == "1" ]] && [[ -x "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}" ]]; then
log "Trigger web deploy (frontend only)..."
SERVICE_NAME=frontend "${DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}"
fi
log "Pipeline complete."
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUB_PATH="${TAURI_SIGNER_PUB:-$ROOT/desktop/updater.pub}"
CONF="$ROOT/desktop/src-tauri/tauri.conf.json"
if [[ ! -f "$PUB_PATH" ]]; then
echo "[patch-updater-pubkey] skip — no $PUB_PATH (run setup-desktop-updater-keys.sh)"
exit 0
fi
PUBKEY="$(grep -v '^[[:space:]]*#' "$PUB_PATH" | grep -v '^[[:space:]]*$' | tr -d '\n\r' | head -c 500)"
if [[ -z "$PUBKEY" || "$PUBKEY" == "REPLACE_WITH_TAURI_SIGNER_PUBKEY" ]]; then
echo "[patch-updater-pubkey] skip — invalid or placeholder pubkey in $PUB_PATH"
exit 0
fi
CONF="$CONF" PUBKEY="$PUBKEY" node -e "
const fs = require('fs');
const confPath = process.env.CONF;
const pubkey = process.env.PUBKEY;
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.plugins = conf.plugins || {};
conf.plugins.updater = conf.plugins.updater || {};
conf.plugins.updater.pubkey = pubkey;
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
console.log('[patch-updater-pubkey] updated', confPath);
"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# tauri.conf.json — updater artifacts / pubkey (키 있을 때만 활성화)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CONF="$ROOT/desktop/src-tauri/tauri.conf.json"
KEY_PATH="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}"
PUB_PATH="${TAURI_SIGNER_PUB:-$ROOT/desktop/updater.pub}"
enable="false"
if [[ -f "$KEY_PATH" && -f "$PUB_PATH" ]]; then
enable="true"
if [[ -x "$ROOT/scripts/patch-tauri-updater-pubkey.sh" ]]; then
"$ROOT/scripts/patch-tauri-updater-pubkey.sh"
fi
fi
node -e "
const fs = require('fs');
const confPath = process.argv[1];
const enable = process.argv[2] === 'true';
const conf = JSON.parse(fs.readFileSync(confPath, 'utf8'));
conf.bundle = conf.bundle || {};
conf.bundle.createUpdaterArtifacts = enable;
conf.plugins = conf.plugins || {};
conf.plugins.updater = conf.plugins.updater || {};
if (!conf.plugins.updater.endpoints?.length) {
conf.plugins.updater.endpoints = ['https://exdev.co.kr/desktop/updates/latest.json'];
}
if (!conf.plugins.updater.pubkey) {
conf.plugins.updater.pubkey = 'REPLACE_WITH_TAURI_SIGNER_PUBKEY';
}
fs.writeFileSync(confPath, JSON.stringify(conf, null, 2) + '\n');
console.log('[prepare-tauri-build] createUpdaterArtifacts=' + enable);
" "$CONF" "$enable"
if [[ "$enable" == "true" ]]; then
export TAURI_SIGNING_PRIVATE_KEY="$(tr -d '\n\r' < "$KEY_PATH")"
echo "[prepare-tauri-build] updater signing enabled"
else
unset TAURI_SIGNING_PRIVATE_KEY 2>/dev/null || true
echo "[prepare-tauri-build] updater signing skipped (no key)"
fi
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# GoldenChart Desktop — updater 서명 + latest.json + 웹 static 배포
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT/dist-desktop}"
UPDATES_SRC="${UPDATES_SRC:-$ARTIFACT_DIR/updates}"
UPDATES_DIR="${UPDATES_DIR:-${WORK_TREE:-$ROOT}/frontend/public/desktop/updates}"
WEB_STATIC_DIR="${WEB_STATIC_DIR:-$UPDATES_DIR}"
BASE_URL="${DESKTOP_UPDATE_BASE_URL:-http://exdev.co.kr/desktop/updates}"
KEY_PATH="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}"
DESKTOP_DIR="$ROOT/desktop"
CONF="$DESKTOP_DIR/src-tauri/tauri.conf.json"
VERSION="$(node -p "require('$CONF').version")"
NOTES="${DESKTOP_RELEASE_NOTES:-GoldenChart Desktop $VERSION}"
BUILD_INFO="$ARTIFACT_DIR/build-info.json"
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
log() { echo "[publish-desktop] $*"; }
log "=== Publish Desktop v$VERSION ==="
log "Updates src: $UPDATES_SRC"
log "Web static: $WEB_STATIC_DIR"
mkdir -p "$WEB_STATIC_DIR"
# 설치 파일(dist-desktop/*.dmg, *setup.exe) 복사
if [[ -d "$ARTIFACT_DIR" ]]; then
find "$ARTIFACT_DIR" -maxdepth 1 -type f \( -name '*.dmg' -o -name '*setup.exe' \) -exec cp -f {} "$WEB_STATIC_DIR/" \;
fi
PUBLISH_UPDATES="$WEB_STATIC_DIR"
mkdir -p "$PUBLISH_UPDATES"
if [[ -d "$UPDATES_SRC" ]]; then
cp -f "$UPDATES_SRC"/* "$PUBLISH_UPDATES/" 2>/dev/null || true
fi
# updater 번들 서명
if [[ -f "$KEY_PATH" ]]; then
shopt -s nullglob
for bundle in "$PUBLISH_UPDATES"/*.tar.gz "$PUBLISH_UPDATES"/*.nsis.zip "$PUBLISH_UPDATES"/*.zip; do
[[ -f "$bundle" ]] || continue
[[ "$bundle" == *.sig ]] && continue
log "Signing $(basename "$bundle")"
(cd "$DESKTOP_DIR" && npx tauri signer sign -f "$KEY_PATH" "$bundle")
done
shopt -u nullglob
else
log "[WARN] TAURI_SIGNER_KEY 없음 ($KEY_PATH) — updater 서명 생략"
log " ./scripts/setup-desktop-updater-keys.sh 실행 후 Jenkins Credential 등록"
fi
# build-info 복사
if [[ -f "$BUILD_INFO" ]]; then
cp -f "$BUILD_INFO" "$PUBLISH_UPDATES/build-info.json"
fi
# latest.json
set +e
node "$ROOT/scripts/generate-desktop-latest-json.mjs" \
--version "$VERSION" \
--dir "$PUBLISH_UPDATES" \
--base-url "$BASE_URL" \
--notes "$NOTES"
GEN_RC=$?
set -e
if [[ "$GEN_RC" -eq 2 ]]; then
log "[WARN] signed updater bundle 없음 — placeholder latest.json 작성"
cat > "$PUBLISH_UPDATES/latest.json" <<EOF
{
"version": "$VERSION",
"notes": "$NOTES",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {}
}
EOF
elif [[ "$GEN_RC" -ne 0 ]]; then
exit "$GEN_RC"
fi
log "Published:"
ls -la "$PUBLISH_UPDATES"
log "nginx: /desktop/updates/ → frontend/public/desktop/updates (Docker rebuild 시 반영)"
log "Done."
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# exdev Jenkins — goldenChart-Desktop-Pipeline Job 생성
set -euo pipefail
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
JENKINS_HOME="${JENKINS_HOME:-$HOME/.jenkins}"
JENKINS_JOB="goldenChart-Desktop-Pipeline"
GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/goldenChart.git}"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/goldenChart}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
log() { echo "[install-desktop-jenkins] $*"; }
log "Jenkins job 생성: $JENKINS_JOB"
mkdir -p "${JENKINS_HOME}/jobs/${JENKINS_JOB}"
cat > "${JENKINS_HOME}/jobs/${JENKINS_JOB}/config.xml" << JENKINS_XML
<?xml version='1.1' encoding='UTF-8'?>
<project>
<description>GoldenChart Desktop — Tauri macOS dmg + Windows NSIS + updater</description>
<keepDependencies>false</keepDependencies>
<properties/>
<scm class="hudson.plugins.git.GitSCM" plugin="git@5.0.0">
<configVersion>2</configVersion>
<userRemoteConfigs>
<hudson.plugins.git.UserRemoteConfig>
<url>file://${GIT_DIR}</url>
</hudson.plugins.git.UserRemoteConfig>
</userRemoteConfigs>
<branches>
<hudson.plugins.git.BranchSpec>
<name>*/main</name>
</hudson.plugins.git.BranchSpec>
</branches>
<doGenerateSubmoduleConfigurations>false</doGenerateSubmoduleConfigurations>
<submoduleCfg class="empty-list"/>
<extensions/>
</scm>
<canRoam>true</canRoam>
<disabled>false</disabled>
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
<triggers>
<hudson.triggers.SCMTrigger>
<spec>H/30 * * * *</spec>
</hudson.triggers.SCMTrigger>
</triggers>
<concurrentBuild>false</concurrentBuild>
<builders>
<hudson.tasks.Shell>
<command>#!/bin/bash
set -euo pipefail
export PATH="/opt/homebrew/opt/llvm/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:\$PATH"
export WORK_TREE="${WORK_TREE}"
export GIT_DIR="${GIT_DIR}"
export BUILD_NUMBER="\${BUILD_NUMBER}"
export TAURI_SIGNER_KEY="\${TAURI_SIGNER_KEY:-\$HOME/.tauri/goldenchart.key}"
export DESKTOP_DEPLOY_WEB="\${DESKTOP_DEPLOY_WEB:-1}"
"${REPO_ROOT}/scripts/jenkins-desktop-pipeline.sh"
</command>
</hudson.tasks.Shell>
</builders>
<publishers>
<hudson.tasks.ArtifactArchiver>
<artifacts>dist-desktop/**</artifacts>
<allowEmptyArchive>true</allowEmptyArchive>
<onlyIfSuccessful>false</onlyIfSuccessful>
<fingerprint>false</fingerprint>
<defaultExcludes>true</defaultExcludes>
</hudson.tasks.ArtifactArchiver>
</publishers>
<buildWrappers/>
</project>
JENKINS_XML
if curl -sf -o /dev/null "http://127.0.0.1:8090/login" 2>/dev/null; then
curl -sf -X POST "http://127.0.0.1:8090/reload" 2>/dev/null || true
fi
log "완료: http://127.0.0.1:8090/job/${JENKINS_JOB}/"
log ""
log "Jenkins Credentials (Secret file):"
log " ID: tauri-signer-key → \$HOME/.tauri/goldenchart.key"
log ""
log "최초 1회 (서버):"
log " ./scripts/install-desktop-build-deps.sh"
log " ./scripts/setup-desktop-updater-keys.sh"
log " git add desktop/updater.pub && git commit"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Tauri updater minisign 키쌍 생성 — 1회 (Jenkins secret 또는 서버 로컬)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
KEY_PATH="${TAURI_SIGNER_KEY:-$HOME/.tauri/goldenchart.key}"
PUB_PATH="${TAURI_SIGNER_PUB:-$ROOT/desktop/updater.pub}"
DESKTOP_DIR="$ROOT/desktop"
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bin:/bin}"
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
mkdir -p "$(dirname "$KEY_PATH")"
if [[ -f "$KEY_PATH" ]]; then
echo "기존 private key: $KEY_PATH"
else
echo "새 updater 키 생성: $KEY_PATH"
GEN_OUT="$(cd "$DESKTOP_DIR" && npx tauri signer generate -w "$KEY_PATH" -f 2>&1)" || true
echo "$GEN_OUT"
if [[ ! -f "$KEY_PATH" ]]; then
echo "키 생성 실패. Rust/tauri-cli 확인 후 재시도."
exit 1
fi
fi
if [[ ! -f "$PUB_PATH" ]]; then
echo "Public key 추출 시도..."
if echo "$GEN_OUT" | grep -qi 'public key'; then
echo "$GEN_OUT" | grep -i 'public key' | tail -1 | sed -E 's/^[^:]*:[[:space:]]*//' > "$PUB_PATH"
fi
if [[ ! -s "$PUB_PATH" ]] && [[ -f "${KEY_PATH}.pub" ]]; then
cp "${KEY_PATH}.pub" "$PUB_PATH"
fi
if [[ ! -s "$PUB_PATH" ]]; then
echo "desktop/updater.pub 를 수동 생성하세요 (tauri signer generate 출력의 public key)."
exit 1
fi
fi
chmod +x "$ROOT/scripts/patch-tauri-updater-pubkey.sh"
"$ROOT/scripts/patch-tauri-updater-pubkey.sh"
echo ""
echo "=== updater 키 설정 완료 ==="
echo " Private: $KEY_PATH (git/Jenkins secret — 커밋 금지)"
echo " Public: $PUB_PATH"
echo "Jenkins: TAURI_SIGNER_KEY=$KEY_PATH"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# GoldenChart Desktop — smoke test (CI·로컬)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BASE_URL="${DESKTOP_UPDATE_BASE_URL:-http://exdev.co.kr/desktop/updates}"
FAIL=0
ok() { echo "[OK] $*"; }
fail() { echo "[FAIL] $*"; FAIL=1; }
echo "=== Desktop Smoke Test ==="
# 1) Vite 번들
if npm run build -w @goldenchart/desktop >/dev/null 2>&1; then
ok "desktop vite build"
else
fail "desktop vite build"
fi
# 2) updater pubkey
if [[ -f "$ROOT/desktop/updater.pub" ]]; then
ok "desktop/updater.pub exists"
else
echo "[WARN] desktop/updater.pub 없음 — setup-desktop-updater-keys.sh 필요"
fi
# 3) tauri.conf pubkey not placeholder
PUB="$(node -p "require('$ROOT/desktop/src-tauri/tauri.conf.json').plugins?.updater?.pubkey || ''")"
if [[ -n "$PUB" && "$PUB" != "REPLACE_WITH_TAURI_SIGNER_PUBKEY" ]]; then
ok "tauri.conf updater pubkey configured"
else
echo "[WARN] tauri.conf pubkey 미설정"
fi
# 4) latest.json (local or remote)
LOCAL_LATEST="$ROOT/frontend/public/desktop/updates/latest.json"
if [[ -f "$LOCAL_LATEST" ]]; then
ok "local latest.json"
node -e "JSON.parse(require('fs').readFileSync('$LOCAL_LATEST','utf8'))" && ok "latest.json valid JSON"
fi
if curl -sf "${BASE_URL}/latest.json" >/dev/null 2>&1; then
ok "remote latest.json ${BASE_URL}/latest.json"
else
echo "[WARN] remote latest.json unreachable (배포 전 정상)"
fi
# 5) 필수 스크립트
for s in build-desktop.sh publish-desktop-update.sh jenkins-desktop-pipeline.sh; do
if [[ -x "$ROOT/scripts/$s" ]]; then ok "scripts/$s"; else fail "scripts/$s missing"; fi
done
if [[ "$FAIL" -eq 0 ]]; then
echo "=== Smoke test passed ==="
exit 0
fi
echo "=== Smoke test failed ==="
exit 1