juc500 정상동작

This commit is contained in:
Macbook
2026-07-21 21:04:25 +09:00
parent a423db0556
commit bb9874b931
2917 changed files with 708025 additions and 668 deletions
@@ -0,0 +1,12 @@
폐쇄망 Windows 11 — 오프라인 업데이트
====================================
1. 이 폴더 전체를 USB로 Windows PC에 복사
2. JUC500 GUI 종료
3. apply_offline_update.bat 실행 (기본 대상 C:\dev\juc500)
4. C:\dev\juc500 에서 start.bat 실행
5. 상단 빌드 태그가 BUILD.txt 의 XFER_BUILD 와 같은지 확인
6. SmartKMLink(0ea0:7301) / JUC500: vendor\tools\zadig.exe
→ Options → List All Devices → Interface 5 (MI_05) → WinUSB
인터넷·pip 설치 불필요. vendor\wheels · libusb · (선택) zadig 포함.
@@ -0,0 +1,16 @@
JUC500 offline update
XFER_BUILD=2026-07-20-mac-if0-bcast
created=20260720-165112
SmartKMLink VID:PID=0ea0:7301 (IF5 same as JUC500)
Windows closed-network apply:
1) Copy this folder (or unzip) to the peer PC via USB stick
2) Close JUC500 GUI on Windows
3) Double-click apply_offline_update.bat
(default DEST=C:\dev\juc500 — or: apply_offline_update.bat D:\path\juc500)
4) cd C:\dev\juc500 && start.bat
5) GUI version line must show: 2026-07-20-mac-if0-bcast
6) SmartKMLink / JUC500 IF5: vendor\tools\zadig.exe → MI_05 → WinUSB
Full fresh install (includes embeddable Python):
./scripts/make_windows_bundle.sh
@@ -0,0 +1,182 @@
# JUC500 / JUC700 File Transfer (Apple Silicon ↔ Windows 11)
j5create **JUC500** / **JUC700** USB 3.0 Wormhole 케이블로 **Apple Silicon Mac**과 **Windows 11** 사이 파일을 직접 전송하는 오픈 구현입니다.
## 지원 케이블
| 모델 | USB ID | 비고 |
|------|--------|------|
| **JUC500** | `0711:7500` | Mac ↔ Windows, Smart Data Link |
| **JUC700** | `0711:7000`–`700F` | Windows DSS(화면 공유) 포함, **데이터 링크는 동일 IF5** |
케이블 종류는 **USB로 자동 감지**됩니다. GUI 상단에 `JUC500` 또는 `JUC700`이 표시됩니다. 별도 선택 설정은 필요 없습니다.
> JUC700의 화면 미러/확장(DSS) 기능은 공식 Wormhole 앱 전용이며, 본 프로그램은 **파일 전송·입력공유**만 IF5 bulk 프로토콜로 제공합니다.
## 왜 공식 앱이 Silicon Mac에서 안 되나
실측/분석 결과:
| 항목 | 내용 |
|------|------|
| USB ID | `0711:7500` (JUC500) · `0711:700x` (JUC700) / Product: **Smart Data Link** |
| 가상 CD | `WORMHOLE` (FAT12, 공식 설치본 포함) |
| 번들/공식 Mac 앱 (2024, v1.0.1463.47) | **x86_64 전용**, Apple Silicon 네이티브 **arm64 없음** |
| USB 스택 | deprecated `IOUSBDevice` API (`kIOUSBDeviceInterfaceID500`) |
| 핵심 라이브러리 | KaiJet / OTi `OTiTransfer.framework` — bounding·alive·XML UPipe |
Intel Mac에서는 Rosetta 없이(구버전) 또는 Rosetta로 동작할 수 있으나, Apple Silicon + 최신 macOS에서는 공식 Wormhole 경로가 깨집니다. 이 프로젝트는 **libusb로 Vendor/CDC 인터페이스를 직접 열고**, 자체 프레임 프로토콜로 파일을 보냅니다.
공식 Windows Wormhole과 호환되지 않습니다. **양쪽 모두 이 프로그램을 실행**해야 합니다.
## 장치 인터페이스 (요약)
```
IF0 CDC Comm INT 0x81
IF1 CDC Data BULK 0x02 / 0x83
IF2 Mass Storage WORMHOLE CD (OS 점유 — 건드리지 않음)
IF3 HID mouse (KM — OS 점유)
IF4 HID keyboard (KM — OS 점유)
IF5 Vendor BULK 0x08/0x89 (데이터) + 0x0A/0x8B (keepalive)
```
## 요구 사항
### macOS (Apple Silicon)
- Python 3.10+
- libusb: `brew install libusb`
- (선택) 공식 Wormhole 앱이 떠 있으면 종료
```bash
cd /path/to/juc500
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python -m juc500_xfer info
```
### Windows 11
**폐쇄망/오프라인:** `vendor/` 에 Python embeddable·wheel·libusb 가 포함되어 있습니다.
복사 후 `scripts\setup_windows.bat` → `.\start.bat` (자세한 절차: [`docs/OFFLINE-WINDOWS.md`](docs/OFFLINE-WINDOWS.md)).
1. (온라인 PC면) Python 3.10+ 선택 설치 — 없어도 `vendor\python-windows` 사용
2. 이 저장소 `vendor/libusb-1.0.dll` + `vendor/wheels` 사용 (PyPI 불필요)
3. **Zadig**로 `Smart Data Link` 복합 장치의
- **Interface 5** (Vendor Specific)
- **Interface 1** (CDC Data, 권장)
에 **WinUSB** 드라이버 설치
4. 공식 j5create Wormhole / 자동실행 소프트웨어는 **종료·제거** (같은 인터페이스를 점유함)
```bat
cd juc500
scripts\setup_windows.bat
.\start.bat
```
**장치가 안 보이면:**
```powershell
# libusb DLL 복사 (필수)
copy vendor\libusb-1.0.dll .venv\Scripts\libusb-1.0.dll
python -m juc500_xfer doctor
```
Zadig로 **Interface 5 (MI_05)** 에 WinUSB 설치 여부를 확인하세요.
케이블 가상 CD의 공식 **Wormhole**가 IF5를 점유하면 Access denied가 납니다.
GUI **Wormhole 종료** 버튼 또는 `python -m juc500_xfer kill-wormhole` 로 종료할 수 있습니다 (연결·실행 시에도 자동 종료).
## 사용법
### GUI (권장)
**macOS**
```bash
# 최초 1회
./scripts/setup_macos.sh
# 이후
./start.sh
```
**Windows 10/11** (Git Bash)
```bash
# 최초 1회 (PowerShell/CMD)
scripts\setup_windows.bat
# 이후 (Git Bash)
./start.sh
```
인자를 넘기면 CLI로 동작합니다: `./start.sh doctor`, `./start.sh kill-wormhole` 등.
양쪽 PC에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
**Windows (수신 예)**
```bash
./start.sh recv --dest "$USERPROFILE/Downloads" -y
```
**Mac (송신 예)**
```bash
./start.sh send ~/Desktop/archive.zip
```
## Windows 설치파일
산출물:
| 파일 | 용도 |
|------|------|
| `JUC500-Setup-win64.exe` | Inno Setup 설치 프로그램 |
| `JUC500-portable.exe` | 단일 실행 포터블 EXE |
| `JUC500-win64-portable.zip` | 폴더형 포터블 (+ `setup.bat`) |
**Mac(Apple Silicon)에서 빌드:**
```bash
./build-installer-windows.sh --ci
# → dist-windows/ 에 위 파일 다운로드 (GitHub Actions)
```
**Windows PC에서 빌드:**
```bat
build-installer-windows.bat
```
(Inno Setup 6 필요 시 Setup.exe 생성. 없어도 portable exe/zip 은 생성됩니다.)
사용 전 Win11에서 Zadig WinUSB 설정이 필요합니다 (`docs/WINDOWS.md`).
## GUI
fileShare와 동일한 **듀얼 패널(이 PC | 전송 | 상대 PC)** 웹 UI입니다.
```bash
python -m juc500_xfer gui
# → http://127.0.0.1:8765/
```
양쪽 PC에서 GUI를 실행한 뒤 **연결**을 누르세요.
**입력공유(가장자리 제어권):** 양쪽 모두 동일 소스 + `pynput` 설치 후 「입력공유 켜기」.
상대 PC를 오른쪽에 둔다고 가정합니다. 마우스 오른쪽 끝 → 상대 제어, 왼쪽 끝(또는 Ctrl+Shift+←) → 이 PC 복귀.
macOS는 **손쉬운 사용** 권한이 필요합니다.
Windows 설치본은 `JUC500.exe` / `JUC500-portable.exe` 더블클릭으로 동일 GUI가 열립니다.
## 프로토콜
Vendor BULK(`0x08`/`0x89`) 위에 길이·CRC 프레임(`J5FX` 매직)을 올리고 `HELLO` 핸드셰이크로 연결을 만듭니다. 보조 파이프(`0x0A`/`0x8B`)로 keepalive를내어 링크를 유지합니다. 파일은 SHA-256으로 무결성을 검증합니다.
공식 OTi XML / bounding 프로토콜은 재구현하지 않았습니다.
## 분석 메모
자세한 USB·바이너리 분석은 [`docs/ANALYSIS.md`](docs/ANALYSIS.md) 를 참고하세요.
@@ -0,0 +1,78 @@
@echo off
REM Offline overlay update for closed-network Windows 11.
REM Run from the extracted update folder (or pass DEST as arg 1).
setlocal EnableExtensions
set "SRC=%~dp0"
if "%SRC:~-1%"=="\" set "SRC=%SRC:~0,-1%"
REM If this bat lives in scripts\, SRC is package root when called as scripts\apply...
if exist "%SRC%\juc500_xfer\usb_link.py" goto :have_src
if exist "%SRC%\..\juc500_xfer\usb_link.py" (
set "SRC=%SRC%\.."
goto :have_src
)
echo [!] juc500_xfer not found next to this script.
echo Extract the offline update zip first, then run this bat from that folder.
exit /b 1
:have_src
set "DEST=%~1"
if "%DEST%"=="" set "DEST=C:\dev\juc500"
echo === JUC500 offline update ===
echo SRC = %SRC%
echo DEST= %DEST%
if not exist "%DEST%" (
echo [!] DEST missing: %DEST%
echo Create it or pass path: apply_offline_update.bat D:\juc500
exit /b 1
)
echo.
echo Copying runtime sources...
robocopy "%SRC%\juc500_xfer" "%DEST%\juc500_xfer" /E /XD __pycache__ .git /NFL /NDL /NJH /NJS /nc /ns /np
if errorlevel 8 exit /b 1
if exist "%SRC%\scripts" (
robocopy "%SRC%\scripts" "%DEST%\scripts" /E /XD __pycache__ /NFL /NDL /NJH /NJS /nc /ns /np
)
for %%F in (start.bat start.sh start_win.sh start_both.sh juc500_app.py requirements.txt README.md) do (
if exist "%SRC%\%%F" copy /Y "%SRC%\%%F" "%DEST%\%%F" >nul
)
if exist "%SRC%\vendor\libusb-1.0.dll" (
if not exist "%DEST%\vendor" mkdir "%DEST%\vendor"
copy /Y "%SRC%\vendor\libusb-1.0.dll" "%DEST%\vendor\libusb-1.0.dll" >nul
if exist "%DEST%\.venv\Scripts" copy /Y "%SRC%\vendor\libusb-1.0.dll" "%DEST%\.venv\Scripts\libusb-1.0.dll" >nul
)
if exist "%SRC%\vendor\wheels" (
if not exist "%DEST%\vendor\wheels" mkdir "%DEST%\vendor\wheels"
robocopy "%SRC%\vendor\wheels" "%DEST%\vendor\wheels" *.whl /NFL /NDL /NJH /NJS /nc /ns /np
)
if exist "%SRC%\vendor\tools\zadig.exe" (
if not exist "%DEST%\vendor\tools" mkdir "%DEST%\vendor\tools"
copy /Y "%SRC%\vendor\tools\zadig.exe" "%DEST%\vendor\tools\zadig.exe" >nul
)
if exist "%SRC%\docs" (
if not exist "%DEST%\docs" mkdir "%DEST%\docs"
robocopy "%SRC%\docs" "%DEST%\docs" /E /NFL /NDL /NJH /NJS /nc /ns /np
)
echo.
echo Verifying build tag...
if exist "%DEST%\.venv\Scripts\python.exe" (
"%DEST%\.venv\Scripts\python.exe" -c "from juc500_xfer.usb_link import XFER_BUILD; print('XFER_BUILD=', XFER_BUILD)"
) else (
echo [.venv missing — run start.bat once to bootstrap from vendor]
)
echo.
echo Done. Close JUC500 GUI, then:
echo cd /d %DEST%
echo start.bat
echo GUI should show build tag smartkmlink (or newer).
echo SmartKMLink: Zadig MI_05 WinUSB — vendor\tools\zadig.exe
endlocal
exit /b 0
@@ -0,0 +1,81 @@
# 폐쇄망 Windows 11 — 오프라인 빌드/실행
이 저장소의 `vendor/` 에는 **인터넷 없이** 설치·실행할 수 있는 파일이 포함되어 있습니다.
## 포함 내용
- `vendor/wheels/*.whl` — pyusb, pynput, setuptools, pip, virtualenv 등
- `vendor/libusb-1.0.dll` — libusb x64
- `vendor/python-windows/` — Python 3.11.9 embeddable (시스템 Python 불필요)
- `vendor/get-pip.py` — 오프라인 pip 설치
- `vendor/tools/zadig.exe` — WinUSB 바인딩 (폐쇄망용, 오프라인)
## 지원 케이블 (앱 인식)
| 모델 | USB ID | 비고 |
|------|--------|------|
| JUC500 | `0711:7500` | IF5 |
| JUC700 | `0711:700x` | Mac IF0 / Win IF5·IF0 |
| **SmartKMLink** | `0ea0:7301` | JUC500과 동일 IF5 |
## 대상 PC에서 **신규** 설치
1. 소스 트리 전체를 USB 등으로 복사 (특히 `vendor/` 누락 금지)
- Mac에서 전체 zip: `./scripts/make_windows_bundle.sh` (`.venv` 제외, `vendor/` 포함)
2. Git Bash (권장):
```bash
cd /c/dev/juc500
./start_win.sh
```
첫 실행 시 `vendor/` 로 `.venv`·`libusb` 가 **자동 생성**됩니다.
또는 CMD:
```bat
cd c:\dev\juc500
scripts\setup_windows.bat
.\start.bat
```
## 이미 설치된 PC에 **소스 업데이트만** (폐쇄망)
Mac에서:
```bash
./scripts/make_offline_windows_update.sh
```
생성물:
- `dist-offline/juc500-offline-update-*.zip`
- 바탕화면 동일 zip
- `vendor/tools/zadig.exe`, `vendor/wheels`, `juc500_xfer/*`, `apply_offline_update.bat` 포함
Windows에서:
1. zip을 USB로 복사·압축 해제
2. JUC500 GUI 종료
3. `apply_offline_update.bat` 실행 (기본 `C:\dev\juc500`)
4. `start.bat` 재실행 → 상단 빌드 태그가 `BUILD.txt`의 `XFER_BUILD`와 일치하는지 확인
인터넷·PyPI 불필요.
## USB / WinUSB
장치 Interface 5 는 **Zadig → WinUSB** 가 필요합니다.
```
vendor\tools\zadig.exe
```
Options → List All Devices → `SmartKMLink` 또는 `Smart Data Link` → **Interface 5 (MI_05)** → WinUSB.
## 온라인 PC에서 vendor 갱신
```bat
python -m pip download -d vendor\wheels ^
pyusb==1.2.1 pynput==1.8.2 six==1.17.0 ^
"setuptools>=68" wheel packaging pip build virtualenv
```
@@ -0,0 +1,54 @@
# Windows 11 — Access denied (Interface 5) 해결
연결 시 아래 메시지가 나오면:
```
USB interface 5 점유 실패: [Errno 13] Access denied (insufficient permissions)
```
케이블은 보이지만, **Interface 5를 쓸 권한이 없다**는 뜻입니다.
(공식 Wormhole 드라이버가 Interface 5를 점유 중일 때가 가장 흔합니다.)
## 해결 순서
### 1. 공식 앱 종료/제거
이 앱은 실행·연결 시 Wormhole를 **자동 종료**합니다. 그래도 Access denied면:
- GUI 상단 **Wormhole 종료** 버튼, 또는 `python -m juc500_xfer kill-wormhole`
- 작업 관리자에서 `Wormhole` / `SmartDataLink` / `j5create` 수동 종료
- 설정 → 앱에서 j5create Wormhole 제거 (가능하면)
### 2. Zadig로 Interface 5만 WinUSB 변경
1. [Zadig](https://zadig.akeo.ie/) 를 **관리자 권한**으로 실행
2. **Options → List All Devices**
3. 드롭다운에서 다음 중 하나를 고릅니다.
- `Smart Data Link (Interface 5)`
- 또는 `USB ID` 가 `0711:7500` 이고 이름/인스턴스에 **MI_05** 포함
4. 오른쪽 Driver 를 **WinUSB** 로 선택
5. **Replace Driver**
6. USB 케이블을 뽑았다가 Windows 쪽에 다시 연결
> Interface 2(대용량 저장소 CD), 3·4(HID) 는 바꾸지 마세요.
### 3. 장치 관리자 확인
장치 관리자 → 보기 → 디바이스(연결 기준)
- `0711` / `Smart Data Link` 하위 **MI_05** 항목의 드라이버가 **WinUSB** 인지 확인
- `j5create` / `Wormhole` / `OTi` 드라이버면 → 드라이버 제거 후 Zadig 다시
### 4. 재실행
```powershell
python -m juc500_xfer doctor
python -m juc500_xfer gui
```
그다음 **연결**.
## 참고
- Access denied 는 “관리자 미실행”보다 **잘못된 USB 드라이버** 문제인 경우가 훨씬 많습니다.
- libusb DLL(`vendor\libusb-1.0.dll` → `.venv\Scripts\`)도 필요합니다.
@@ -0,0 +1,114 @@
# Windows 11 설정
## 연결 시 Access denied (Interface 5)
```
USB interface 5 점유 실패: [Errno 13] Access denied
```
→ 케이블은 보이지만 Interface 5가 WinUSB가 아닙니다.
자세한 단계: [`WINDOWS-ACCESS-DENIED.md`](WINDOWS-ACCESS-DENIED.md)
요약: GUI **Wormhole 종료** → Zadig → **List All Devices** → **Smart Data Link (Interface 5) / MI_05** → **WinUSB** → Replace Driver → 케이블 재연결.
## 소스에서 실행 (오프라인 wheel 포함)
저장소의 `vendor/wheels/pyusb-1.2.1-py3-none-any.whl` 을 사용합니다. PyPI 접속이 없어도 됩니다.
```powershell
cd juc500
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --no-index --find-links=vendor\wheels -r requirements.txt
pip install -e .
python -m juc500_xfer gui
```
또는 `scripts\setup_windows.bat`
설치 후 실행 (Git Bash):
```bash
./start.sh
```
CMD/탐색기에서는 `start.bat` 을 사용해도 됩니다.
## 설치파일 사용 (권장)
1. `JUC500-Setup-win64.exe` 실행해 설치
또는 `JUC500-portable.exe` / zip 포터블 사용
2. 아래 **Zadig WinUSB** 설정
3. `JUC500` 실행 → 브라우저 GUI → **연결**
Mac에서 빌드 산출물 받기:
```bash
./build-installer-windows.sh --ci
```
## 1. Zadig로 WinUSB 바인딩
1. [Zadig](https://zadig.akeo.ie/) 실행 (관리자)
2. Options → **List All Devices**
3. `Smart Data Link` / `SmartKMLink` 선택
4. 하단에서 **Interface 5** 선택 → Driver: **WinUSB** → Replace Driver
5. 마찬가지로 **Interface 1** (CDC Data) 권장
6. Interface 2(Mass Storage), 3·4(HID) 는 그대로 두세요.
공식 Wormhole가 설치돼 있으면 GUI의 **Wormhole 종료** 또는 `python -m juc500_xfer kill-wormhole` 후 Zadig를 실행하세요.
### SmartKMLink가 목록에 하나만 보이고 MI_05가 없을 때
OEM 드라이버가 **복합 장치 전체**를 한 덩어리로 붙잡고 있으면 Zadig에 `MI_00`…`MI_05` 하위 항목이 안 나옵니다. 아래 순서로 인터페이스를 분리하세요.
**A. Zadig 옵션 확인**
1. 관리자 실행
2. Options → **List All Devices** 체크
3. Options → **Ignore Hubs or Composite Parents** 체크 해제 후 목록을 다시 연다
4. 같은 이름이라도 USB ID가 `0EA0:7301` / `0EA0:7301 (MI_05)` 처럼 다른 항목이 있는지 확인
5. 장치 선택 후 창 하단 **Interface** 숫자(0~5)를 **5**로 바꿀 수 있으면 → WinUSB → Replace Driver
**B. 장치 관리자로 OEM 드라이버 제거 (권장, MI_xx 분리)**
1. JUC500 / Wormhole 종료
2. `devmgmt.msc` → 보기 → **장치(연결)별** / **숨김 장치 표시**
3. **범용 직렬 버스 장치** · **휴대용 장치** · **디스크 드라이브** 등에서
`SmartKMLink` / `WORMHOLE` / `j5create` / `0EA0` 관련 항목을 모두 찾는다
4. 각 항목 → 제거 → **이 장치의 드라이버 소프트웨어 삭제** 체크
5. 케이블 분리 → 5초 대기 → 다시 연결
6. Windows가 **USB 복합 장치(usbccgp)** + 자식 `MI_00`…`MI_05` 로 다시 잡히면
Zadig List All Devices 에 Interface/MI_05 가 나타난다
7. **MI_05 (Interface 5)** 만 WinUSB 설치 (저장장치·HID MI는 건드리지 않음)
**C. 그래도 MI_05가 없을 때 (최후 수단)**
복합 부모(`USB Composite Device` / usbccgp, USB ID `0EA0:7301` 에 MI 없음)에
WinUSB를 씌우면 libusb로 IF5에 접근 가능해질 수 있다.
단, 같은 케이블의 CD롬/HID/가상 시리얼이 Windows에서 안 보일 수 있다.
파일 전송만 필요하면 이 방법으로 진행한 뒤 `start.bat` 로 연결을 확인한다.
복구: 장치 관리자에서 해당 WinUSB 장치 제거(+드라이버 삭제) 후 케이블 재연결.
## 2. libusb DLL
[libusb releases](https://github.com/libusb/libusb/releases) 에서 MS64/dll을 받아:
```
juc500/vendor/libusb-1.0.dll
```
에 두거나 PATH에 추가합니다.
## 3. 실행
```powershell
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m juc500_xfer info
python -m juc500_xfer recv --dest $env:USERPROFILE\Downloads -y
```
Mac 쪽에서 `send` 하면 전송이 시작됩니다.
@@ -0,0 +1,43 @@
"""PyInstaller / Windows 엔트리 — 인자 없으면 GUI, 있으면 CLI."""
from __future__ import annotations
import os
import sys
def main() -> int:
argv = sys.argv[1:]
backend_only = "--backend" in argv
if backend_only:
argv = [a for a in argv if a != "--backend"]
# Tauri shell loads http://127.0.0.1:8765 — no external browser
if backend_only or os.environ.get("JUC500_TAURI", "").strip().lower() in (
"1",
"true",
"yes",
):
from juc500_xfer.gui_server import run_gui
run_gui(open_browser=False)
return 0
# 더블클릭 / 바로가기: GUI (legacy — browser or Tauri)
if not argv:
if os.environ.get("JUC500_USE_TAURI", "").strip().lower() in ("1", "true", "yes"):
# Tauri launcher should spawn us with --backend; fallback to HTTP-only.
from juc500_xfer.gui_server import run_gui
run_gui(open_browser=False)
return 0
from juc500_xfer.gui_server import run_gui
run_gui(open_browser=True)
return 0
# 하위 호환: 첫 인자가 옵션이 아니면 CLI
from juc500_xfer.cli import main as cli_main
return cli_main(argv)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,3 @@
"""JUC500 cross-platform file transfer for Apple Silicon Mac ↔ Windows 11."""
__version__ = "0.1.0"
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,216 @@
"""Command-line interface for JUC500 file transfer."""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
from . import __version__
from .protocol import Session
from .usb_link import list_devices, Juc500Link, doctor
from .wormhole_kill import kill_wormhole_processes, wormhole_status
def _progress(prefix: str):
last = [0.0]
def cb(done: int, total: int) -> None:
now = time.monotonic()
if now - last[0] < 0.1 and done < total:
return
last[0] = now
pct = (done / total * 100) if total else 100.0
mb = done / (1024 * 1024)
tot = total / (1024 * 1024)
print(f"\r{prefix} {mb:.2f}/{tot:.2f} MiB ({pct:5.1f}%)", end="", flush=True)
if done >= total:
print()
return cb
def cmd_info(_args: argparse.Namespace) -> int:
devices = list_devices()
if not devices:
print("연결된 JUC500 장치가 없습니다. 진단: python -m juc500_xfer doctor")
return 1
for d in devices:
print(
f"{d.vid:04x}:{d.pid:04x} bus={d.bus} addr={d.address} "
f"serial={d.serial!r} product={d.product!r}"
)
return 0
def cmd_doctor(_args: argparse.Namespace) -> int:
rep = doctor()
print(f"libusb: {rep.libusb_path or '(없음)'}")
if rep.libusb_error:
print(f"libusb 오류: {rep.libusb_error}")
print(f"USB 장치 수(libusb): {rep.usb_device_count}")
print(f"JUC500 후보: {len(rep.juc500)}")
for j in rep.juc500:
print(f" - {j}")
if rep.sample_devices:
print("USB 샘플:")
for line in rep.sample_devices:
print(f" {line}")
if rep.pnp_vid0711:
print("Windows PnP VID_0711:")
for row in rep.pnp_vid0711:
print(f" - {row}")
if rep.ioreg_vid0711:
print("macOS IORegistry VID_0711/0ea0:")
for row in rep.ioreg_vid0711:
print(f" - {row}")
wh = wormhole_status()
print(f"Wormhole 프로세스: {wh['count']} — {wh['message']}")
for p in wh.get("processes") or []:
print(f" - {p.get('name')} pid={p.get('pid')}")
print("안내:")
for h in rep.hints:
print(f" * {h}")
return 0 if rep.ok else 1
def cmd_kill_wormhole(_args: argparse.Namespace) -> int:
result = kill_wormhole_processes(force=True)
print(result.get("message") or "")
if result.get("killed"):
for p in result["killed"]:
print(f" 종료: {p.get('name')} pid={p.get('pid')}")
if result.get("remaining"):
for p in result["remaining"]:
print(f" 잔여: {p.get('name')} pid={p.get('pid')}")
return 1
return 0
def cmd_probe(args: argparse.Namespace) -> int:
with Juc500Link(serial=args.serial) as link:
print(f"열림: serial={link.serial!r}")
link.drain(0.3)
payload = b"JUC500-PROBE-" + os.urandom(8)
n = link.write_data(payload)
print(f"DATA OUT 기록: {n} bytes")
# If Windows peer echoes or our own framed app is listening, we may see traffic
for i in range(10):
data = link.read_data(timeout_ms=300)
if data:
print(f"DATA IN[{i}]: {data[:64]!r} ({len(data)} bytes)")
ctrl = link.read_ctrl(timeout_ms=50)
if ctrl:
print(f"CTRL IN[{i}]: {ctrl[:64]!r}")
print("probe 완료 (상대 앱이 없으면 IN 데이터는 없을 수 있음)")
return 0
def cmd_send(args: argparse.Namespace) -> int:
path = Path(args.file)
with Juc500Link(serial=args.serial) as link:
session = Session(link, peer_name=args.name, on_log=print)
session.start()
try:
peer = session.wait_peer(timeout=args.timeout)
print(f"연결됨: {peer}")
session.send_file(path, progress=_progress("전송"))
finally:
session.stop()
return 0
def cmd_recv(args: argparse.Namespace) -> int:
dest = Path(args.dest)
with Juc500Link(serial=args.serial) as link:
session = Session(link, peer_name=args.name, on_log=print)
session.start()
try:
peer = session.wait_peer(timeout=args.timeout)
print(f"연결됨: {peer}")
def allow(name: str, size: int) -> bool:
if args.yes:
print(f"수신 수락: {name} ({size} bytes)")
return True
ans = input(f"수신할까요? {name} ({size} bytes) [y/N] ").strip().lower()
return ans in ("y", "yes", "ㅛ")
session.receive_file(dest, allow=allow, progress=_progress("수신"), timeout=args.recv_timeout)
finally:
session.stop()
return 0
def cmd_gui(args: argparse.Namespace) -> int:
from .gui_server import run_gui
run_gui(open_browser=not args.no_browser, port=args.port)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="juc500",
description="Apple Silicon Mac ↔ Windows 11 JUC500(Smart Data Link) 파일 전송",
)
p.add_argument("--version", action="version", version=f"juc500-xfer {__version__}")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("info", help="연결된 JUC500 장치 목록")
s.set_defaults(func=cmd_info)
s = sub.add_parser("doctor", help="USB/libusb/WinUSB 진단")
s.set_defaults(func=cmd_doctor)
s = sub.add_parser("kill-wormhole", help="공식 Wormhole/GoBridge 프로세스 종료")
s.set_defaults(func=cmd_kill_wormhole)
s = sub.add_parser("probe", help="USB 인터페이스 점유 및 송수신 프로브")
s.add_argument("--serial", default=None)
s.set_defaults(func=cmd_probe)
s = sub.add_parser("send", help="파일 전송")
s.add_argument("file")
s.add_argument("--serial", default=None)
s.add_argument("--name", default=None, help="이 쪽 peer 이름")
s.add_argument("--timeout", type=float, default=60.0, help="상대 연결 대기(초)")
s.set_defaults(func=cmd_send)
s = sub.add_parser("recv", help="파일 수신")
s.add_argument("--dest", default=".", help="저장 폴더")
s.add_argument("--serial", default=None)
s.add_argument("--name", default=None)
s.add_argument("--timeout", type=float, default=60.0)
s.add_argument("--recv-timeout", type=float, default=3600.0)
s.add_argument("-y", "--yes", action="store_true", help="수락 확인 생략")
s.set_defaults(func=cmd_recv)
s = sub.add_parser("gui", help="fileShare 스타일 웹 GUI 실행")
s.add_argument("--port", type=int, default=None, help="GUI 포트 (기본 8765)")
s.add_argument("--no-browser", action="store_true", help="브라우저 자동 실행 안 함")
s.set_defaults(func=cmd_gui)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if getattr(args, "name", None) is None and args.cmd in ("send", "recv"):
host = os.uname().nodename if hasattr(os, "uname") else "host"
args.name = f"{host}"
try:
return args.func(args)
except KeyboardInterrupt:
print("\n중단됨")
return 130
except Exception as exc:
print(f"오류: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,298 @@
"""Read/write OS clipboard (file paths + plain text)."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse
_IS_WIN = sys.platform == "win32"
_IS_MAC = sys.platform == "darwin"
def list_clipboard_files() -> list[Path]:
"""Return existing file/dir paths currently on the clipboard."""
raw: list[str] = []
if _IS_MAC:
raw = _mac_filenames()
elif _IS_WIN:
raw = _win_filenames()
out: list[Path] = []
seen: set[str] = set()
for item in raw:
p = _normalize_path(item)
if p is None:
continue
key = str(p)
if key in seen:
continue
seen.add(key)
out.append(p)
return out
def clipboard_preview(limit: int = 8) -> dict[str, Any]:
files = list_clipboard_files()
names = [p.name for p in files[:limit]]
return {
"count": len(files),
"names": names,
"truncated": len(files) > limit,
"paths": [str(p) for p in files[:limit]],
}
def get_clipboard_text() -> str:
"""Best-effort plain text from the OS clipboard."""
if _IS_MAC:
return _mac_get_text()
if _IS_WIN:
return _win_get_text()
return ""
def set_clipboard_text(text: str) -> bool:
"""Set peer OS clipboard to plain text (for remote paste)."""
if text is None:
return False
if _IS_MAC:
return _mac_set_text(text)
if _IS_WIN:
return _win_set_text(text)
return False
def _normalize_path(item: str) -> Path | None:
s = (item or "").strip()
if not s:
return None
if s.startswith("file:"):
parsed = urlparse(s)
path = unquote(parsed.path or "")
if _IS_MAC and parsed.netloc and parsed.netloc not in ("", "localhost"):
path = f"/{parsed.netloc}{path}"
s = path
if _IS_WIN and s.startswith("/") and len(s) > 2 and s[2] == ":":
s = s[1:]
try:
p = Path(s)
if p.exists():
return p.resolve()
except OSError:
return None
return None
def _mac_filenames() -> list[str]:
# Prefer AppKit NSFilenamesPboardType
try:
from AppKit import NSPasteboard # type: ignore
pb = NSPasteboard.generalPasteboard()
for typ in (
"NSFilenamesPboardType",
"public.file-url",
):
try:
if typ == "NSFilenamesPboardType":
from AppKit import NSFilenamesPboardType # type: ignore
items = pb.propertyListForType_(NSFilenamesPboardType)
if items:
return [str(x) for x in items]
else:
# public.file-url may be multiple items
classes_out = []
try:
from Foundation import NSURL # type: ignore
objs = pb.readObjectsForClasses_options_([NSURL], None)
if objs:
for u in objs:
try:
if hasattr(u, "isFileURL") and u.isFileURL():
classes_out.append(str(u.path()))
except Exception:
continue
except Exception:
pass
if classes_out:
return classes_out
except Exception:
continue
except Exception:
pass
return _mac_filenames_osascript()
def _mac_filenames_osascript() -> list[str]:
ascript = """
try
set clip to the clipboard as «class furl»
return POSIX path of clip
on error
try
set out to {}
set clip to the clipboard
if class of clip is list then
repeat with i in clip
try
set end of out to POSIX path of (i as alias)
end try
end repeat
end if
set AppleScript's text item delimiters to linefeed
return out as text
on error
return ""
end try
end try
"""
try:
proc = subprocess.run(
["osascript", "-e", ascript],
capture_output=True,
text=True,
timeout=3,
check=False,
)
text = (proc.stdout or "").strip()
if not text:
return []
return [ln.strip() for ln in text.splitlines() if ln.strip()]
except Exception:
return []
def _win_filenames() -> list[str]:
try:
import ctypes
user32 = ctypes.windll.user32
shell32 = ctypes.windll.shell32
CF_HDROP = 15
if not user32.OpenClipboard(None):
return []
try:
handle = user32.GetClipboardData(CF_HDROP)
if not handle:
return []
count = shell32.DragQueryFileW(handle, 0xFFFFFFFF, None, 0)
out: list[str] = []
buf = ctypes.create_unicode_buffer(1024)
for i in range(int(count)):
n = shell32.DragQueryFileW(handle, i, buf, 1024)
if n:
out.append(buf.value)
return out
finally:
user32.CloseClipboard()
except Exception:
return []
def _mac_get_text() -> str:
try:
from AppKit import NSPasteboard, NSPasteboardTypeString # type: ignore
pb = NSPasteboard.generalPasteboard()
s = pb.stringForType_(NSPasteboardTypeString)
if s:
return str(s)
except Exception:
pass
try:
proc = subprocess.run(
["pbpaste"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
return proc.stdout or ""
except Exception:
return ""
def _mac_set_text(text: str) -> bool:
try:
from AppKit import NSPasteboard, NSPasteboardTypeString # type: ignore
pb = NSPasteboard.generalPasteboard()
pb.clearContents()
return bool(pb.setString_forType_(text, NSPasteboardTypeString))
except Exception:
pass
try:
proc = subprocess.run(
["pbcopy"],
input=text,
text=True,
timeout=2,
check=False,
)
return proc.returncode == 0
except Exception:
return False
def _win_get_text() -> str:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
CF_UNICODETEXT = 13
if not user32.OpenClipboard(None):
return ""
try:
handle = user32.GetClipboardData(CF_UNICODETEXT)
if not handle:
return ""
ptr = kernel32.GlobalLock(handle)
if not ptr:
return ""
try:
return ctypes.wstring_at(ptr)
finally:
kernel32.GlobalUnlock(handle)
finally:
user32.CloseClipboard()
except Exception:
return ""
def _win_set_text(text: str) -> bool:
try:
import ctypes
from ctypes import wintypes
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
CF_UNICODETEXT = 13
GMEM_MOVEABLE = 0x0002
data = (text.replace("\n", "\r\n") + "\0").encode("utf-16-le")
if not user32.OpenClipboard(None):
return False
try:
user32.EmptyClipboard()
h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data))
if not h:
return False
ptr = kernel32.GlobalLock(h)
if not ptr:
kernel32.GlobalFree(h)
return False
ctypes.memmove(ptr, data, len(data))
kernel32.GlobalUnlock(h)
if not user32.SetClipboardData(CF_UNICODETEXT, h):
kernel32.GlobalFree(h)
return False
return True
finally:
user32.CloseClipboard()
except Exception:
return False
@@ -0,0 +1,5 @@
"""호환용 — 웹 GUI로 위임."""
from .gui_server import run_gui
__all__ = ["run_gui"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
"""Dev-only: push local project sources to the USB peer (manual sync_peer.sh)."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Callable, Optional
from .protocol import Session
_ROOT = Path(__file__).resolve().parent.parent
# Keep in sync with scripts/sync_peer.sh default set.
SYNC_XFER = (
"juc500_xfer/gui_server.py",
"juc500_xfer/usb_link.py",
"juc500_xfer/inputshare.py",
"juc500_xfer/protocol.py",
"juc500_xfer/peer_sync.py",
)
SYNC_STATIC = (
"juc500_xfer/static/app.js",
"juc500_xfer/static/index.html",
"juc500_xfer/static/styles.css",
)
def _use_mac_peer_paths() -> bool:
return os.environ.get("USE_MAC_PEER_PATHS", "").strip() in ("1", "true", "yes")
def peer_dest_dirs() -> dict[str, str]:
"""Destination directories on the peer filesystem."""
if _use_mac_peer_paths():
base = "dev/juc500/juc500"
return {
"xfer": f"{base}/juc500_xfer",
"static": f"{base}/juc500_xfer/static",
}
root = os.environ.get("JUC500_PEER_ROOT", "C:/dev/juc500").replace("\\", "/").rstrip("/")
alt = os.environ.get("JUC500_PEER_ROOT_ALT", "C:/dev/juc500/juc500").replace("\\", "/").rstrip("/")
return {
"xfer": f"{root}/juc500_xfer",
"static": f"{root}/juc500_xfer/static",
"xfer_alt": f"{alt}/juc500_xfer",
"static_alt": f"{alt}/juc500_xfer/static",
}
def _local_paths(rel_paths: tuple[str, ...]) -> list[Path]:
out: list[Path] = []
for rel in rel_paths:
p = (_ROOT / rel).resolve()
if p.is_file():
out.append(p)
return out
def _send_batch(
session: Session,
dest_dir: str,
files: list[Path],
*,
on_log: Callable[[str], None],
on_progress: Optional[Callable[[int, int, str], None]] = None,
) -> int:
if not files:
return 0
dest = dest_dir.replace("\\", "/").rstrip("/")
count = 0
for local in files:
remote_name = f"{dest}/{local.name}" if dest else local.name
size = local.stat().st_size
if on_progress:
on_progress(0, size, local.name)
session.on_progress = (
(lambda done, total, name=local.name: on_progress(done, total, name))
if on_progress
else None
)
timeout = 180.0 if size > 32 * 1024 * 1024 else 120.0
session.send_file(local, remote_name=remote_name, accept_timeout=timeout)
count += 1
on_log(f"상대 동기화: {local.name} → {dest}")
return count
def peer_ping(session: Session, *, timeout: float = 10.0) -> dict[str, Any]:
try:
result = session.rpc("ping", timeout=timeout)
return result if isinstance(result, dict) else {}
except Exception:
return {}
def local_build_ids() -> tuple[str, str]:
try:
from .inputshare import INPUTSHARE_BUILD
inputshare = INPUTSHARE_BUILD
except Exception:
inputshare = "unknown"
try:
from .usb_link import XFER_BUILD
xfer = XFER_BUILD
except Exception:
xfer = "unknown"
return inputshare, xfer
def peer_needs_restart(session: Session) -> bool:
ping = peer_ping(session)
local_inputshare, local_xfer = local_build_ids()
peer_inputshare = str(ping.get("inputshare_build") or "unknown")
peer_xfer = str(ping.get("xfer_build") or "unknown")
if peer_xfer == "unknown":
return True
return peer_inputshare != local_inputshare or peer_xfer != local_xfer
def request_peer_restart(session: Session, *, on_log: Callable[[str], None]) -> bool:
try:
session.rpc("restart_gui", timeout=12.0)
on_log("상대편 GUI 재시작 예약됨 — 프로세스가 새 코드를 로드합니다")
return True
except Exception as exc:
on_log(f"상대 재시작 RPC 실패: {exc} — 상대 PC에서 ./start.sh 수동 실행 필요")
return False
def push_sources_only(
session: Session,
*,
on_log: Callable[[str], None],
on_progress: Optional[Callable[[int, int, str], None]] = None,
) -> int:
xfer_files = _local_paths(SYNC_XFER)
static_files = _local_paths(SYNC_STATIC)
dests = peer_dest_dirs()
total = 0
for key in ("xfer", "static"):
files = xfer_files if key == "xfer" else static_files
dest = dests.get(key)
if dest:
total += _send_batch(session, dest, files, on_log=on_log, on_progress=on_progress)
for key, alt_key in (("xfer", "xfer_alt"), ("static", "static_alt")):
alt = dests.get(alt_key)
if not alt or alt == dests.get(key):
continue
files = xfer_files if key == "xfer" else static_files
total += _send_batch(session, alt, files, on_log=on_log, on_progress=on_progress)
return total
def sync_sources_to_peer(
session: Session,
*,
on_log: Callable[[str], None],
on_progress: Optional[Callable[[int, int, str], None]] = None,
restart_if_stale: bool = False,
) -> dict[str, Any]:
"""Send default source bundle to peer. Optionally restart peer to load new code."""
on_log("상대편 소스 자동 동기화 시작…")
total = push_sources_only(session, on_log=on_log, on_progress=on_progress)
if total <= 0:
return {"ok": False, "error": "동기화할 로컬 파일 없음", "count": 0}
local_inputshare, local_xfer = local_build_ids()
ping = peer_ping(session)
peer_inputshare = str(ping.get("inputshare_build") or "unknown")
peer_xfer = str(ping.get("xfer_build") or "unknown")
restart = False
if restart_if_stale and (
peer_xfer != local_xfer or peer_inputshare != local_inputshare
):
on_log(
f"상대 build 불일치 (xfer 로컬 {local_xfer} · 상대 {peer_xfer}) — 재시작 요청"
)
restart = request_peer_restart(session, on_log=on_log)
on_log(f"상대편 소스 동기화 완료 ({total}개 파일)")
return {
"ok": True,
"count": total,
"local_xfer_build": local_xfer,
"peer_xfer_build": peer_xfer,
"restart_requested": restart,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>JUC</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app">
<header class="top">
<div class="brand">
<div class="brand-row">
<span class="brand-mark" id="brandModel">JUC</span>
<span class="brand-version" id="appVersion" title="앱 버전">—</span>
</div>
<span class="brand-sub" id="thisHost">Smart Data Link 파일 전송</span>
</div>
<div class="conn">
<div class="control-switch cable-switch" title="사용할 케이블 (변경 시 기존 연결을 끊고 재연결)">
<button id="cableJuc500Btn" type="button" class="ghost control-btn">JUC500</button>
<button id="cableJuc700Btn" type="button" class="ghost control-btn">JUC700</button>
<button id="cableKmLinkBtn" type="button" class="ghost control-btn">KMLink</button>
</div>
<span id="autoBadge" class="auto-badge wait">미연결</span>
<span id="wormholeBadge" class="auto-badge wait" title="공식 Wormhole 프로세스">Wormhole —</span>
<button id="killWormholeBtn" type="button" class="ghost" title="케이블 자동실행 Wormhole 종료">Wormhole 종료</button>
<span id="inputBadge" class="auto-badge wait" title="마우스·키보드 공유">입력공유 꺼짐</span>
<span id="pasteBadge" class="auto-badge wait" title="클립보드 파일 → 상대 폴더 (Ctrl+Shift+V)">붙여넣기 —</span>
<button id="inputShareBtn" type="button" class="ghost" disabled>입력공유 켜기</button>
<div class="control-switch" title="입력공유를 먼저 켜세요">
<button id="controlLocalBtn" type="button" class="ghost control-btn" disabled>이 PC</button>
<button id="controlRemoteBtn" type="button" class="ghost control-btn" disabled>상대 PC</button>
</div>
<button id="connectBtn" type="button" class="primary">연결</button>
<button id="disconnectBtn" type="button" class="ghost" disabled>끊기</button>
<button id="settingsBtn" type="button" class="ghost settings-btn" title="설정" aria-label="설정">
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M10 6.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zm7.1 2.4-.9-.2a5.9 5.9 0 0 0-.5-1.2l.5-.8a.8.8 0 0 0-.1-.9l-1.4-1.4a.8.8 0 0 0-.9-.1l-.8.5c-.4-.2-.8-.4-1.2-.5l-.2-.9a.8.8 0 0 0-.8-.6H8.2a.8.8 0 0 0-.8.6l-.2.9c-.4.1-.8.3-1.2.5l-.8-.5a.8.8 0 0 0-.9.1L2.9 5.8a.8.8 0 0 0-.1.9l.5.8c-.2.4-.4.8-.5 1.2l-.9.2a.8.8 0 0 0-.6.8v2a.8.8 0 0 0 .6.8l.9.2c.1.4.3.8.5 1.2l-.5.8a.8.8 0 0 0 .1.9l1.4 1.4a.8.8 0 0 0 .9.1l.8-.5c.4.2.8.4 1.2.5l.2.9a.8.8 0 0 0 .8.6h2a.8.8 0 0 0 .8-.6l.2-.9c.4-.1.8-.3 1.2-.5l.8.5a.8.8 0 0 0 .9-.1l1.4-1.4a.8.8 0 0 0 .1-.9l-.5-.8c.2-.4.4-.8.5-1.2l.9-.2a.8.8 0 0 0 .6-.8v-2a.8.8 0 0 0-.6-.8z"/>
</svg>
</button>
</div>
</header>
<div id="settingsModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="modal-backdrop" data-close-settings></div>
<div class="modal-panel">
<div class="modal-head">
<h2 id="settingsTitle">설정</h2>
<button type="button" class="ghost modal-close" data-close-settings aria-label="닫기">닫기</button>
</div>
<p class="modal-lead">JUC500/JUC700 GUI 포트입니다. 케이블 종류는 상단 JUC500/JUC700 버튼에서 선택합니다. 변경 후 다음 실행 시 적용됩니다.</p>
<div id="portsForm" class="ports-form"></div>
<p id="portsConfigPath" class="ports-path muted"></p>
<p id="settingsMsg" class="settings-msg" hidden></p>
<div class="modal-actions">
<button type="button" id="portsResetBtn" class="ghost">기본값</button>
<button type="button" id="portsApplyBtn" class="primary">적용</button>
</div>
</div>
</div>
<section id="peerBar" class="peer-bar">
<span id="peerInfo">한쪽에서 「연결」을 누르면 양쪽이 자동 연결됩니다</span>
<span id="ifaceInfo"></span>
</section>
<main class="main ftp-layout">
<aside class="finder local-browser" aria-label="이 PC 파일">
<div class="finder-titlebar">
<span class="finder-title">이 PC</span>
<span class="finder-subtitle" id="localRootHint">로컬 파일</span>
</div>
<div class="finder-toolbar">
<div class="nav-group">
<button id="localUpBtn" type="button" class="tool-btn" title="상위 폴더" disabled>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5 3 8.5h3.2V13h3.6V8.5H13L8 3.5z"/></svg>
</button>
<button id="localRefreshBtn" type="button" class="tool-btn" title="새로고침">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.4-3.6l.9.9V3.2h-2.1l1.1 1.1A4.2 4.2 0 1 0 12.2 8h1.3z"/></svg>
</button>
</div>
<div id="localPathBar" class="address-bar" title="경로"></div>
</div>
<div class="finder-cols" aria-hidden="true">
<button type="button" class="col-btn" data-sort-local="name">이름</button>
<button type="button" class="col-btn" data-sort-local="mtime">수정한 날짜</button>
<button type="button" class="col-btn" data-sort-local="size">크기</button>
<button type="button" class="col-btn" data-sort-local="kind">종류</button>
</div>
<div class="finder-scroll">
<table class="finder-table" aria-label="이 PC 목록">
<tbody id="localDirList"></tbody>
</table>
<div id="localEmpty" class="finder-empty hidden">폴더가 비어 있습니다</div>
</div>
<div class="finder-status">
<span id="localCount">0개 항목</span>
<span id="localPathHint" class="path-hint">/</span>
</div>
</aside>
<aside class="transfer-rail" aria-label="전송">
<div class="rail-paths">
<div class="rail-path">
<span class="rail-label">이 PC</span>
<strong id="railLocalPath">/</strong>
</div>
<div class="rail-path">
<span class="rail-label">상대</span>
<strong id="railRemotePath">/</strong>
</div>
</div>
<button id="uploadBtn" type="button" class="xfer-btn" disabled title="선택한 항목을 상대 현재 폴더로 전송">
<span class="xfer-arrow">→</span>
<span class="xfer-text">전송</span>
</button>
<button id="downloadBtn" type="button" class="xfer-btn" disabled title="선택한 항목을 이 PC 현재 폴더로 가져오기">
<span class="xfer-arrow">←</span>
<span class="xfer-text">가져오기</span>
</button>
<p class="rail-hint">선택 후 버튼<br/>또는 패널로 드래그<br/>Ctrl+Shift+V 붙여넣기</p>
<div class="rail-progress">
<div class="progress-wrap">
<div id="progressBar" class="progress-bar"></div>
</div>
<div id="statusText" class="status">연결 버튼을 누르세요</div>
</div>
</aside>
<aside class="finder remote-browser" aria-label="상대 PC 폴더">
<div class="finder-titlebar">
<span class="finder-title">상대 PC</span>
<span class="finder-subtitle" id="remoteRootHint">연결 필요</span>
</div>
<div class="finder-toolbar">
<div class="nav-group">
<button id="upBtn" type="button" class="tool-btn" title="상위 폴더" disabled>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5 3 8.5h3.2V13h3.6V8.5H13L8 3.5z"/></svg>
</button>
<button id="refreshBtn" type="button" class="tool-btn" title="새로고침">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.4-3.6l.9.9V3.2h-2.1l1.1 1.1A4.2 4.2 0 1 0 12.2 8h1.3z"/></svg>
</button>
<button id="mkdirBtn" type="button" class="tool-btn wide" title="새 폴더">새 폴더</button>
</div>
<div id="pathBar" class="address-bar" title="경로"></div>
</div>
<div class="finder-cols" aria-hidden="true">
<button type="button" class="col-btn" data-sort-remote="name">이름</button>
<button type="button" class="col-btn" data-sort-remote="mtime">수정한 날짜</button>
<button type="button" class="col-btn" data-sort-remote="size">크기</button>
<button type="button" class="col-btn" data-sort-remote="kind">종류</button>
</div>
<div class="finder-scroll">
<table class="finder-table" aria-label="상대 PC 목록">
<tbody id="dirList"></tbody>
</table>
<div id="remoteEmpty" class="finder-empty">연결 후 여기에 상대 폴더가 표시됩니다</div>
</div>
<div class="finder-status">
<span id="remoteCount">연결되지 않음</span>
<span id="remotePathHint" class="path-hint">/</span>
</div>
</aside>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>
@@ -0,0 +1,771 @@
:root {
--bg: #c9d1d8;
--bg-deep: #b4c0c8;
--window: #eceff1;
--titlebar: linear-gradient(180deg, #f6f7f8 0%, #e3e6ea 100%);
--toolbar: #e8ebef;
--row: #ffffff;
--row-alt: #f5f7f9;
--row-hover: #e8f0fe;
--row-selected: #0a5fff;
--row-selected-text: #ffffff;
--ink: #1c1c1e;
--muted: #6b7280;
--line: #c5CAD1;
--accent: #0b5fff;
--accent-2: #0047d9;
--danger: #c0392b;
--folder: #5cadff;
--folder-dark: #2f7fd4;
--file: #8e99a8;
--shadow: 0 10px 28px rgba(20, 28, 36, 0.14);
--radius: 10px;
--font: -apple-system, "SF Pro Text", "Segoe UI", "Apple SD Gothic Neo", sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
}
body {
margin: 0;
font-family: var(--font);
color: var(--ink);
background:
linear-gradient(180deg, #d7dee5 0%, #bbc5cf 100%);
}
.app {
width: 100%;
max-width: none;
margin: 0;
/* 좌·우 여백 = 하단과 동일 (14px) */
padding: 12px 14px 14px;
height: 100%;
max-height: 100dvh;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.top {
display: flex;
flex-wrap: wrap;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex: 0 0 auto;
}
.brand-mark {
display: block;
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
}
.brand-row {
display: flex;
align-items: baseline;
gap: 10px;
}
.brand-version {
font-family: var(--mono);
font-size: 0.72rem;
font-weight: 500;
color: var(--muted);
letter-spacing: -0.02em;
white-space: nowrap;
}
.brand-sub { color: var(--muted); font-size: 0.88rem; }
.conn {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.auto-badge {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: #e8f0fe;
color: var(--accent);
font-weight: 650;
font-size: 0.88rem;
border: 1px solid #c5d7f5;
}
.auto-badge.ok { background: #dff5e8; color: #0f6e56; border-color: #b7ddd1; }
.auto-badge.wait { background: #eef1f4; color: var(--muted); border-color: var(--line); }
button {
font: inherit;
border-radius: 8px;
border: 1px solid var(--line);
padding: 9px 13px;
cursor: pointer;
background: #fff;
color: var(--ink);
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
button.primary:hover:not(:disabled) { background: var(--accent-2); }
button.primary.compact { padding: 6px 12px; font-size: 0.84rem; }
button.ghost:hover:not(:disabled) { background: #e8ebef; }
.control-switch {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
background: #f4f6f8;
}
.control-switch .control-btn {
border: none;
border-radius: 0;
padding: 8px 11px;
font-size: 0.84rem;
background: transparent;
}
.control-switch .control-btn + .control-btn {
border-left: 1px solid var(--line);
}
.control-switch .control-btn.active {
background: var(--accent);
color: #fff;
font-weight: 650;
}
.control-switch .control-btn:hover:not(:disabled):not(.active) {
background: #e8ebef;
}
.cable-switch .control-btn {
min-width: 4.6rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.cable-switch .control-btn.cable-hidden {
display: none;
}
.cable-switch.cable-single {
gap: 0;
}
.peer-bar {
background: rgba(255,255,255,0.72);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 14px;
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 0.88rem;
box-shadow: var(--shadow);
flex: 0 0 auto;
}
.main {
display: grid;
grid-template-columns: 1fr 1fr 0.75fr;
gap: 12px;
flex: 1 1 auto;
min-height: 0; /* flex 자식이 늘어나지 않고 내부 스크롤 */
overflow: hidden;
}
.main.ftp-layout {
grid-template-columns: minmax(0, 1fr) 132px minmax(0, 1fr);
align-items: stretch;
}
@media (max-width: 1100px) {
html, body { overflow: auto; }
.app {
height: auto;
max-height: none;
overflow: visible;
}
.main,
.main.ftp-layout {
grid-template-columns: 1fr;
min-height: 0;
overflow: visible;
}
.finder,
.transfer-rail {
height: min(70dvh, 640px);
max-height: 70dvh;
min-height: 280px;
}
.transfer-rail {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
padding: 12px;
}
.rail-paths { width: 100%; }
.rail-progress { width: 100%; }
}
/* ===== Finder / Explorer window ===== */
.finder {
background: var(--window);
border: 1px solid #9aa3ad;
border-radius: 11px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.finder-titlebar {
background: var(--titlebar);
border-bottom: 1px solid #c0c6cd;
padding: 8px 12px;
display: flex;
align-items: baseline;
gap: 10px;
user-select: none;
}
.finder-title { font-weight: 700; font-size: 0.92rem; }
.finder-subtitle { color: var(--muted); font-size: 0.78rem; }
.finder-toolbar {
background: var(--toolbar);
border-bottom: 1px solid #cfd4da;
padding: 8px 10px;
display: flex;
gap: 8px;
align-items: center;
}
.nav-group {
display: flex;
gap: 4px;
flex: 0 0 auto;
}
.tool-btn {
width: 32px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 7px;
background: linear-gradient(180deg, #fff, #eef1f4);
border: 1px solid #b8c0c9;
}
.tool-btn.wide {
width: auto;
padding: 0 10px;
font-size: 0.78rem;
font-weight: 600;
}
.tool-btn svg {
width: 14px;
height: 14px;
fill: #3a4450;
}
.tool-btn:hover:not(:disabled) { background: #fff; }
.tool-btn:active:not(:disabled) { background: #dde3ea; }
.address-bar {
flex: 1;
min-width: 0;
height: 28px;
display: flex;
align-items: center;
gap: 2px;
padding: 0 8px;
background: #fff;
border: 1px solid #b8c0c9;
border-radius: 7px;
overflow: hidden;
white-space: nowrap;
font-family: var(--mono);
font-size: 0.78rem;
}
.path-sep { color: #9aa3ad; flex: 0 0 auto; }
button.path-crumb {
flex: 0 1 auto;
min-width: 0;
max-width: 36%;
padding: 2px 6px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--ink);
font: inherit;
font-family: var(--mono);
font-size: 0.78rem;
overflow: hidden;
text-overflow: ellipsis;
}
button.path-crumb:hover {
background: var(--row-hover);
color: var(--accent);
}
.finder-cols {
display: grid;
grid-template-columns: minmax(140px, 1.6fr) 140px 88px 90px;
gap: 0;
background: linear-gradient(180deg, #f4f5f7, #e7eaee);
border-bottom: 1px solid #c5CAD1;
user-select: none;
}
.col-btn {
border: none;
border-right: 1px solid #d0d5db;
border-radius: 0;
background: transparent;
text-align: left;
padding: 6px 10px;
font-size: 0.72rem;
font-weight: 650;
color: #4b5563;
letter-spacing: -0.01em;
}
.col-btn:last-child { border-right: none; }
.col-btn:hover { background: rgba(255,255,255,0.55); }
.col-btn.active { color: var(--accent); }
.finder.drop-target {
outline: 2px solid var(--accent);
outline-offset: -2px;
background: #eef4ff;
}
.finder-table tr.drop-target {
background: #d6e6ff !important;
box-shadow: inset 0 0 0 2px var(--accent);
}
.finder-table tr.parent-row .name-cell span {
font-weight: 700;
letter-spacing: 0.04em;
}
.finder-table tr.parent-row {
position: sticky;
top: 0;
z-index: 2;
background: #eef3f8 !important;
box-shadow: 0 1px 0 #c5cad1;
cursor: pointer;
}
.finder-table tr.parent-row:hover {
background: #e0ebf8 !important;
}
.finder-table tr.parent-row-root {
opacity: 0.55;
cursor: default;
}
.finder-table tr.parent-row:hover:not(.selected):not(.checked) {
background: #e0ebf8 !important;
}
.finder.local-browser.drop-target .finder-empty,
.finder.remote-browser.drop-target .finder-empty {
color: var(--accent);
}
.finder-titlebar,
.finder-toolbar,
.finder-cols,
.finder-status {
flex: 0 0 auto;
}
.finder-scroll {
flex: 1 1 auto;
min-height: 0; /* 핵심: 목록만 스크롤 */
overflow: auto;
background: var(--row);
position: relative;
overscroll-behavior: contain;
}
.finder-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.finder-table tr {
cursor: default;
user-select: none;
}
.finder-table tr:nth-child(even):not(.selected):not(.checked) {
background: var(--row-alt);
}
.finder-table tr:hover:not(.selected):not(.checked) {
background: var(--row-hover);
}
.finder-table tr.selected,
.finder-table tr.checked {
background: var(--row-selected) !important;
color: var(--row-selected-text);
}
.finder-table tr.selected .muted,
.finder-table tr.checked .muted,
.finder-table tr.selected .kind,
.finder-table tr.checked .kind {
color: rgba(255,255,255,0.85);
}
.finder-table td {
padding: 5px 10px;
font-size: 0.84rem;
border-bottom: 1px solid transparent;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.finder-table .name-cell {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.finder-table .name-cell span {
overflow: hidden;
text-overflow: ellipsis;
}
.finder-table .col-name { width: auto; }
.finder-table .col-date { width: 140px; color: var(--muted); font-variant-numeric: tabular-nums; }
.finder-table .col-size { width: 88px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.finder-table .col-kind { width: 90px; color: var(--muted); }
.item-icon {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.item-icon.folder { color: var(--folder); }
.item-icon.file { color: var(--file); }
.finder-table tr.selected .item-icon,
.finder-table tr.checked .item-icon { color: #fff; }
.finder-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 0.9rem;
pointer-events: none;
padding: 24px;
text-align: center;
}
.finder-empty.hidden { display: none; }
.finder-status {
border-top: 1px solid #cfd4da;
background: #eef1f4;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 0.78rem;
color: var(--muted);
}
.dest-hint strong {
color: var(--ink);
font-family: var(--mono);
font-weight: 600;
}
.path-hint {
font-family: var(--mono);
color: var(--ink);
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* FTP 가운데 전송 레일 */
.transfer-rail {
background: var(--window);
border: 1px solid #9aa3ad;
border-radius: 11px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 10px;
min-height: 0;
height: 100%;
max-height: 100%;
overflow: auto;
}
.rail-paths {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.rail-path {
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 6px 8px;
font-size: 0.72rem;
min-width: 0;
}
.rail-label {
display: block;
color: var(--muted);
margin-bottom: 2px;
}
.rail-path strong {
display: block;
font-family: var(--mono);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.xfer-btn {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 12px 8px;
border-radius: 10px;
border: 1px solid #b8c0c9;
background: linear-gradient(180deg, #fff, #eef1f4);
font-weight: 700;
}
.xfer-btn:hover:not(:disabled) {
border-color: var(--accent);
background: #e8f0fe;
color: var(--accent);
}
.xfer-arrow {
font-size: 1.35rem;
line-height: 1;
}
.xfer-text { font-size: 0.78rem; }
.rail-hint {
margin: 4px 0 0;
text-align: center;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
}
.rail-progress {
width: 100%;
margin-top: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.rail-progress .status {
font-size: 0.72rem;
text-align: center;
word-break: keep-all;
}
.drop-pane {
display: none;
}
.drop-zone {
flex: 1;
border: 2px dashed #9aa8b8;
border-radius: 12px;
background: linear-gradient(180deg, #f7f9fb, #e8eef4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.drop-zone.dragover {
border-color: var(--accent);
background: #e8f0fe;
}
.drop-title {
margin: 0 0 8px;
font-size: 1.1rem;
font-weight: 700;
}
.drop-hint {
margin: 0 0 16px;
color: var(--muted);
max-width: 26ch;
line-height: 1.45;
font-size: 0.88rem;
}
.file-pick {
display: inline-block;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
cursor: pointer;
font-size: 0.88rem;
}
.file-pick:hover { border-color: var(--accent); color: var(--accent); }
.progress-wrap {
height: 8px;
background: #d5dbe3;
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent), #5cadff);
transition: width 0.12s linear;
}
.status {
min-height: 1.35em;
color: var(--muted);
font-size: 0.88rem;
}
.status.error { color: var(--danger); }
.hidden { display: none !important; }
.settings-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
}
.settings-btn svg { display: block; }
.modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(18, 24, 32, 0.38);
}
.modal-panel {
position: relative;
width: min(520px, 100%);
max-height: min(86vh, 720px);
overflow: auto;
background: #f7f8fa;
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: var(--shadow);
padding: 18px 18px 16px;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.modal-head h2 {
margin: 0;
font-size: 1.2rem;
letter-spacing: -0.02em;
}
.modal-lead {
margin: 0 0 14px;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.ports-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.port-row {
display: grid;
grid-template-columns: 1fr 118px;
gap: 10px;
align-items: center;
padding: 10px 12px;
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
}
.port-meta {
min-width: 0;
}
.port-label {
display: block;
font-weight: 650;
font-size: 0.92rem;
}
.port-desc {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 2px;
line-height: 1.35;
}
.port-proto {
display: inline-block;
margin-top: 4px;
font-family: var(--mono);
font-size: 0.72rem;
color: #4b5563;
background: #eef1f4;
border-radius: 4px;
padding: 1px 6px;
}
.port-input {
width: 100%;
font: inherit;
font-family: var(--mono);
font-size: 0.95rem;
text-align: right;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fafbfc;
}
.port-input:focus {
outline: 2px solid #c5d7f5;
border-color: var(--accent);
}
.ports-path {
margin: 12px 0 0;
font-size: 0.78rem;
word-break: break-all;
}
.settings-msg {
margin: 10px 0 0;
font-size: 0.88rem;
color: var(--accent);
}
.settings-msg.error { color: var(--danger); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
@media (max-width: 560px) {
.port-row {
grid-template-columns: 1fr;
}
.port-input { text-align: left; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
"""Detect and terminate official j5create / OTi Wormhole processes.
The JUC500 virtual CD (WORMHOLE) auto-runs Wormhole.exe / WormholeShell which
exclusively claims USB Interface 5. Our stack needs that interface free.
"""
from __future__ import annotations
import os
import signal
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from typing import Any
_IS_WIN = sys.platform == "win32"
# Exact / substring match against process image name (case-insensitive)
PROCESS_NAMES = (
"wormhole.exe",
"wormhold.exe", # typo safety
"wormholeld.exe",
"wormholeshell",
"wormhole",
"u3wormhole.exe",
"u3wormholeshell",
"u3wormhole",
"gobridge.exe",
"gobridgedemon.exe",
"gobridgedemon",
"gobridgedemonu3.exe",
"gobridgedemonu3",
"downloadupdatetool.exe",
"downloadupdatetool",
"smartdatalink.exe",
"smartlink.exe",
"oti_kmloader.exe",
"otigoBridge.exe",
"gosuite.exe",
)
# Broader name hints (substring in process name)
NAME_HINTS = (
"wormhole",
"gobridge",
"kaijet",
"j5create",
"smartdatalink",
"smart data link",
"oti_",
"otisync",
)
# Do not kill ourselves
SELF_HINTS = ("juc500", "juc500_xfer", "juc500-portable")
# Other copies of this app also hold Interface 5 exclusively
# Keep markers specific — do not match scripts that merely import juc500_xfer.
SELF_PROCESS_MARKERS = (
"juc500_app.py",
"juc500-portable.exe",
"juc500_portable.exe",
"juc500-portable",
)
@dataclass
class ProcInfo:
pid: int
name: str
path: str = ""
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def _is_self(name: str, path: str = "") -> bool:
blob = f"{name} {path}".lower()
return any(s in blob for s in SELF_HINTS)
def _matches_wormhole(name: str, path: str = "") -> bool:
n = (name or "").lower().strip()
p = (path or "").lower()
if not n:
return False
if _is_self(n, p):
return False
base = n if n.endswith(".exe") else n
# exact-ish
for cand in PROCESS_NAMES:
c = cand.lower()
if base == c or base == c.replace(".exe", "") or n == c:
return True
# path / hint
blob = f"{n} {p}"
return any(h in blob for h in NAME_HINTS)
def list_wormhole_processes() -> list[ProcInfo]:
if _IS_WIN:
return _list_windows()
return _list_posix()
def _list_windows() -> list[ProcInfo]:
# CSV: "name","pid","session","mem"
try:
proc = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"],
capture_output=True,
text=True,
timeout=20,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
return []
out: list[ProcInfo] = []
for line in (proc.stdout or "").splitlines():
line = line.strip()
if not line:
continue
# "Wormhole.exe","1234","Console","1","12,345 K"
parts = [x.strip().strip('"') for x in line.split('","')]
if len(parts) < 2:
# fallback split
parts = [x.strip().strip('"') for x in line.split(",")]
if len(parts) < 2:
continue
name, pid_s = parts[0], parts[1]
try:
pid = int(pid_s)
except ValueError:
continue
if _matches_wormhole(name):
out.append(ProcInfo(pid=pid, name=name))
# Enrich path via WMIC / PowerShell (optional)
return _dedupe(out)
def _list_posix() -> list[ProcInfo]:
try:
proc = subprocess.run(
["ps", "-ax", "-o", "pid=,comm=,command="],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except Exception:
return []
out: list[ProcInfo] = []
my_pid = os.getpid()
for line in (proc.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pid_s, rest = line.split(None, 1)
pid = int(pid_s)
except ValueError:
continue
if pid == my_pid:
continue
# comm is first token of rest sometimes; keep full command
tokens = rest.split(None, 1)
comm = tokens[0]
cmd = tokens[1] if len(tokens) > 1 else ""
if _matches_wormhole(comm, cmd) or _matches_wormhole(os.path.basename(comm), cmd):
out.append(ProcInfo(pid=pid, name=os.path.basename(comm) or comm, path=cmd[:240]))
return _dedupe(out)
def _dedupe(items: list[ProcInfo]) -> list[ProcInfo]:
seen: set[int] = set()
out: list[ProcInfo] = []
for p in items:
if p.pid in seen:
continue
seen.add(p.pid)
out.append(p)
return out
def kill_wormhole_processes(*, force: bool = True) -> dict[str, Any]:
"""Terminate official Wormhole processes. Returns status dict."""
before = list_wormhole_processes()
killed: list[dict[str, Any]] = []
errors: list[str] = []
for info in before:
try:
_kill_one(info.pid, force=force)
killed.append(info.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"{info.name}({info.pid}): {exc}")
# brief settle so USB handles are released
if killed:
time.sleep(0.8)
after = list_wormhole_processes()
# second pass for stubborn ones
if after and force:
for info in after:
try:
_kill_one(info.pid, force=True)
if info.to_dict() not in killed:
killed.append(info.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"retry {info.name}({info.pid}): {exc}")
time.sleep(0.5)
after = list_wormhole_processes()
return {
"ok": len(after) == 0,
"found": [p.to_dict() for p in before],
"killed": killed,
"remaining": [p.to_dict() for p in after],
"errors": errors,
"message": _summary(before, killed, after),
}
def _summary(before: list[ProcInfo], killed: list[dict], after: list[ProcInfo]) -> str:
if not before:
return "실행 중인 Wormhole 관련 프로세스가 없습니다."
if not after:
names = ", ".join(sorted({k.get("name", "?") for k in killed})) or "프로세스"
return f"Wormhole 종료 완료: {names}"
rem = ", ".join(f"{p.name}({p.pid})" for p in after)
return f"일부 종료되지 않음: {rem} (관리자 권한으로 다시 시도하거나 작업 관리자에서 종료)"
def list_other_juc500_processes() -> list[ProcInfo]:
"""Other juc500 GUI/CLI instances (excludes this PID). They hold IF5."""
my_pid = os.getpid()
out: list[ProcInfo] = []
if _IS_WIN:
try:
ps = (
"Get-CimInstance Win32_Process | "
"Where-Object { $_.CommandLine -match 'juc500_app\\.py|juc500-portable|juc500_portable|-m juc500_xfer' } | "
"Select-Object ProcessId, Name, CommandLine | ConvertTo-Json -Compress"
)
proc = subprocess.run(
["powershell", "-NoProfile", "-Command", ps],
capture_output=True,
text=True,
timeout=15,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (proc.stdout or "").strip()
if not raw:
return []
import json
data = json.loads(raw)
if isinstance(data, dict):
data = [data]
for d in data:
pid = int(d.get("ProcessId") or 0)
if not pid or pid == my_pid:
continue
cmd = str(d.get("CommandLine") or "")
name = str(d.get("Name") or "python.exe")
if any(m in cmd.lower() for m in SELF_PROCESS_MARKERS):
out.append(ProcInfo(pid=pid, name=name, path=cmd[:240]))
except Exception:
return []
return _dedupe(out)
# POSIX: scan ps
try:
proc = subprocess.run(
["ps", "-ax", "-o", "pid=,command="],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except Exception:
return []
for line in (proc.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pid_s, cmd = line.split(None, 1)
pid = int(pid_s)
except ValueError:
continue
if pid == my_pid:
continue
low = cmd.lower()
if any(m in low for m in SELF_PROCESS_MARKERS):
out.append(ProcInfo(pid=pid, name=os.path.basename(cmd.split()[0]), path=cmd[:240]))
return _dedupe(out)
def kill_other_juc500_processes(*, force: bool = True) -> dict[str, Any]:
"""Terminate other juc500 instances so Interface 5 can be claimed."""
before = list_other_juc500_processes()
killed: list[dict[str, Any]] = []
errors: list[str] = []
for info in before:
try:
_kill_one(info.pid, force=force)
killed.append(info.to_dict())
except Exception as exc: # noqa: BLE001
errors.append(f"{info.name}({info.pid}): {exc}")
if killed:
time.sleep(0.6)
after = list_other_juc500_processes()
return {
"ok": len(after) == 0,
"found": [p.to_dict() for p in before],
"killed": killed,
"remaining": [p.to_dict() for p in after],
"errors": errors,
"message": (
"다른 JUC500 인스턴스가 없습니다."
if not before
else (
f"다른 JUC500 종료: {', '.join(str(k.get('pid')) for k in killed)}"
if not after
else f"일부 JUC500이 남음: {[p.pid for p in after]}"
)
),
}
def _kill_one(pid: int, *, force: bool) -> None:
if _IS_WIN:
args = ["taskkill", "/PID", str(pid)]
if force:
args.append("/F")
proc = subprocess.run(
args,
capture_output=True,
text=True,
timeout=15,
check=False,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "taskkill failed").strip()
# 128 = not found — treat as OK
if proc.returncode == 128 or "not found" in err.lower():
return
raise RuntimeError(err)
return
sig = signal.SIGKILL if force else signal.SIGTERM
try:
os.kill(pid, sig)
except ProcessLookupError:
return
if not force:
time.sleep(0.4)
try:
os.kill(pid, 0)
except ProcessLookupError:
return
os.kill(pid, signal.SIGKILL)
def wormhole_status() -> dict[str, Any]:
procs = list_wormhole_processes()
return {
"running": len(procs) > 0,
"count": len(procs),
"processes": [p.to_dict() for p in procs],
"message": (
f"Wormhole 관련 프로세스 {len(procs)}개 실행 중 (Interface 5 점유 → 종료 권장)"
if procs
else "Wormhole 프로세스 없음"
),
}
@@ -0,0 +1,8 @@
# Prefer vendor/wheels (offline). On closed networks run:
# python -m pip install --no-index --find-links=vendor/wheels -r requirements.txt
#
# See vendor/wheels/README.md and docs/OFFLINE-WINDOWS.md
--find-links=vendor/wheels
pyusb==1.2.1
pynput==1.8.2
six==1.17.0
@@ -0,0 +1,78 @@
@echo off
REM Offline overlay update for closed-network Windows 11.
REM Run from the extracted update folder (or pass DEST as arg 1).
setlocal EnableExtensions
set "SRC=%~dp0"
if "%SRC:~-1%"=="\" set "SRC=%SRC:~0,-1%"
REM If this bat lives in scripts\, SRC is package root when called as scripts\apply...
if exist "%SRC%\juc500_xfer\usb_link.py" goto :have_src
if exist "%SRC%\..\juc500_xfer\usb_link.py" (
set "SRC=%SRC%\.."
goto :have_src
)
echo [!] juc500_xfer not found next to this script.
echo Extract the offline update zip first, then run this bat from that folder.
exit /b 1
:have_src
set "DEST=%~1"
if "%DEST%"=="" set "DEST=C:\dev\juc500"
echo === JUC500 offline update ===
echo SRC = %SRC%
echo DEST= %DEST%
if not exist "%DEST%" (
echo [!] DEST missing: %DEST%
echo Create it or pass path: apply_offline_update.bat D:\juc500
exit /b 1
)
echo.
echo Copying runtime sources...
robocopy "%SRC%\juc500_xfer" "%DEST%\juc500_xfer" /E /XD __pycache__ .git /NFL /NDL /NJH /NJS /nc /ns /np
if errorlevel 8 exit /b 1
if exist "%SRC%\scripts" (
robocopy "%SRC%\scripts" "%DEST%\scripts" /E /XD __pycache__ /NFL /NDL /NJH /NJS /nc /ns /np
)
for %%F in (start.bat start.sh start_win.sh start_both.sh juc500_app.py requirements.txt README.md) do (
if exist "%SRC%\%%F" copy /Y "%SRC%\%%F" "%DEST%\%%F" >nul
)
if exist "%SRC%\vendor\libusb-1.0.dll" (
if not exist "%DEST%\vendor" mkdir "%DEST%\vendor"
copy /Y "%SRC%\vendor\libusb-1.0.dll" "%DEST%\vendor\libusb-1.0.dll" >nul
if exist "%DEST%\.venv\Scripts" copy /Y "%SRC%\vendor\libusb-1.0.dll" "%DEST%\.venv\Scripts\libusb-1.0.dll" >nul
)
if exist "%SRC%\vendor\wheels" (
if not exist "%DEST%\vendor\wheels" mkdir "%DEST%\vendor\wheels"
robocopy "%SRC%\vendor\wheels" "%DEST%\vendor\wheels" *.whl /NFL /NDL /NJH /NJS /nc /ns /np
)
if exist "%SRC%\vendor\tools\zadig.exe" (
if not exist "%DEST%\vendor\tools" mkdir "%DEST%\vendor\tools"
copy /Y "%SRC%\vendor\tools\zadig.exe" "%DEST%\vendor\tools\zadig.exe" >nul
)
if exist "%SRC%\docs" (
if not exist "%DEST%\docs" mkdir "%DEST%\docs"
robocopy "%SRC%\docs" "%DEST%\docs" /E /NFL /NDL /NJH /NJS /nc /ns /np
)
echo.
echo Verifying build tag...
if exist "%DEST%\.venv\Scripts\python.exe" (
"%DEST%\.venv\Scripts\python.exe" -c "from juc500_xfer.usb_link import XFER_BUILD; print('XFER_BUILD=', XFER_BUILD)"
) else (
echo [.venv missing — run start.bat once to bootstrap from vendor]
)
echo.
echo Done. Close JUC500 GUI, then:
echo cd /d %DEST%
echo start.bat
echo GUI should show build tag smartkmlink (or newer).
echo SmartKMLink: Zadig MI_05 WinUSB — vendor\tools\zadig.exe
endlocal
exit /b 0
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Windows 오프라인 초기 설정 — Git Bash / MSYS (cmd.exe 경로 변환 없이 직접 실행)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
WHEELS="$ROOT/vendor/wheels"
GETPIP="$ROOT/vendor/get-pip.py"
LIBUSB="$ROOT/vendor/libusb-1.0.dll"
EMBED_PY="$ROOT/vendor/python-windows/python.exe"
VENV_PY="$ROOT/.venv/Scripts/python.exe"
MAC_VENV="$ROOT/.venv/bin/python"
if [[ ! -f "$WHEELS/pyusb-1.2.1-py3-none-any.whl" || ! -f "$LIBUSB" ]]; then
echo "[!] vendor 번들이 불완전합니다. 압축 시 vendor/ 폴더를 반드시 포함하세요." >&2
echo " 필요: vendor/wheels, vendor/libusb-1.0.dll, vendor/get-pip.py" >&2
exit 1
fi
# Mac .venv 가 zip 에 섞이면 Windows 에서 깨짐
if [[ -f "$MAC_VENV" && ! -f "$VENV_PY" ]]; then
echo "[*] Mac .venv 제거 중…"
rm -rf "$ROOT/.venv"
fi
venv_ok() {
[[ -f "$VENV_PY" ]] || return 1
PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" "$VENV_PY" -c "import usb" 2>/dev/null || return 1
PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}" "$VENV_PY" -c "
from juc500_xfer.usb_link import libusb_path
import sys
sys.exit(0 if libusb_path() else 1)
" 2>/dev/null
}
if venv_ok; then
exit 0
fi
echo "============================================"
echo " JUC500 Windows 오프라인 초기 설정"
echo "============================================"
pick_boot_py() {
if command -v py >/dev/null 2>&1; then
if py -3 -c "import sys" 2>/dev/null; then
echo "py -3"
return 0
fi
fi
if command -v python >/dev/null 2>&1; then
if python -c "import sys" 2>/dev/null; then
echo "python"
return 0
fi
fi
if [[ -f "$EMBED_PY" ]]; then
echo "$EMBED_PY"
echo "Using vendored Python: vendor/python-windows/python.exe" >&2
return 0
fi
return 1
}
BOOT_PY="$(pick_boot_py)" || {
echo "[!] Python 없음. vendor/python-windows 가 있는지 확인하세요." >&2
exit 1
}
echo "Bootstrap: $BOOT_PY"
run_boot() {
# shellcheck disable=SC2086
$BOOT_PY "$@"
}
if ! run_boot -c "import virtualenv" 2>/dev/null; then
echo "Installing virtualenv from vendor/wheels …"
if ! run_boot -m pip install --no-index --find-links="$WHEELS" virtualenv 2>/dev/null; then
if [[ -f "$GETPIP" ]]; then
run_boot "$GETPIP" --no-index --find-links="$WHEELS" pip setuptools wheel
run_boot -m pip install --no-index --find-links="$WHEELS" virtualenv
else
echo "[!] virtualenv 설치 실패" >&2
exit 1
fi
fi
fi
if [[ ! -f "$VENV_PY" ]]; then
echo "Creating .venv (offline)…"
run_boot -m virtualenv --no-periodic-update --no-setuptools --no-pip "$ROOT/.venv"
run_boot "$GETPIP" --no-index --find-links="$WHEELS" pip setuptools wheel packaging
elif ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then
echo "[*] .venv 손상 — 재생성…"
rm -rf "$ROOT/.venv"
run_boot -m virtualenv --no-periodic-update --no-setuptools --no-pip "$ROOT/.venv"
run_boot "$GETPIP" --no-index --find-links="$WHEELS" pip setuptools wheel packaging
fi
echo "Installing dependencies from vendor/wheels (no PyPI)…"
"$VENV_PY" -m pip install --no-index --find-links="$WHEELS" -U pip setuptools wheel packaging
"$VENV_PY" -m pip install --no-index --find-links="$WHEELS" -r "$ROOT/requirements.txt"
"$VENV_PY" -m pip install --no-index --find-links="$WHEELS" --no-build-isolation -e "$ROOT"
cp -f "$LIBUSB" "$ROOT/.venv/Scripts/libusb-1.0.dll"
echo "libusb-1.0.dll → .venv/Scripts/"
if ! venv_ok; then
echo "[!] 초기 설정 후에도 .venv 검증 실패" >&2
exit 1
fi
echo "[*] Windows 초기 설정 완료"
exit 0
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# 개발 적용: sync_peer (동기화) + 수동 재시작 안내
# 1) 소스 수정
# 2) 이 스크립트 또는 ./scripts/sync_peer.sh
# 3) ./scripts/stop_tauri_watchers.sh (Tauri 케이블 감시 종료)
# 4) ./start.sh (이 PC)
# 5) 상대 PC: cd C:/dev/juc500 && ./start.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ -x "$ROOT/scripts/stop_tauri_watchers.sh" ]]; then
"$ROOT/scripts/stop_tauri_watchers.sh" || true
fi
export JUC500_SYNC_RESTART=0
exec "$ROOT/scripts/sync_peer.sh" "$@"
@@ -0,0 +1,22 @@
@echo off
setlocal EnableExtensions
cd /d "%~dp0.."
set "ROOT=%CD%"
set "SRC=%ROOT%\vendor\libusb-1.0.dll"
if not exist "%SRC%" (
echo [!] Missing vendor\libusb-1.0.dll
echo Copy the full repo including vendor\ from the Mac, or run scripts\setup_windows.bat
exit /b 1
)
if not exist "%ROOT%\.venv\Scripts\python.exe" (
echo [!] .venv missing. Run: scripts\setup_windows.bat
exit /b 1
)
copy /Y "%SRC%" "%ROOT%\.venv\Scripts\libusb-1.0.dll" >nul
echo libusb-1.0.dll -^> .venv\Scripts\
"%ROOT%\.venv\Scripts\python.exe" -c "from juc500_xfer.usb_link import libusb_path; p=libusb_path(); print('libusb OK:', p or 'FAILED'); import sys; sys.exit(0 if p else 1)"
exit /b %ERRORLEVEL%
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# macOS 로그인 시 JUC500 Tauri 감시 앱 자동 실행 (케이블 연결 → GUI)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LABEL="com.juc500.desktop"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
LAUNCHER="$ROOT/scripts/launch_tauri.sh"
BIN="$ROOT/juc500-app/src-tauri/target/release/juc500-app"
if [[ -x "$BIN" ]]; then
RUN_CMD="$BIN"
elif [[ -x "$LAUNCHER" ]]; then
RUN_CMD="/bin/bash"
RUN_ARGS=("$LAUNCHER")
else
echo "Tauri 앱 없음. 먼저: cd juc500-app && npm install && npm run tauri build" >&2
exit 1
fi
mkdir -p "$HOME/Library/LaunchAgents"
chmod +x "$ROOT/scripts/launch_tauri.sh"
if [[ "$RUN_CMD" == "/bin/bash" ]]; then
cat >"$PLIST" <<EOF
<?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>Label</key><string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>${LAUNCHER}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>JUC500_ROOT</key><string>${ROOT}</string>
<key>JUC500_CABLE_AUTOLAUNCH</key><string>1</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${ROOT}/.juc500/tauri-launch.log</string>
<key>StandardErrorPath</key><string>${ROOT}/.juc500/tauri-launch.log</string>
</dict>
</plist>
EOF
else
cat >"$PLIST" <<EOF
<?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>Label</key><string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${BIN}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>JUC500_ROOT</key><string>${ROOT}</string>
<key>JUC500_CABLE_AUTOLAUNCH</key><string>1</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>${ROOT}/.juc500/tauri-launch.log</string>
<key>StandardErrorPath</key><string>${ROOT}/.juc500/tauri-launch.log</string>
</dict>
</plist>
EOF
fi
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST"
launchctl enable "gui/$(id -u)/${LABEL}"
echo "설치 완료: ${PLIST}"
echo "로그인 시 트레이 감시 앱이 실행되고, 케이블 연결 시 GUI가 열립니다."
@@ -0,0 +1,25 @@
@echo off
REM Windows 로그인 시 JUC500 Tauri 감시 앱 자동 실행
setlocal EnableExtensions
cd /d "%~dp0.."
set "ROOT=%CD%"
set "BIN=%ROOT%\juc500-app\src-tauri\target\release\juc500-app.exe"
if not exist "%BIN%" (
echo [!] Build first: cd juc500-app ^&^& npm install ^&^& npm run tauri build
exit /b 1
)
set "STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup"
set "LINK=%STARTUP%\JUC500.lnk"
set "LAUNCHER=%ROOT%\scripts\launch_tauri.bat"
powershell -NoProfile -Command ^
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('%LINK%');" ^
"$s.TargetPath='%LAUNCHER%';" ^
"$s.WorkingDirectory='%ROOT%';" ^
"$s.Description='JUC500 cable auto-launch';" ^
"$s.Save()"
echo Installed startup shortcut: %LINK%
echo Tray watcher runs at logon; GUI opens when cable is plugged.
@@ -0,0 +1,17 @@
@echo off
REM JUC500 Tauri shell — auto-launch on cable connect
cd /d "%~dp0.."
set "JUC500_ROOT=%CD%"
set "JUC500_USE_TAURI=1"
set "BIN=%CD%\juc500-app\src-tauri\target\release\juc500-app.exe"
if exist "%BIN%" goto run
set "BIN=%CD%\juc500-app\src-tauri\target\debug\juc500-app.exe"
if exist "%BIN%" goto run
echo [!] Tauri app not built. Run: cd juc500-app ^&^& npm install ^&^& npm run tauri build
exit /b 1
:run
start "" "%BIN%"
exit /b 0
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# JUC500 Tauri 셸 — USB 케이블 연결 시 Python 백엔드 + GUI 자동 기동
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export JUC500_ROOT="$ROOT"
# 케이블 자동 실행은 install_autolaunch_* 설치 시 JUC500_CABLE_AUTOLAUNCH=1 로 켬
export JUC500_CABLE_AUTOLAUNCH="${JUC500_CABLE_AUTOLAUNCH:-0}"
export JUC500_USE_TAURI=1
APP_DIR="$ROOT/juc500-app"
if [[ ! -d "$APP_DIR/src-tauri" ]]; then
echo "juc500-app 없음. Tauri 프로젝트를 먼저 빌드하세요." >&2
exit 1
fi
# Release 바이너리 우선, 없으면 dev
BIN=""
for candidate in \
"$APP_DIR/src-tauri/target/release/juc500-app" \
"$APP_DIR/src-tauri/target/debug/juc500-app"; do
if [[ -x "$candidate" ]]; then
BIN="$candidate"
break
fi
done
if [[ -n "$BIN" ]]; then
exec "$BIN"
fi
if ! command -v npm >/dev/null 2>&1; then
echo "npm 없음 — Node.js 설치 후: cd juc500-app && npm install && npm run tauri build" >&2
exit 1
fi
cd "$APP_DIR"
if [[ ! -d node_modules ]]; then
npm install
fi
exec npm run tauri -- dev
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Build offline Windows update package (USB stick / closed network).
# Includes runtime sources + vendor wheels/libusb/zadig + apply bat.
# Does NOT require internet on the Windows peer.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
STAMP="$(date +%Y%m%d-%H%M%S)"
BUILD="$("$ROOT/.venv/bin/python" -c "from juc500_xfer.usb_link import XFER_BUILD; print(XFER_BUILD)" 2>/dev/null || echo unknown)"
OUT_DIR="${1:-$ROOT/dist-offline/juc500-offline-update-${STAMP}}"
ZIP_OUT="${2:-$ROOT/dist-offline/juc500-offline-update-${STAMP}.zip}"
mkdir -p "$OUT_DIR" "$ROOT/dist-offline"
echo "=== Offline Windows update package ==="
echo "XFER_BUILD=$BUILD"
echo "OUT_DIR=$OUT_DIR"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/juc500_xfer/static" "$OUT_DIR/scripts" "$OUT_DIR/vendor/wheels" "$OUT_DIR/vendor/tools" "$OUT_DIR/docs"
# Runtime Python package (all modules — SmartKMLink lives in usb_link.py)
rsync -a --delete \
--exclude '__pycache__' --exclude '*.py[cod]' --exclude '.DS_Store' \
"$ROOT/juc500_xfer/" "$OUT_DIR/juc500_xfer/"
# Launchers / entry
for f in juc500_app.py start.bat start.sh start_win.sh start_both.sh requirements.txt README.md; do
[[ -f "$ROOT/$f" ]] && cp -f "$ROOT/$f" "$OUT_DIR/$f"
done
# Scripts (bootstrap + apply)
rsync -a --exclude '__pycache__' "$ROOT/scripts/" "$OUT_DIR/scripts/"
cp -f "$ROOT/scripts/apply_offline_update.bat" "$OUT_DIR/apply_offline_update.bat"
# Vendor offline essentials
cp -f "$ROOT/vendor/libusb-1.0.dll" "$OUT_DIR/vendor/libusb-1.0.dll"
cp -f "$ROOT/vendor/wheels/"*.whl "$OUT_DIR/vendor/wheels/" 2>/dev/null || true
[[ -f "$ROOT/vendor/wheels/README.md" ]] && cp -f "$ROOT/vendor/wheels/README.md" "$OUT_DIR/vendor/wheels/"
[[ -f "$ROOT/vendor/README.md" ]] && cp -f "$ROOT/vendor/README.md" "$OUT_DIR/vendor/"
[[ -f "$ROOT/vendor/get-pip.py" ]] && cp -f "$ROOT/vendor/get-pip.py" "$OUT_DIR/vendor/"
if [[ -f "$ROOT/vendor/tools/zadig.exe" ]]; then
cp -f "$ROOT/vendor/tools/zadig.exe" "$OUT_DIR/vendor/tools/zadig.exe"
fi
# Docs
for f in OFFLINE-WINDOWS.md WINDOWS.md WINDOWS-ACCESS-DENIED.md; do
[[ -f "$ROOT/docs/$f" ]] && cp -f "$ROOT/docs/$f" "$OUT_DIR/docs/$f"
done
cat >"$OUT_DIR/BUILD.txt" <<EOF
JUC500 offline update
XFER_BUILD=$BUILD
created=$STAMP
SmartKMLink VID:PID=0ea0:7301 (IF5 same as JUC500)
Windows closed-network apply:
1) Copy this folder (or unzip) to the peer PC via USB stick
2) Close JUC500 GUI on Windows
3) Double-click apply_offline_update.bat
(default DEST=C:\\dev\\juc500 — or: apply_offline_update.bat D:\\path\\juc500)
4) cd C:\\dev\\juc500 && start.bat
5) GUI version line must show: $BUILD
6) SmartKMLink / JUC500 IF5: vendor\\tools\\zadig.exe → MI_05 → WinUSB
Full fresh install (includes embeddable Python):
./scripts/make_windows_bundle.sh
EOF
cat >"$OUT_DIR/APPLY-WINDOWS.txt" <<'EOF'
폐쇄망 Windows 11 — 오프라인 업데이트
====================================
1. 이 폴더 전체를 USB로 Windows PC에 복사
2. JUC500 GUI 종료
3. apply_offline_update.bat 실행 (기본 대상 C:\dev\juc500)
4. C:\dev\juc500 에서 start.bat 실행
5. 상단 빌드 태그가 BUILD.txt 의 XFER_BUILD 와 같은지 확인
6. SmartKMLink(0ea0:7301) / JUC500: vendor\tools\zadig.exe
→ Options → List All Devices → Interface 5 (MI_05) → WinUSB
인터넷·pip 설치 불필요. vendor\wheels · libusb · (선택) zadig 포함.
EOF
rm -f "$ZIP_OUT"
(
cd "$OUT_DIR"
zip -r -q "$ZIP_OUT" .
)
# Also drop a copy on Desktop for easy USB copy
DESK="$HOME/Desktop/juc500-offline-update-${STAMP}.zip"
cp -f "$ZIP_OUT" "$DESK"
bytes="$(wc -c <"$ZIP_OUT" | tr -d ' ')"
echo "완료: $ZIP_OUT ($bytes bytes)"
echo "Desktop: $DESK"
echo "폴더: $OUT_DIR"
echo "XFER_BUILD=$BUILD"
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Mac/Linux → Windows 배포용 zip 생성 (vendor 포함, .venv 제외)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
STAMP="$(date +%Y%m%d)"
OUT="${1:-$ROOT/../juc500-windows-${STAMP}.zip}"
if [[ ! -f "$ROOT/vendor/libusb-1.0.dll" ]]; then
echo "[!] vendor/libusb-1.0.dll 없음" >&2
exit 1
fi
if [[ ! -f "$ROOT/vendor/wheels/pyusb-1.2.1-py3-none-any.whl" ]]; then
echo "[!] vendor/wheels 없음" >&2
exit 1
fi
if [[ ! -f "$ROOT/vendor/tools/zadig.exe" ]]; then
echo "[!] vendor/tools/zadig.exe 없음 — WinUSB 오프라인 도구. ./scripts/make_offline_windows_update.sh 전에 준비하세요." >&2
# non-fatal: bundle still usable if peer already has Zadig
fi
echo "=== Windows 배포 zip 생성 ==="
echo "출력: $OUT"
# zip: 프로젝트 루트 내용을 아카이브 루트에 넣음 (juc500/ 접두사 없음)
rm -f "$OUT"
(
cd "$ROOT"
zip -r -q "$OUT" . \
-x ".venv/*" \
-x ".venv-build/*" \
-x ".venv-build-win/*" \
-x ".venv-offline-test/*" \
-x ".git/*" \
-x "*/__pycache__/*" \
-x "*.py[cod]" \
-x "*.egg-info/*" \
-x "dist/*" \
-x "dist-windows/*" \
-x "build/*" \
-x "juc500-app/node_modules/*" \
-x "juc500-app/src-tauri/target/*" \
-x ".DS_Store" \
-x "*.zip" \
-x "*.tar.gz" \
-x ".juc500/*" \
-x "hub.log"
)
bytes="$(wc -c <"$OUT" | tr -d ' ')"
echo "완료: $OUT ($bytes bytes)"
echo ""
echo "Windows에서:"
echo " 1) zip 압축 해제 (예: C:\\dev\\juc500)"
echo " 2) Git Bash: cd /c/dev/juc500 && ./start_win.sh"
echo " (첫 실행 시 vendor 로 .venv 자동 생성)"
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# juc500 — 이 Mac에서 JUC500 GUI 프로세스를 종료하고 백그라운드로 다시 실행
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
is_windows() {
case "$(uname -s 2>/dev/null || echo unknown)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
PY=""
if is_windows && [[ -f "$ROOT/.venv/Scripts/python.exe" ]]; then
PY="$ROOT/.venv/Scripts/python.exe"
elif [[ -x "$ROOT/.venv/bin/python" ]]; then
PY="$ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PY="$(command -v python3)"
else
echo "python3 없음" >&2
exit 1
fi
LOG_DIR="$ROOT/.juc500"
mkdir -p "$LOG_DIR"
LOG_FILE="$LOG_DIR/restart.log"
HOST_TAG="$(hostname -s 2>/dev/null || hostname)"
echo "[$HOST_TAG] JUC500 종료 중…"
kill_msg="$(
"$PY" -c "
from juc500_xfer.wormhole_kill import kill_other_juc500_processes
r = kill_other_juc500_processes(force=True)
print(r.get('message') or '')
" 2>/dev/null || true
)"
if [[ -n "${kill_msg}" ]]; then
echo "[$HOST_TAG] $kill_msg"
fi
sleep 0.6
echo "[$HOST_TAG] JUC500 시작 중… (로그: $LOG_FILE)"
if is_windows && [[ -f "$ROOT/start.sh" ]]; then
nohup bash "$ROOT/start.sh" >>"$LOG_FILE" 2>&1 &
elif is_windows; then
WIN_ROOT="$ROOT"
if command -v cygpath >/dev/null 2>&1; then
WIN_ROOT="$(cygpath -w "$ROOT")"
fi
if [[ -f "$ROOT/start.bat" ]]; then
nohup cmd.exe /c "ping 127.0.0.1 -n 3 >nul & cd /d \"$WIN_ROOT\" & call \"$WIN_ROOT\\start.bat\"" >>"$LOG_FILE" 2>&1 &
elif [[ -f "$ROOT/start_win.sh" ]]; then
nohup bash "$ROOT/start_win.sh" >>"$LOG_FILE" 2>&1 &
else
echo "[$HOST_TAG] start.sh / start.bat 없음" >&2
exit 1
fi
else
nohup "$ROOT/start.sh" >>"$LOG_FILE" 2>&1 &
fi
echo "[$HOST_TAG] PID $!"
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if ! command -v brew >/dev/null 2>&1; then
echo "Homebrew 가 필요합니다: https://brew.sh"
exit 1
fi
brew list libusb >/dev/null 2>&1 || brew install libusb
python3 -m venv .venv
# shellcheck disable=SC1091
source .venv/bin/activate
pip install -U pip
# vendor wheel 우선 (오프라인 가능)
pip install --no-index --find-links=vendor/wheels -r requirements.txt \
|| pip install --find-links=vendor/wheels -r requirements.txt
pip install --no-index --find-links=vendor/wheels --no-build-isolation -e . \
|| pip install --find-links=vendor/wheels -e .
echo
echo "설치 완료. 실행:"
echo " ./start.sh"
echo "예:"
echo " source $ROOT/.venv/bin/activate"
echo " juc500 info"
echo " juc500 gui"
echo " juc500 recv -y --dest ~/Downloads"
echo " juc500 send /path/to/file"
@@ -0,0 +1,108 @@
@echo off
setlocal EnableExtensions
cd /d "%~dp0.."
set "ROOT=%CD%"
set "WHEELS=%ROOT%\vendor\wheels"
set "GETPIP=%ROOT%\vendor\get-pip.py"
set "LIBUSB=%ROOT%\vendor\libusb-1.0.dll"
set "EMBED_PY=%ROOT%\vendor\python-windows\python.exe"
echo ============================================
echo JUC500 Windows offline setup
echo ============================================
if not exist "%WHEELS%\pyusb-1.2.1-py3-none-any.whl" (
echo [!] Missing vendor\wheels. Copy the full source tree including vendor\.
exit /b 1
)
if not exist "%LIBUSB%" (
echo [!] Missing vendor\libusb-1.0.dll
exit /b 1
)
REM Remove Mac-copied broken .venv
if exist "%ROOT%\.venv\bin\python" (
if not exist "%ROOT%\.venv\Scripts\python.exe" (
echo [!] Mac .venv detected - removing...
rmdir /s /q "%ROOT%\.venv"
)
)
REM Choose bootstrap Python: system first, else vendored embeddable
set "BOOT_PY="
where py >nul 2>&1 && set "BOOT_PY=py -3"
if not defined BOOT_PY (
where python >nul 2>&1 && set "BOOT_PY=python"
)
if not defined BOOT_PY (
if exist "%EMBED_PY%" (
set "BOOT_PY=%EMBED_PY%"
echo Using vendored Python: vendor\python-windows\python.exe
)
)
if not defined BOOT_PY (
echo [!] No Python found. vendor\python-windows is missing.
exit /b 1
)
echo Bootstrap: %BOOT_PY%
REM Ensure virtualenv module available (embeddable has no stdlib venv)
%BOOT_PY% -c "import virtualenv" 2>nul
if errorlevel 1 (
echo Installing virtualenv from vendor\wheels ...
%BOOT_PY% -m pip install --no-index --find-links="%WHEELS%" virtualenv
if errorlevel 1 (
if exist "%GETPIP%" (
%BOOT_PY% "%GETPIP%" --no-index --find-links="%WHEELS%" pip setuptools wheel
%BOOT_PY% -m pip install --no-index --find-links="%WHEELS%" virtualenv
)
)
)
if not exist "%ROOT%\.venv\Scripts\python.exe" (
echo Creating .venv (fully offline, no pip seed from internet)...
%BOOT_PY% -m virtualenv --no-periodic-update --no-setuptools --no-pip "%ROOT%\.venv"
if errorlevel 1 (
echo [!] virtualenv failed.
exit /b 1
)
"%ROOT%\.venv\Scripts\python.exe" "%GETPIP%" --no-index --find-links="%WHEELS%" pip setuptools wheel packaging
if errorlevel 1 (
echo [!] get-pip offline bootstrap failed.
exit /b 1
)
goto install_deps
)
"%ROOT%\.venv\Scripts\python.exe" -c "import usb" 2>nul
if errorlevel 1 (
echo [!] .venv incomplete — reinstalling dependencies...
goto install_deps
)
goto copy_libusb
:install_deps
echo Installing dependencies from vendor\wheels (no PyPI)...
"%ROOT%\.venv\Scripts\python.exe" -m pip install --no-index --find-links="%WHEELS%" -U pip setuptools wheel packaging
"%ROOT%\.venv\Scripts\python.exe" -m pip install --no-index --find-links="%WHEELS%" -r "%ROOT%\requirements.txt"
if errorlevel 1 (
echo [!] requirements install failed.
exit /b 1
)
"%ROOT%\.venv\Scripts\python.exe" -m pip install --no-index --find-links="%WHEELS%" --no-build-isolation -e "%ROOT%"
if errorlevel 1 (
echo [!] editable install failed.
exit /b 1
)
:copy_libusb
copy /Y "%LIBUSB%" "%ROOT%\.venv\Scripts\libusb-1.0.dll" >nul
echo libusb-1.0.dll -^> .venv\Scripts\
echo.
echo Setup done (offline). Run:
echo .\start.bat
echo or Git Bash: ./start_win.sh
echo.
endlocal
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Tauri 케이블 감시 앱 종료 (개발 중 ./start.sh 와 충돌 방지)
set -euo pipefail
stopped=0
for pattern in \
"juc500-app/src-tauri/target/release/juc500-app" \
"juc500-app/src-tauri/target/debug/juc500-app" \
"/Applications/JUC500.app/Contents/MacOS/juc500-app" \
"juc500-app.exe"; do
if pkill -f "$pattern" 2>/dev/null; then
echo "종료: $pattern"
stopped=1
fi
done
if [[ "$stopped" == "0" ]]; then
echo "실행 중인 Tauri juc500-app 없음"
fi
@@ -0,0 +1,233 @@
#!/usr/bin/env bash
# juc500 — 수정한 파일을 USB 연결된 상대편으로 전송 (동기화만, 재시작은 수동)
#
# 개발 절차:
# 1) 소스 수정
# 2) ./scripts/sync_peer.sh ← 이 스크립트 (상대로 파일 전송)
# 3) 이 PC에서 ./start.sh
# 4) 상대 PC (Void/Git Bash): cd C:/dev/juc500 && ./start.sh
#
# 상대 경로 (절대 경로, 슬래시 사용):
# Windows 기본: C:/dev/juc500
# Mac Studio: /Users/aidev/dev/juc500/juc500 (USE_MAC_PEER_PATHS=1)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
GUI_URL="${JUC500_GUI_URL:-http://127.0.0.1:8765}"
# Dev-only manual sync. Runtime does not auto-sync; set JUC500_AUTO_SYNC_PEER=1 to enable on connect.
# Windows peer at C:\dev\juc500 (Void에서 ./start.sh 실행 경로)
PEER_ROOT="${JUC500_PEER_ROOT:-C:/dev/juc500}"
PEER_ROOT_ALT="${JUC500_PEER_ROOT_ALT:-C:/dev/juc500/juc500}"
# Mac peer (browse 홈 기준 상대경로) — JUC500_PEER_ROOT 비우고 USE_MAC_PEER_PATHS=1
MAC_PEER_XFER="dev/juc500/juc500/juc500_xfer"
MAC_PEER_STATIC="dev/juc500/juc500/juc500_xfer/static"
MAC_PEER_SCRIPTS="dev/juc500/juc500/scripts"
MAC_PEER_ROOT_DIR="dev/juc500/juc500"
if [[ "${USE_MAC_PEER_PATHS:-0}" == "1" ]]; then
PEER_XFER="$MAC_PEER_XFER"
PEER_STATIC="$MAC_PEER_STATIC"
PEER_SCRIPTS="$MAC_PEER_SCRIPTS"
PEER_ROOT_DIR="$MAC_PEER_ROOT_DIR"
else
PEER_XFER="${PEER_ROOT}/juc500_xfer"
PEER_STATIC="${PEER_ROOT}/juc500_xfer/static"
PEER_SCRIPTS="${PEER_ROOT}/scripts"
PEER_ROOT_DIR="${PEER_ROOT}"
if [[ "$PEER_ROOT_ALT" != "$PEER_ROOT" ]]; then
PEER_XFER_ALT="${PEER_ROOT_ALT}/juc500_xfer"
PEER_STATIC_ALT="${PEER_ROOT_ALT}/juc500_xfer/static"
PEER_SCRIPTS_ALT="${PEER_ROOT_ALT}/scripts"
PEER_ROOT_DIR_ALT="${PEER_ROOT_ALT}"
fi
fi
PY=""
if [[ -x "$ROOT/.venv/bin/python" ]]; then
PY="$ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PY="$(command -v python3)"
else
echo "python3 없음" >&2
exit 1
fi
send_files() {
local dest="$1"
shift
"$PY" - "$dest" "$GUI_URL" "$@" <<'PY'
import json, sys, urllib.request
dest, gui_url = sys.argv[1], sys.argv[2]
paths = sys.argv[3:]
body = json.dumps({"paths": paths, "dest_dir": dest}).encode()
req = urllib.request.Request(
f"{gui_url}/api/local/send",
data=body,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as r:
print(r.read().decode())
PY
}
if ! curl -sf -m3 "$GUI_URL/api/status" >/dev/null 2>&1; then
echo "GUI가 떠 있지 않습니다. 먼저 ./start.sh 로 실행하세요." >&2
exit 1
fi
CONNECTED=$("$PY" -c "
import json, urllib.request
with urllib.request.urlopen('$GUI_URL/api/status', timeout=3) as r:
print(json.load(r).get('connected'))
" 2>/dev/null || echo "False")
if [[ "$CONNECTED" != "True" ]]; then
echo "상대편에 연결되지 않았습니다. 양쪽 GUI에서 「연결」을 누르세요." >&2
exit 1
fi
if [[ $# -gt 0 ]]; then
for f in "$@"; do
if [[ ! -f "$f" ]]; then
echo "파일 없음: $f" >&2
exit 1
fi
done
send_files "$PEER_XFER" "$@"
else
echo "=== 기본 파일 전송 → ${PEER_XFER} ==="
XFER_FILES=(
"$ROOT/juc500_xfer/gui_server.py"
"$ROOT/juc500_xfer/usb_link.py"
"$ROOT/juc500_xfer/inputshare.py"
"$ROOT/juc500_xfer/protocol.py"
"$ROOT/juc500_xfer/peer_sync.py"
"$ROOT/juc500_xfer/clipboard_files.py"
"$ROOT/juc500_xfer/__init__.py"
)
STATIC_FILES=(
"$ROOT/juc500_xfer/static/app.js"
"$ROOT/juc500_xfer/static/index.html"
"$ROOT/juc500_xfer/static/styles.css"
)
send_files "$PEER_XFER" "${XFER_FILES[@]}"
send_files "$PEER_STATIC" "${STATIC_FILES[@]}"
if [[ -n "${PEER_XFER_ALT:-}" ]]; then
echo "=== legacy editable 경로 → ${PEER_XFER_ALT} ==="
send_files "$PEER_XFER_ALT" "${XFER_FILES[@]}"
send_files "$PEER_STATIC_ALT" "${STATIC_FILES[@]}"
fi
if [[ -f "$ROOT/scripts/peer_restart.sh" ]]; then
send_files "$PEER_SCRIPTS" "$ROOT/scripts/peer_restart.sh"
if [[ -n "${PEER_SCRIPTS_ALT:-}" ]]; then
send_files "$PEER_SCRIPTS_ALT" "$ROOT/scripts/peer_restart.sh"
fi
fi
if [[ -f "$ROOT/start.sh" ]]; then
send_files "$PEER_ROOT_DIR" "$ROOT/start.sh"
if [[ -n "${PEER_ROOT_DIR_ALT:-}" ]]; then
send_files "$PEER_ROOT_DIR_ALT" "$ROOT/start.sh"
fi
fi
if [[ -f "$ROOT/start.bat" ]]; then
send_files "$PEER_ROOT_DIR" "$ROOT/start.bat"
if [[ -n "${PEER_ROOT_DIR_ALT:-}" ]]; then
send_files "$PEER_ROOT_DIR_ALT" "$ROOT/start.bat"
fi
fi
if [[ -f "$ROOT/start_win.sh" ]]; then
send_files "$PEER_ROOT_DIR" "$ROOT/start_win.sh"
if [[ -n "${PEER_ROOT_DIR_ALT:-}" ]]; then
send_files "$PEER_ROOT_DIR_ALT" "$ROOT/start_win.sh"
fi
fi
if [[ -f "$ROOT/start_both.sh" ]]; then
send_files "$PEER_ROOT_DIR" "$ROOT/start_both.sh"
if [[ -n "${PEER_ROOT_DIR_ALT:-}" ]]; then
send_files "$PEER_ROOT_DIR_ALT" "$ROOT/start_both.sh"
fi
fi
if [[ -f "$ROOT/README.md" ]]; then
send_files "$PEER_ROOT_DIR" "$ROOT/README.md"
if [[ -n "${PEER_ROOT_DIR_ALT:-}" ]]; then
send_files "$PEER_ROOT_DIR_ALT" "$ROOT/README.md"
fi
fi
if [[ -f "$ROOT/vendor/libusb-1.0.dll" ]]; then
echo "=== libusb DLL → ${PEER_ROOT}/vendor ==="
send_files "${PEER_ROOT}/vendor" "$ROOT/vendor/libusb-1.0.dll"
if [[ -n "${PEER_ROOT_ALT:-}" && "$PEER_ROOT_ALT" != "$PEER_ROOT" ]]; then
send_files "${PEER_ROOT_ALT}/vendor" "$ROOT/vendor/libusb-1.0.dll"
fi
fi
for win_script in ensure_libusb_windows.bat setup_windows.bat bootstrap_windows.sh make_windows_bundle.sh make_offline_windows_update.sh apply_offline_update.bat; do
if [[ -f "$ROOT/scripts/$win_script" ]]; then
send_files "$PEER_SCRIPTS" "$ROOT/scripts/$win_script"
if [[ -n "${PEER_SCRIPTS_ALT:-}" ]]; then
send_files "$PEER_SCRIPTS_ALT" "$ROOT/scripts/$win_script"
fi
fi
done
if [[ -f "$ROOT/vendor/tools/zadig.exe" ]]; then
echo "=== zadig → ${PEER_ROOT}/vendor/tools ==="
send_files "${PEER_ROOT}/vendor/tools" "$ROOT/vendor/tools/zadig.exe"
fi
fi
LOCAL_INPUTSHARE=$("$PY" -c "from juc500_xfer.inputshare import INPUTSHARE_BUILD; print(INPUTSHARE_BUILD)" 2>/dev/null || echo "unknown")
LOCAL_XFER=$("$PY" -c "from juc500_xfer.usb_link import XFER_BUILD; print(XFER_BUILD)" 2>/dev/null || echo "unknown")
echo ""
echo "로컬 build — inputshare: $LOCAL_INPUTSHARE · xfer: $LOCAL_XFER"
echo "상대편 build 확인 중 (RPC ping, 재시작 전이면 구버전일 수 있음)…"
read -r PEER_INPUTSHARE PEER_XFER < <(
curl -sf -m10 "$GUI_URL/api/peer/ping" 2>/dev/null | "$PY" -c "
import json,sys
try:
d=json.load(sys.stdin)
print(d.get('inputshare_build') or 'unknown', d.get('xfer_build') or 'unknown')
except Exception:
print('check-failed check-failed')
" 2>/dev/null || echo "check-failed check-failed"
)
echo "상대편 build — inputshare: $PEER_INPUTSHARE · xfer: $PEER_XFER"
if [[ "$PEER_INPUTSHARE" == "$LOCAL_INPUTSHARE" && "$PEER_XFER" == "$LOCAL_XFER" ]]; then
echo "✓ 상대편 build 일치"
else
echo "⚠ 상대편 build 불일치 — 아래 3·4단계에서 양쪽 ./start.sh 로 새 코드 로드"
fi
print_dev_workflow() {
echo ""
echo "=== 개발 적용 절차 (남은 단계) ==="
echo "3) 이 PC: cd $ROOT && ./start.sh"
if [[ "${USE_MAC_PEER_PATHS:-0}" == "1" ]]; then
echo "4) 상대 Mac: cd /Users/aidev/dev/juc500/juc500 && ./start.sh"
else
echo "4) 상대 PC: cd ${PEER_ROOT} && ./start.sh (Void/Git Bash)"
echo " 시작 시 'JUC500 build: ...juc700-win9 | ...\\juc500_xfer\\usb_link.py' 확인"
if [[ -n "${PEER_XFER_ALT:-}" ]]; then
echo " ※ legacy editable 경로(${PEER_XFER_ALT})에도 동기화함"
fi
fi
echo ""
echo "build 확인: curl -s $GUI_URL/api/status | grep xfer_build"
echo " curl -s $GUI_URL/api/peer/ping"
}
# 개발 중에는 RPC 자동 재시작을 쓰지 않음 (루프·경로 오류 방지). 필요 시 JUC500_SYNC_RESTART=1
if [[ "${JUC500_SYNC_RESTART:-0}" == "1" ]]; then
echo ""
echo "=== RPC 자동 재시작 (JUC500_SYNC_RESTART=1) ==="
resp="$(curl -sf -m25 -X POST "$GUI_URL/api/restart/peer" -H 'Content-Type: application/json' -d '{}' || true)"
if [[ -n "$resp" ]]; then
echo "$resp"
else
echo "⚠ 상대 RPC 재시작 실패 — 4단계에서 수동 ./start.sh" >&2
fi
fi
print_dev_workflow
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# macOS 로그인 자동 실행(Tauri 케이블 감시) 제거
set -euo pipefail
LABEL="com.juc500.desktop"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true
if [[ -f "$PLIST" ]]; then
rm -f "$PLIST"
echo "제거: $PLIST"
else
echo "LaunchAgent 없음: $PLIST"
fi
"$(cd "$(dirname "$0")" && pwd)/stop_tauri_watchers.sh"
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Verify JUC500 connect → JUC700 switch via local GUI API."""
from __future__ import annotations
import json
import sys
import time
import urllib.error
import urllib.request
GUI = "http://127.0.0.1:8765"
POLL = 1.0
CONNECT_DEADLINE = 180.0
SWITCH_DEADLINE = 180.0
def api(path: str, *, method: str = "GET", body: dict | None = None, timeout: float = 15.0):
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(f"{GUI}{path}", data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
def status() -> dict:
return api("/api/status", timeout=8.0)
def wait_connected(deadline: float, *, label: str) -> dict:
end = time.monotonic() + deadline
last: dict = {}
started = time.monotonic()
last_retry = started
while time.monotonic() < end:
try:
last = status()
except Exception as exc:
print(f"[{label}] status error: {exc}", flush=True)
time.sleep(POLL)
continue
conn = bool(last.get("connected"))
model = last.get("cable_model") or last.get("selected_cable_model")
sel = last.get("selected_cable_model")
connecting = bool(last.get("connecting"))
err = last.get("connect_error")
print(
f"[{label}] connected={conn} model={model} selected={sel} "
f"connecting={connecting} err={err!r}",
flush=True,
)
if conn:
return last
if err and not connecting:
retryable = any(
k in (err or "")
for k in (
"session stopped",
"연결 시간 초과",
"연결되지 않았습니다",
"Access denied",
"점유 실패",
)
)
if retryable and now - last_retry >= 5.0:
try:
api("/api/disconnect", method="POST", body={})
time.sleep(1.0)
api("/api/connect/auto", method="POST", body={})
print(f"[{label}] retry after: {err}", flush=True)
except Exception as exc:
print(f"[{label}] retry error: {exc}", flush=True)
last_retry = now
time.sleep(POLL)
continue
raise RuntimeError(f"{label} failed: {err}")
now = time.monotonic()
if now - last_retry >= 70.0 and (connecting or now - started >= 25.0):
try:
api("/api/disconnect", method="POST", body={})
time.sleep(1.0)
api("/api/connect/auto", method="POST", body={})
print(f"[{label}] retry disconnect+connect/auto", flush=True)
except Exception as exc:
print(f"[{label}] retry error: {exc}", flush=True)
last_retry = now
time.sleep(POLL)
raise TimeoutError(f"{label}: not connected within {deadline:.0f}s — last={last}")
def verify_listdir(label: str) -> int:
try:
data = api("/api/listdir?path=__roots__", timeout=30.0)
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
raise RuntimeError(f"{label} listdir HTTP {exc.code}: {body}") from exc
n = len(data.get("entries") or [])
print(f"[{label}] listdir roots={n}", flush=True)
if n <= 0:
raise RuntimeError(f"{label} listdir returned no entries")
return n
def select_cable(model: str) -> dict:
print(f"\n=== SELECT {model} ===", flush=True)
return api("/api/cable/select", method="POST", body={"model": model}, timeout=20.0)
def assert_model(s: dict, expected: str, label: str) -> None:
sel = (s.get("selected_cable_model") or "").upper()
active = (s.get("cable_model") or "").upper()
dev = s.get("active_cable") or s.get("device") or {}
dev_model = (dev.get("model") or "").upper()
pid = (dev.get("pid") or "").lower()
if expected == "JUC500":
pid_ok = pid == "7500"
else:
pid_ok = pid.startswith("700") or pid == "7500"
if active != expected:
raise RuntimeError(
f"{label}: cable_model={active!r} expected {expected} "
f"(selected={sel}, device={dev_model}, pid={pid})"
)
if not pid_ok:
raise RuntimeError(f"{label}: unexpected pid={pid} for {expected}")
print(
f"[{label}] OK model={active} selected={sel} pid={pid} host={s.get('hostname')}",
flush=True,
)
def main() -> int:
print("Cable switch verification", flush=True)
try:
s0 = status()
except Exception as exc:
print(f"GUI not reachable at {GUI}: {exc}", file=sys.stderr)
return 1
devices = s0.get("devices") or []
models = {d.get("model") for d in devices}
print(f"Detected devices: {models}", flush=True)
if "JUC500" not in models or "JUC700" not in models:
print("WARN: both JUC500 and JUC700 should be plugged in for full test", flush=True)
sel0 = (s0.get("selected_cable_model") or "JUC500").upper()
if sel0 != "JUC500":
select_cable("JUC500")
elif not s0.get("connected"):
print("Waiting for autoconnect on JUC500…", flush=True)
# Step 1: JUC500
s500 = wait_connected(CONNECT_DEADLINE, label="JUC500")
assert_model(s500, "JUC500", "JUC500-connected")
verify_listdir("JUC500")
# Step 2: switch to JUC700
select_cable("JUC700")
s700 = wait_connected(SWITCH_DEADLINE, label="JUC700")
assert_model(s700, "JUC700", "JUC700-connected")
verify_listdir("JUC700")
# Step 3: optional ping
try:
ping = api("/api/peer/ping", timeout=15.0)
print(f"[JUC700] peer ping: {ping}", flush=True)
except Exception as exc:
print(f"[JUC700] peer ping skipped: {exc}", flush=True)
print("\n=== ALL CHECKS PASSED ===", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,42 @@
@echo off
REM Windows helper: same as ./start.sh (Git Bash)
cd /d "%~dp0"
if not exist "vendor\libusb-1.0.dll" (
echo [!] vendor\libusb-1.0.dll missing — zip with vendor\ folder required.
pause
exit /b 1
)
if not exist ".venv\Scripts\python.exe" (
echo [*] First run — running offline setup...
call "scripts\setup_windows.bat"
if errorlevel 1 exit /b 1
)
if exist "vendor\libusb-1.0.dll" (
copy /Y "vendor\libusb-1.0.dll" ".venv\Scripts\libusb-1.0.dll" >nul 2>&1
)
".venv\Scripts\python.exe" -c "import usb" 2>nul
if errorlevel 1 (
echo [!] pyusb missing — reinstalling...
call "scripts\setup_windows.bat"
if errorlevel 1 exit /b 1
)
set "PYTHONPATH=%CD%;%PYTHONPATH%"
set "JUC500_ROOT=%CD%"
REM Dev: manual start.sh — Python GUI unless JUC500_USE_TAURI=1
if not "%JUC500_USE_TAURI%"=="1" goto legacy
if exist "juc500-app\src-tauri\target\release\juc500-app.exe" (
start "" "juc500-app\src-tauri\target\release\juc500-app.exe"
exit /b 0
)
if exist "juc500-app\src-tauri\target\debug\juc500-app.exe" (
start "" "juc500-app\src-tauri\target\debug\juc500-app.exe"
exit /b 0
)
if exist "scripts\launch_tauri.bat" (
call "scripts\launch_tauri.bat"
exit /b %ERRORLEVEL%
)
:legacy
REM Kill other juc500 instances holding USB Interface 5
".venv\Scripts\python.exe" -c "from juc500_xfer.wormhole_kill import kill_other_juc500_processes; r=kill_other_juc500_processes(force=True); print(r.get('message') or '')" 2>nul
".venv\Scripts\python.exe" "%CD%\juc500_app.py" %*
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# JUC500 GUI 실행 (인자 없으면 GUI, 있으면 CLI로 전달)
# macOS / Windows (Git Bash) 공통
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
is_windows() {
case "$(uname -s 2>/dev/null || echo unknown)" in
MINGW*|MSYS*|CYGWIN*) return 0 ;;
*) return 1 ;;
esac
}
setup_hint() {
if is_windows; then
echo " scripts\\setup_windows.bat (또는 ./start_win.sh 재실행 — 자동 설정)" >&2
else
echo " ./scripts/setup_macos.sh" >&2
fi
}
# Windows zip 복사 후: Mac .venv 제거 + vendor 로 .venv 자동 생성
if is_windows; then
if [[ -f "$ROOT/scripts/bootstrap_windows.sh" ]]; then
bash "$ROOT/scripts/bootstrap_windows.sh" || exit 1
fi
fi
# venv Python 선택 (Windows: Scripts\python.exe, Unix: bin/python)
# 크로스 OS로 복사된 .venv 대비 — 현재 플랫폼에 맞는 인터프리터만 고른다.
PY=""
if is_windows && [[ -f "$ROOT/.venv/Scripts/python.exe" ]]; then
PY="$ROOT/.venv/Scripts/python.exe"
elif [[ -x "$ROOT/.venv/bin/python" ]] && "$ROOT/.venv/bin/python" -c "import sys" 2>/dev/null; then
PY="$ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PY="$(command -v python3)"
elif command -v python >/dev/null 2>&1; then
PY="$(command -v python)"
else
echo "Python 이 없습니다. 먼저 설치하세요:" >&2
setup_hint
exit 1
fi
if is_windows; then
if [[ ! -f "$ROOT/.venv/Scripts/python.exe" ]]; then
echo "경고: .venv 없음 — 시스템 Python 사용. 문제가 있으면:" >&2
setup_hint
fi
elif [[ ! -x "$ROOT/.venv/bin/python" ]]; then
echo "경고: .venv 없음 — 시스템 Python 사용. 문제가 있으면:" >&2
setup_hint
fi
# Windows 네이티브 Python 은 PYTHONPATH 에 Win 경로·세미콜론을 기대
if is_windows; then
WINROOT=""
if command -v cygpath >/dev/null 2>&1; then
WINROOT="$(cygpath -w "$ROOT")"
elif WINROOT="$(cd "$ROOT" && pwd -W 2>/dev/null)" && [[ -n "$WINROOT" ]]; then
:
else
# MSYS /c/dev/juc500 → C:/dev/juc500 (Windows python 호환)
WINROOT="$ROOT"
if [[ "$WINROOT" =~ ^/([a-zA-Z])/(.*)$ ]]; then
WINROOT="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
fi
fi
export PYTHONPATH="${WINROOT}${PYTHONPATH:+;$PYTHONPATH}"
export JUC500_ROOT="${WINROOT}"
else
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
export JUC500_ROOT="$ROOT"
fi
# Windows: libusb DLL 은 python.exe 옆에 있어야 pyusb 가 로드함
if is_windows; then
libusb_src="$ROOT/vendor/libusb-1.0.dll"
py_dir="$(dirname "$PY")"
libusb_dst="$py_dir/libusb-1.0.dll"
if [[ -f "$libusb_src" ]]; then
cp -f "$libusb_src" "$libusb_dst" 2>/dev/null || true
echo "libusb-1.0.dll → $py_dir/"
elif [[ ! -f "$libusb_dst" ]]; then
echo "경고: vendor/libusb-1.0.dll 없음 — vendor/ 포함 zip 인지 확인하세요" >&2
fi
fi
# editable install 가 예전 경로(C:\dev\juc500\juc500)를 가리키면 구버전이 로드됨 — 매 기동 시 현재 ROOT 로 고정
if [[ -f "$ROOT/pyproject.toml" ]]; then
pip_args=(-m pip install -e "$ROOT" --no-deps -q)
if [[ -d "$ROOT/vendor/wheels" ]]; then
pip_args+=(--no-index --find-links="$ROOT/vendor/wheels")
fi
"$PY" "${pip_args[@]}" 2>/dev/null || true
fi
# 의존성 빠른 확인
if ! "$PY" -c "import usb" 2>/dev/null; then
echo "pyusb 가 없습니다. 먼저 설치하세요:" >&2
setup_hint
exit 1
fi
if is_windows; then
libusb_ok="$(
"$PY" -c "
from juc500_xfer.usb_link import libusb_path
p = libusb_path()
print(p or '')
import sys
sys.exit(0 if p else 1)
" 2>/dev/null || true
)"
if [[ -z "$libusb_ok" ]]; then
echo "libusb-1.0 를 로드하지 못했습니다. Windows에서:" >&2
echo " scripts\\ensure_libusb_windows.bat" >&2
echo " 또는 scripts\\setup_windows.bat" >&2
exit 1
fi
echo "libusb: $libusb_ok"
fi
# 기존 juc500 인스턴스가 IF5를 점유하면 Access denied 가 난다 — 먼저 종료
kill_msg="$(
"$PY" -c "
from juc500_xfer.wormhole_kill import kill_other_juc500_processes
r = kill_other_juc500_processes(force=True)
print(r.get('message') or '')
" 2>/dev/null || true
)"
if [[ -n "${kill_msg}" ]]; then
echo "$kill_msg"
fi
build_info="$(
"$PY" -c "
import juc500_xfer.usb_link as u
print(getattr(u, 'XFER_BUILD', '?'), '|', u.__file__)
" 2>/dev/null || echo "? | import failed"
)"
echo "JUC500 build: ${build_info}"
# 개발 중: Python GUI 직접 실행. Tauri 케이블 감시는 JUC500_USE_TAURI=1 일 때만.
if [[ "${JUC500_USE_TAURI:-0}" == "1" && -f "$ROOT/scripts/launch_tauri.sh" ]]; then
TAURI_RELEASE="$ROOT/juc500-app/src-tauri/target/release/juc500-app"
TAURI_DEBUG="$ROOT/juc500-app/src-tauri/target/debug/juc500-app"
if [[ -x "$TAURI_RELEASE" || -x "$TAURI_DEBUG" ]]; then
exec "$ROOT/scripts/launch_tauri.sh"
fi
fi
exec "$PY" "$ROOT/juc500_app.py" "$@"
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# juc500 — 로컬 + 상대편(Mac Studio) JUC500을 동시에 재시작
#
# 상대편 재시작 순서:
# 1) USB 연결 + GUI 실행 중 → /api/restart/both (RPC로 상대도 재시작)
# 2) SSH 가능 시 → 상대편에서 scripts/peer_restart.sh 실행
#
# 환경 변수:
# JUC500_PEER_SSH 기본 aidev@aidevs-Mac-Studio.local
# JUC500_PEER_ROOT 기본 /Users/aidev/dev/juc500/juc500
# JUC500_GUI_URL 기본 http://127.0.0.1:8766
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
PEER_SSH="${JUC500_PEER_SSH:-aidev@aidevs-Mac-Studio.local}"
PEER_ROOT="${JUC500_PEER_ROOT:-/Users/aidev/dev/juc500/juc500}"
GUI_URL="${JUC500_GUI_URL:-http://127.0.0.1:8766}"
PY=""
if [[ -x "$ROOT/.venv/bin/python" ]]; then
PY="$ROOT/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
PY="$(command -v python3)"
else
echo "python3 없음" >&2
exit 1
fi
chmod +x "$ROOT/scripts/peer_restart.sh" 2>/dev/null || true
pick_gui_url() {
local url
for url in "$GUI_URL" "http://127.0.0.1:8766" "http://127.0.0.1:8765"; do
if curl -sf -m2 "$url/api/status" >/dev/null 2>&1; then
echo "$url"
return 0
fi
done
return 1
}
restart_via_usb_api() {
local base url resp connected
base="$(pick_gui_url)" || return 1
connected="$("$PY" -c "
import json, urllib.request, sys
with urllib.request.urlopen('${base}/api/status', timeout=3) as r:
print('yes' if json.load(r).get('connected') else 'no')
" 2>/dev/null || echo no)"
if [[ "$connected" != "yes" ]]; then
echo "USB API: GUI는 실행 중이나 상대와 연결되지 않음 ($base)" >&2
return 1
fi
echo "=== USB 연결됨 — API로 양쪽 재시작 ($base) ==="
resp="$(curl -sf -m20 -X POST "$base/api/restart/both" -H 'Content-Type: application/json' -d '{}' || true)"
if [[ -z "$resp" ]]; then
echo "USB API 재시작 요청 실패" >&2
return 1
fi
echo "$resp"
return 0
}
restart_peer_ssh() {
echo "=== 상대편 SSH 재시작 ($PEER_SSH) ==="
ssh -o BatchMode=yes -o ConnectTimeout=12 "$PEER_SSH" \
"bash -lc 'cd \"$PEER_ROOT\" && chmod +x scripts/peer_restart.sh start.sh 2>/dev/null; ./scripts/peer_restart.sh'"
}
restart_local() {
echo "=== 로컬 재시작 ==="
"$ROOT/scripts/peer_restart.sh"
}
echo "juc500 양쪽 재시작 시작…"
echo " 로컬: $ROOT"
echo " 상대: $PEER_SSH:$PEER_ROOT"
echo ""
# USB로 양쪽 동시 재시작 (연결된 경우 — RPC로 상대도 재시작됨)
if pick_gui_url >/dev/null 2>&1; then
if [[ "${JUC500_SYNC_BEFORE_RESTART:-1}" != "0" ]]; then
echo "=== 상대편 소스 동기화 ==="
if JUC500_SYNC_RESTART=0 JUC500_GUI_URL="$(pick_gui_url)" "$ROOT/scripts/sync_peer.sh"; then
echo "동기화 완료"
else
echo "동기화 건너뜀 (연결 없음 또는 실패)" >&2
fi
echo ""
fi
fi
if restart_via_usb_api; then
echo ""
echo "완료 — 잠시 후 양쪽 GUI가 다시 뜹니다. 한쪽에서 「연결」을 누르세요."
exit 0
fi
# 폴백: SSH 상대 + 로컬 병렬 재시작
PEER_RC=0
LOCAL_RC=0
restart_peer_ssh &
PID_PEER=$!
restart_local &
PID_LOCAL=$!
wait "$PID_PEER" || PEER_RC=$?
wait "$PID_LOCAL" || LOCAL_RC=$?
echo ""
if [[ "$LOCAL_RC" -eq 0 ]]; then
echo "로컬 재시작: OK"
else
echo "로컬 재시작: 실패" >&2
fi
if [[ "$PEER_RC" -eq 0 ]]; then
echo "상대 재시작: OK"
else
echo "상대 재시작: 실패 (SSH $PEER_SSH 확인 또는 USB 연결 후 다시 실행)" >&2
fi
if [[ "$LOCAL_RC" -ne 0 ]]; then
exit 1
fi
echo ""
echo "완료 — 한쪽에서 「연결」을 누르면 양쪽이 자동 연결됩니다."
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# JUC500 Windows 실행 (Git Bash / MSYS / Cygwin) — start.sh 로 위임
# zip 복사 후 첫 실행: vendor/ 로 .venv·libusb 자동 설정
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
exec "$ROOT/start.sh" "$@"
@@ -0,0 +1,17 @@
# vendor — 폐쇄망 Windows 번들
| 경로 | 용도 |
|------|------|
| `libusb-1.0.dll` | Windows USB (x64) — 반드시 `.venv\Scripts\` 또는 `python.exe` 옆에 복사 |
| `wheels\` | 모든 pip wheel (오프라인 설치) |
| `python-windows\` | Python 3.11.9 embeddable + pip (시스템에 Python 없을 때) |
| `get-pip.py` | 오프라인 pip 부트스트랩 |
| `tools\zadig.exe` | 폐쇄망 WinUSB 바인딩 (SmartKMLink / JUC500 IF5) |
자세한 절차: [`docs/OFFLINE-WINDOWS.md`](../docs/OFFLINE-WINDOWS.md)
소스만 갱신(이미 설치된 PC):
```bash
./scripts/make_offline_windows_update.sh
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
# Offline Python wheels (Windows / macOS)
이 폴더는 **폐쇄망**에서도 `pip install --no-index` 로 설치할 수 있도록
런타임·빌드용 wheel 을 포함합니다.
## 포함 패키지 (요약)
| 용도 | 패키지 |
|------|--------|
| 런타임 | `pyusb`, `pynput`, `six` |
| 빌드/설치 | `pip`, `setuptools`, `wheel`, `packaging`, `build`, `pyproject_hooks`, `colorama` |
| venv 생성 | `virtualenv` (+ `distlib`, `filelock`, `platformdirs`, `python_discovery`) |
## 설치
Windows (권장):
```bat
scripts\setup_windows.bat
```
수동:
```bat
python -m pip install --no-index --find-links=vendor\wheels -r requirements.txt
python -m pip install --no-index --find-links=vendor\wheels --no-build-isolation -e .
```
## wheel 갱신 (온라인 PC에서)
```bat
python -m pip download -d vendor\wheels ^
pyusb==1.2.1 pynput==1.8.2 six==1.17.0 ^
"setuptools>=68" wheel packaging pip build virtualenv
```