Add Windows installer packaging (Setup.exe + portable exe)

PyInstaller onedir/onefile specs, Inno Setup script, and GitHub Actions
workflow to produce JUC500-Setup-win64.exe and portable builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
joyharam
2026-07-15 10:26:10 +09:00
commit 8774a4843e
31 changed files with 4677 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
name: Build Windows 64-bit
on:
workflow_dispatch:
push:
paths:
- "juc500_xfer/**"
- "juc500_app.py"
- "juc500-windows.spec"
- "juc500-windows-onefile.spec"
- "build-installer-windows.bat"
- "build-installer-windows.sh"
- "packaging/windows/**"
- "vendor/libusb-1.0.dll"
- "requirements.txt"
- ".github/workflows/build-windows.yml"
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Verify libusb DLL
shell: pwsh
run: |
if (-not (Test-Path "vendor\libusb-1.0.dll")) {
throw "vendor/libusb-1.0.dll missing — commit the Windows x64 DLL"
}
- name: Build with PyInstaller
shell: cmd
run: |
python -m pip install -U pip pyinstaller pyusb
python -m PyInstaller --noconfirm --clean juc500-windows.spec
if not exist dist\JUC500\JUC500.exe exit /b 1
if not exist dist\JUC500\_internal\static\index.html (
if not exist dist\JUC500\_internal\juc500_xfer\static\index.html (
echo ERROR: static index.html missing
dir /s /b dist\JUC500\_internal 2>nul
exit /b 1
)
)
python -m PyInstaller --noconfirm --clean juc500-windows-onefile.spec
if not exist dist\JUC500-portable.exe exit /b 1
copy /Y packaging\windows\install.ps1 dist\JUC500\install.ps1
copy /Y packaging\windows\setup.bat dist\JUC500\setup.bat
copy /Y packaging\windows\README-WIN.txt dist\JUC500\README-WIN.txt
copy /Y packaging\windows\zadig-hint.url dist\JUC500\zadig-hint.url
copy /Y vendor\libusb-1.0.dll dist\JUC500\libusb-1.0.dll
- name: Smoke help
shell: pwsh
run: |
$env:PYTHONUTF8 = "1"
$p = Start-Process -FilePath "dist\JUC500\JUC500.exe" -ArgumentList "info" -PassThru -Wait -NoNewWindow `
-RedirectStandardOutput "smoke-out.txt" -RedirectStandardError "smoke-err.txt"
Write-Host "--- stdout ---"; Get-Content smoke-out.txt -ErrorAction SilentlyContinue
Write-Host "--- stderr ---"; Get-Content smoke-err.txt -ErrorAction SilentlyContinue
$err = ((Get-Content smoke-err.txt -Raw -ErrorAction SilentlyContinue) + (Get-Content smoke-out.txt -Raw -ErrorAction SilentlyContinue))
if ($err -match "No module named") { throw "import failed: $err" }
Write-Host "smoke ok exit=$($p.ExitCode)"
- name: Zip portable
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist-windows | Out-Null
Compress-Archive -Path dist\JUC500\* -DestinationPath dist-windows\JUC500-win64-portable.zip -Force
Copy-Item dist\JUC500-portable.exe dist-windows\JUC500-portable.exe -Force
Copy-Item packaging\windows\README-WIN.txt dist-windows\README-WIN.txt -Force
Copy-Item packaging\windows\install.ps1 dist-windows\install.ps1 -Force
- name: Install Inno Setup and build Setup.exe
shell: pwsh
run: |
choco install innosetup -y --no-progress
& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" packaging\windows\juc500.iss
if (-not (Test-Path "dist-windows\JUC500-Setup-win64.exe")) {
throw "Setup.exe was not created"
}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: juc500-windows-x64
path: |
dist-windows/JUC500-Setup-win64.exe
dist-windows/JUC500-win64-portable.zip
dist-windows/JUC500-portable.exe
if-no-files-found: error
+16
View File
@@ -0,0 +1,16 @@
.venv/
.venv-build-win/
__pycache__/
*.py[cod]
*.egg-info/
dist/
dist-windows/
build/
.DS_Store
analysis/
*.zip
*.tar.gz
# keep vendor/libusb-1.0.dll in repo for Windows bundling
!vendor/libusb-1.0.dll
.venv-build/
hub.log
+130
View File
@@ -0,0 +1,130 @@
# JUC500 File Transfer (Apple Silicon ↔ Windows 11)
j5create **JUC500** USB 3.0 Wormhole 케이블로 **Apple Silicon Mac**과 **Windows 11** 사이 파일을 직접 전송하는 오픈 구현입니다.
## 왜 공식 앱이 Silicon Mac에서 안 되나
실측/분석 결과:
| 항목 | 내용 |
|------|------|
| USB ID | `0711:7500` / 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
1. Python 3.10+ 설치
2. [libusb](https://libusb.info/) 또는 이 저장소 `vendor/libusb-1.0.dll` 을 PATH/`vendor/` 에 배치
3. **Zadig**로 `Smart Data Link` 복합 장치의
- **Interface 5** (Vendor Specific)
- **Interface 1** (CDC Data, 권장)
에 **WinUSB** 드라이버 설치
4. 공식 j5create Wormhole / 자동실행 소프트웨어는 **종료·제거** (같은 인터페이스를 점유함)
```powershell
cd juc500
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m juc500_xfer info
```
## 사용법
양쪽 PC에 케이블을 꽂고, **먼저 수신 쪽**, 이어서 송신 쪽을 실행합니다.
**Windows (수신 예)**
```powershell
python -m juc500_xfer recv --dest Downloads -y
```
**Mac (송신 예)**
```bash
python -m juc500_xfer 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를 실행한 뒤 **연결**을 누르세요. (입력공유는 미지원, UI만 동일 구조)
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) 를 참고하세요.
+103
View File
@@ -0,0 +1,103 @@
@echo off
REM JUC500 Windows 설치파일 + 포터블 빌드 (Windows x64에서 실행)
REM 결과:
REM dist\JUC500\JUC500.exe
REM dist\JUC500-portable.exe
REM dist-windows\JUC500-Setup-win64.exe
REM dist-windows\JUC500-win64-portable.zip
REM dist-windows\JUC500-portable.exe
setlocal EnableExtensions EnableDelayedExpansion
cd /d "%~dp0"
echo ============================================
echo JUC500 — Windows Installer / Portable
echo ============================================
echo.
set "PY="
if defined PYTHON set "PY=%PYTHON%"
if not defined PY (
where py >nul 2>&1 && set "PY=py -3.11"
)
if not defined PY set "PY=python"
echo ==^> Python: %PY%
%PY% -c "import sys; assert sys.version_info>=(3,10); assert sys.maxsize>2**32; print(sys.version)" || (
echo 64-bit Python 3.10+ 가 필요합니다.
exit /b 1
)
if not exist "vendor\libusb-1.0.dll" (
echo [!] vendor\libusb-1.0.dll 없음 — USB 동작에 필요합니다.
echo https://github.com/libusb/libusb/releases 에서 MS64 dll 을 넣으세요.
exit /b 1
)
if not exist ".venv-build-win\Scripts\python.exe" (
%PY% -m venv .venv-build-win
)
call .venv-build-win\Scripts\activate.bat
python -m pip install -q -U pip pyinstaller pyusb
echo ==^> PyInstaller onedir
if exist build\juc500-windows rmdir /s /q build\juc500-windows 2>nul
if exist build\JUC500 rmdir /s /q build\JUC500 2>nul
if exist dist\JUC500 rmdir /s /q dist\JUC500
python -m PyInstaller --noconfirm --clean juc500-windows.spec
if not exist "dist\JUC500\JUC500.exe" (
echo 빌드 실패: dist\JUC500\JUC500.exe 없음
exit /b 1
)
echo ==^> PyInstaller onefile ^(portable exe^)
python -m PyInstaller --noconfirm --clean juc500-windows-onefile.spec
if not exist "dist\JUC500-portable.exe" (
echo 빌드 실패: dist\JUC500-portable.exe 없음
exit /b 1
)
copy /Y packaging\windows\install.ps1 dist\JUC500\install.ps1 >nul
copy /Y packaging\windows\setup.bat dist\JUC500\setup.bat >nul
copy /Y packaging\windows\README-WIN.txt dist\JUC500\README-WIN.txt >nul
copy /Y packaging\windows\zadig-hint.url dist\JUC500\zadig-hint.url >nul
copy /Y vendor\libusb-1.0.dll dist\JUC500\libusb-1.0.dll >nul 2>nul
if not exist dist-windows mkdir dist-windows
echo ==^> Portable zip + portable exe
powershell -NoProfile -Command ^
"Compress-Archive -Path 'dist\JUC500\*' -DestinationPath 'dist-windows\JUC500-win64-portable.zip' -Force"
copy /Y dist\JUC500-portable.exe dist-windows\JUC500-portable.exe >nul
copy /Y packaging\windows\install.ps1 dist-windows\install.ps1 >nul
copy /Y packaging\windows\README-WIN.txt dist-windows\README-WIN.txt >nul
REM --- Inno Setup → Setup.exe ---
set "ISCC="
if exist "%LocalAppData%\Programs\Inno Setup 6\ISCC.exe" set "ISCC=%LocalAppData%\Programs\Inno Setup 6\ISCC.exe"
if not defined ISCC if exist "%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe" set "ISCC=%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe"
if not defined ISCC if exist "%ProgramFiles%\Inno Setup 6\ISCC.exe" set "ISCC=%ProgramFiles%\Inno Setup 6\ISCC.exe"
if defined ISCC (
echo ==^> Inno Setup: %ISCC%
"%ISCC%" packaging\windows\juc500.iss
) else (
echo.
echo [!] Inno Setup 6 이 없어 Setup.exe 를 생략합니다.
echo https://jrsoftware.org/isinfo.php
echo portable zip / JUC500-portable.exe 는 생성되었습니다.
)
echo.
echo ============================================
echo 완료
echo ============================================
echo EXE: %CD%\dist\JUC500\JUC500.exe
echo 포터블 EXE: %CD%\dist-windows\JUC500-portable.exe
echo 포터블 ZIP: %CD%\dist-windows\JUC500-win64-portable.zip
if exist dist-windows\JUC500-Setup-win64.exe (
echo 설치파일: %CD%\dist-windows\JUC500-Setup-win64.exe
) else (
echo 설치파일: ^(미생성 — Inno Setup 6 필요^)
)
echo.
endlocal
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# JUC500 Windows 설치파일 생성
# Apple Silicon: --ci 로 GitHub Actions 사용
# Windows PC: build-installer-windows.bat
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
cd "$ROOT"
echo "============================================"
echo " JUC500 — Windows Installer 빌드"
echo "============================================"
echo ""
usage() {
cat <<'EOF'
사용법:
./build-installer-windows.sh --ci # GitHub Actions 빌드 + 산출물 다운로드
./build-installer-windows.sh --help
Windows PC 에서는 build-installer-windows.bat 를 실행하세요.
EOF
}
download_ci_artifact() {
if ! command -v gh >/dev/null 2>&1; then
echo "gh (GitHub CLI) 가 필요합니다: brew install gh && gh auth login" >&2
exit 1
fi
if [[ ! -f vendor/libusb-1.0.dll ]]; then
echo "vendor/libusb-1.0.dll 이 저장소에 있어야 CI 가 번들합니다." >&2
exit 1
fi
mkdir -p dist-windows
echo "==> GitHub Actions workflow 실행 (build-windows.yml)"
gh workflow run build-windows.yml
echo "==> 실행 대기…"
sleep 4
RUN_ID="$(gh run list --workflow=build-windows.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
echo "workflow run id 를 찾지 못했습니다. 원격 저장소·workflow 푸시를 확인하세요." >&2
exit 1
fi
echo " run id: $RUN_ID"
gh run watch "$RUN_ID" --exit-status
rm -rf dist-windows/_ci_dl
mkdir -p dist-windows/_ci_dl
gh run download "$RUN_ID" -n juc500-windows-x64 -D dist-windows/_ci_dl
find dist-windows/_ci_dl -type f \( \
-name 'JUC500-Setup-win64.exe' \
-o -name 'JUC500-win64-portable.zip' \
-o -name 'JUC500-portable.exe' \
\) -exec cp -f {} dist-windows/ \;
rm -rf dist-windows/_ci_dl
echo ""
echo "============================================"
echo " Windows 설치파일 완료 (GitHub Actions)"
echo "============================================"
ls -lh dist-windows/JUC500-Setup-win64.exe \
dist-windows/JUC500-win64-portable.zip \
dist-windows/JUC500-portable.exe 2>/dev/null || {
echo "산출물이 dist-windows/ 에 없습니다. Actions 로그를 확인하세요." >&2
exit 1
}
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
--ci|--github|--actions) download_ci_artifact; exit 0 ;;
"")
if [[ "$(uname -s)" == "Darwin" ]]; then
usage
echo ""
echo "이 Mac에서는 ./build-installer-windows.sh --ci 를 사용하세요."
exit 1
fi
;;
esac
echo "Windows 에서는 build-installer-windows.bat 를 실행하세요."
exit 1
+73
View File
@@ -0,0 +1,73 @@
# JUC500 분석 노트
분석일: 2026-07-15
환경: Apple Silicon (T6000 USB XHCI), 케이블 연결 상태
## 하드웨어 식별
| 항목 | 값 |
|------|-----|
| VID:PID | `0711:7500` |
| 제품명 | Smart Data Link |
| bcdUSB | 2.10 |
| Device Class | `EF:02:01` (Miscellaneous + IAD) |
| 시리얼 예 | `D264CCA25B431D2E` |
| 미디어 | `/dev/disk4` → `/Volumes/WORMHOLE` (FAT12, ~16.7MB, read-only) |
| Media Name | SD Card Reader |
| 링크 속도 | USB 2.0 High Speed (480 Mb/s) — 허브 경유 시 SuperSpeed 미적용 가능 |
## 구성 인터페이스
1. **IF0** CDC Communication (`02:02:ff`) EP `0x81` INT
2. **IF1** CDC Data (`0a:00:00`) EP `0x02` OUT / `0x83` IN BULK 512
3. **IF2** Mass Storage BOT — WORMHOLE 가상 CD
4. **IF3** HID (mouse proto) EP `0x87`
5. **IF4** HID keyboard EP `0x86`
6. **IF5** Vendor `ff:00:00`
- `0x08` OUT / `0x89` IN
- `0x0A` OUT / `0x8B` IN
Apple Silicon에서 **IF0/IF1/IF5** 는 libusb로 claim 가능. IF2–4는 커널 드라이버 점유(권한 없이 detach 불가).
## 번들 소프트웨어 (WORMHOLE 볼륨)
- `Wormhole.exe` — PE32
- `Wormhole.app` / shell: **i386 + ppc** (케이블에 들어 있는 2015 페이로드)
- `Deployment/GSDD.zz` — 커스텀 압축(OT…) Windows 로더 페이로드
제조사 OEM: **KaiJet Technology / OTi**, 제품명 Wormhole / GO Bridge.
## 공식 다운로드 (2024-09, v1.0.1463.47)
- Mac: `U3Wormhole.app` + `OTiTransfer.framework` → **x86_64 only**
- 여전히 `IOUSBDevice` / `IOUSBDeviceStruct320` / `Query kIOUSBDeviceInterfaceID500`
- Windows: `JUC500_1463.47 Update FW for Windows.exe`
→ **arm64 네이티브 미제공**. Rosetta로 떠도 USB user client 경로가 Silicon에서 불안정할 수 있음.
## 소프트웨어 아키텍처 (공식)
```
Wormhole UI
└── GoBridgeDemon (daemon)
├── OTiTransfer.framework — USB open, alive pipe, data pipe, bounding
├── KMKeyMouse.dylib — HID KM 공유
└── KMClipboard.dylib — 클립보드
```
주요 API: `OTi_Open`, `OTi_SendData`, `OTi_ReadData`, `OTi_CheckBounding`, `OTi_SendHIDPacket`
알림: `OTi_NotifyDevicePlugin`, `OTi_NotifyRemoteAppActive`, …
상위 메시지: XML 기반 **UPipe** (`<OTIMSG>`, `NP_Cmd_*`, 파일/클립보드/KM)
데이터 경로: USB bulk 위 alive/data 파이프 분리 + XML 패킷 (`isXMLPacket`, `Will send an XML packet`).
## Silicon 미동작 원인 (종합)
1. **바이너리 아키텍처** — arm64 없음 (최신도 x86_64)
2. **레거시 IOKit USB API** — IOUSBDevice 경로
3. **커널/유저 권한·드라이버 클레임** 차이
4. j5create M1 지원 목록에 **JUC500 없음** (디스플레이/이더넷 어댑터 위주)
## 본 프로젝트 전략
공식 OTi/UPipe를 재구현하지 않고, **IF5 bulk 브릿지** 위에 자체 `J5FX` 프레임 프로토콜을 올려 Mac↔Win 양쪽에서 동일 스택으로 통신한다. Windows는 WinUSB(Zadig)로 IF5/IF1을 유저랜드에 노출해야 한다.
+47
View File
@@ -0,0 +1,47 @@
# Windows 11 설정
## 설치파일 사용 (권장)
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` 선택
4. 하단에서 **Interface 5** 선택 → Driver: **WinUSB** → Replace Driver
5. 마찬가지로 **Interface 1** (CDC Data) 권장
6. Interface 2(Mass Storage), 3·4(HID) 는 그대로 두세요.
공식 Wormhole가 설치돼 있으면 제거하거나 프로세스를 종료한 뒤 Zadig를 실행하세요.
## 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` 하면 전송이 시작됩니다.
+65
View File
@@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
# Windows 64-bit 단일 EXE (포터블)
from pathlib import Path
from PyInstaller.utils.hooks import collect_submodules
ROOT = Path(SPECPATH).resolve()
STATIC = ROOT / "juc500_xfer" / "static"
VENDOR_DLL = ROOT / "vendor" / "libusb-1.0.dll"
hidden = collect_submodules("juc500_xfer")
datas = []
for f in STATIC.iterdir():
if f.is_file():
datas.append((str(f), "static"))
datas.append((str(f), "juc500_xfer/static"))
binaries = []
if VENDOR_DLL.is_file():
binaries.append((str(VENDOR_DLL), "."))
a = Analysis(
[str(ROOT / "juc500_app.py")],
pathex=[str(ROOT)],
binaries=binaries,
datas=datas,
hiddenimports=hidden + [
"usb",
"usb.core",
"usb.util",
"usb.backend",
"usb.backend.libusb1",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="JUC500-portable",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
+71
View File
@@ -0,0 +1,71 @@
# -*- mode: python ; coding: utf-8 -*-
# Windows 64-bit PyInstaller spec (onedir → installer / portable)
from pathlib import Path
from PyInstaller.utils.hooks import collect_submodules
ROOT = Path(SPECPATH).resolve()
STATIC = ROOT / "juc500_xfer" / "static"
VENDOR_DLL = ROOT / "vendor" / "libusb-1.0.dll"
hidden = collect_submodules("juc500_xfer")
datas = []
for f in STATIC.iterdir():
if f.is_file():
datas.append((str(f), "static"))
datas.append((str(f), "juc500_xfer/static"))
binaries = []
if VENDOR_DLL.is_file():
binaries.append((str(VENDOR_DLL), "."))
a = Analysis(
[str(ROOT / "juc500_app.py")],
pathex=[str(ROOT)],
binaries=binaries,
datas=datas,
hiddenimports=hidden + [
"usb",
"usb.core",
"usb.util",
"usb.backend",
"usb.backend.libusb1",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="JUC500",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True, # USB/디버그 로그 확인. GUI만이면 False
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="JUC500",
)
+23
View File
@@ -0,0 +1,23 @@
"""PyInstaller / Windows 엔트리 — 인자 없으면 GUI, 있으면 CLI."""
from __future__ import annotations
import sys
def main() -> int:
argv = sys.argv[1:]
# 더블클릭 / 바로가기: GUI
if not argv:
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())
+3
View File
@@ -0,0 +1,3 @@
"""JUC500 cross-platform file transfer for Apple Silicon Mac ↔ Windows 11."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+162
View File
@@ -0,0 +1,162 @@
"""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
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 장치가 없습니다.")
return 1
for d in devices:
print(f"bus={d.bus} addr={d.address} serial={d.serial!r} product={d.product!r}")
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("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())
+5
View File
@@ -0,0 +1,5 @@
"""호환용 — 웹 GUI로 위임."""
from .gui_server import run_gui
__all__ = ["run_gui"]
+637
View File
@@ -0,0 +1,637 @@
"""fileShare 스타일 로컬 웹 GUI — JUC500 USB bulk 브릿지."""
from __future__ import annotations
import json
import mimetypes
import os
import socket
import tempfile
import threading
import time
import urllib.parse
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Optional
from .protocol import Session
from .usb_link import Juc500Link, list_devices
DEFAULT_GUI_PORT = 8765
def _static_root() -> Path:
"""개발/PyInstaller frozen 모두에서 static 루트를 찾는다."""
import sys
candidates: list[Path] = []
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
candidates += [
Path(mei) / "static",
Path(mei) / "juc500_xfer" / "static",
]
candidates.append(Path(sys.executable).resolve().parent / "static")
here = Path(__file__).resolve().parent
candidates += [here / "static", here.parent / "static"]
for c in candidates:
if (c / "index.html").is_file():
return c.resolve()
return (here / "static").resolve()
def _static_file(rel: str) -> Path | None:
root = _static_root()
name = (rel or "").lstrip("/").replace("\\", "/")
if not name or ".." in name.split("/"):
return None
target = (root / name).resolve()
try:
target.relative_to(root)
except ValueError:
return None
return target if target.is_file() else None
class GuiState:
def __init__(self) -> None:
self.lock = threading.Lock()
self.link: Juc500Link | None = None
self.session: Session | None = None
self.peer_name: str | None = None
self.local_root = Path.home().resolve()
self.serial: str | None = None
self.gui_port = DEFAULT_GUI_PORT
self.auto_status = "JUC500 케이블을 연결하세요"
self.last_progress = {"done": 0, "total": 0, "label": "", "error": None, "busy": False}
self.connecting = False
self.log_lines: list[str] = []
def log(self, msg: str) -> None:
self.log_lines.append(msg)
self.log_lines = self.log_lines[-200:]
print(msg)
def set_progress(self, done: int, total: int, label: str) -> None:
self.last_progress = {
"done": done,
"total": total,
"label": label,
"error": None,
"busy": total > 0 and done < total,
}
STATE = GuiState()
def _session_alive() -> bool:
return STATE.session is not None and STATE.session._connected.is_set()
def _safe_local(rel: str) -> Path:
rel = (rel or "").replace("\\", "/").lstrip("/")
target = (STATE.local_root / rel).resolve()
try:
target.relative_to(STATE.local_root)
except ValueError as exc:
raise PermissionError("경로가 로컬 루트를 벗어납니다") from exc
return target
def _list_local_entries(rel: str) -> dict[str, Any]:
path = _safe_local(rel)
if not path.exists():
raise FileNotFoundError(rel or "/")
if not path.is_dir():
raise NotADirectoryError(rel)
entries = []
for child in path.iterdir():
try:
st = child.stat()
entries.append(
{
"name": child.name,
"is_dir": child.is_dir(),
"size": 0 if child.is_dir() else int(st.st_size),
"mtime": int(st.st_mtime),
}
)
except OSError:
continue
return {"path": rel.replace("\\", "/").strip("/"), "entries": entries}
def connect_usb(timeout: float = 45.0) -> dict[str, Any]:
with STATE.lock:
if _session_alive():
return {
"ok": True,
"host": STATE.peer_name or "peer",
"hostname": STATE.peer_name or "peer",
"port": 0,
"start_path": "",
"receive_path": "",
}
if STATE.connecting:
raise RuntimeError("이미 연결 중입니다")
STATE.connecting = True
STATE.auto_status = "USB 링크 여는 중…"
try:
devices = list_devices()
if not devices:
raise RuntimeError("JUC500(Smart Data Link) 장치가 없습니다. 케이블을 연결하세요.")
link = Juc500Link(serial=STATE.serial)
session = Session(
link,
on_log=STATE.log,
local_root=STATE.local_root,
)
session.inbound_dest = STATE.local_root
session.on_progress = STATE.set_progress
session.start()
STATE.auto_status = "상대 프로그램 대기 중…"
peer = session.wait_peer(timeout=timeout)
with STATE.lock:
STATE.link = link
STATE.session = session
STATE.peer_name = peer
STATE.auto_status = f"연결됨 · {peer}"
STATE.connecting = False
return {
"ok": True,
"host": peer,
"hostname": peer,
"port": 0,
"start_path": "",
"receive_path": "",
}
except Exception:
with STATE.lock:
STATE.connecting = False
if STATE.session:
try:
STATE.session.stop()
except Exception:
pass
if STATE.link:
try:
STATE.link.close()
except Exception:
pass
STATE.session = None
STATE.link = None
STATE.auto_status = "연결 실패 — 다시 시도하세요"
raise
def disconnect_usb() -> None:
with STATE.lock:
session = STATE.session
link = STATE.link
STATE.session = None
STATE.link = None
STATE.peer_name = None
STATE.auto_status = "연결 해제됨"
STATE.connecting = False
if session:
try:
session.stop()
except Exception:
pass
if link:
try:
link.close()
except Exception:
pass
def _require_session() -> Session:
if not STATE.session or not STATE.session._connected.is_set():
raise RuntimeError("연결되지 않았습니다")
return STATE.session
def _send_local_paths(paths: list[str], dest_dir: str) -> dict[str, Any]:
session = _require_session()
dest = (dest_dir or "").replace("\\", "/").strip("/")
count = 0
def ensure_remote_dir(rel_dir: str) -> None:
rel_dir = (rel_dir or "").replace("\\", "/").strip("/")
if not rel_dir or rel_dir == ".":
return
try:
session.rpc("mkdir", path=rel_dir, timeout=15)
except Exception:
pass
for rel in paths:
local = _safe_local(rel)
if local.is_file():
remote_name = f"{dest}/{local.name}".strip("/") if dest else local.name
ensure_remote_dir(str(Path(remote_name).parent.as_posix()))
STATE.set_progress(0, local.stat().st_size, local.name)
session.on_progress = lambda done, total, name=local.name: STATE.set_progress(done, total, name)
session.send_file(local, remote_name=remote_name)
count += 1
elif local.is_dir():
base = f"{dest}/{local.name}".strip("/") if dest else local.name
ensure_remote_dir(base)
for file_path in local.rglob("*"):
if not file_path.is_file():
continue
rel_under = file_path.relative_to(local).as_posix()
remote_name = f"{base}/{rel_under}".strip("/")
ensure_remote_dir(str(Path(remote_name).parent.as_posix()))
STATE.set_progress(0, file_path.stat().st_size, remote_name)
session.on_progress = lambda done, total, name=remote_name: STATE.set_progress(
done, total, name
)
session.send_file(file_path, remote_name=remote_name)
count += 1
else:
raise FileNotFoundError(rel)
STATE.last_progress = {"done": 1, "total": 1, "label": "완료", "error": None, "busy": False}
return {"count": count, "dest_dir": dest}
def _fetch_remote_paths(paths: list[str], dest_dir: str) -> dict[str, Any]:
session = _require_session()
dest_rel = (dest_dir or "").replace("\\", "/").strip("/")
dest_abs = _safe_local(dest_rel)
dest_abs.mkdir(parents=True, exist_ok=True)
session.inbound_dest = dest_abs
count = 0
for rel in paths:
rel = rel.replace("\\", "/").lstrip("/")
# Ask peer what it is — listdir parent and find, or just pull
# Pull: peer pushes file(s). For directories, peer walks.
STATE.set_progress(0, 1, rel)
session.rpc("pull", path=rel, timeout=30)
# Wait until inbound idle after activity
deadline = time.monotonic() + 3600
saw = False
idle = 0
while time.monotonic() < deadline:
if session._inbound.get("fh"):
saw = True
idle = 0
elif saw:
idle += 1
if idle >= 8: # ~0.8s quiet after transfer
break
time.sleep(0.1)
count += 1
session.inbound_dest = STATE.local_root
STATE.last_progress = {"done": 1, "total": 1, "label": "완료", "error": None, "busy": False}
return {"count": count, "dest_dir": dest_rel}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args: Any) -> None:
return
def _json(self, code: int, payload: dict[str, Any]) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length") or 0)
if length <= 0:
return {}
return json.loads(self.rfile.read(length).decode("utf-8"))
def _read_multipart(self) -> tuple[dict[str, str], list[tuple[str, bytes]]]:
ctype = self.headers.get("Content-Type", "")
if "multipart/form-data" not in ctype:
raise ValueError("multipart required")
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length)
boundary = None
for part in ctype.split(";"):
part = part.strip()
if part.startswith("boundary="):
boundary = part.split("=", 1)[1].strip().strip('"')
if not boundary:
raise ValueError("no boundary")
fields: dict[str, str] = {}
files: list[tuple[str, bytes]] = []
for chunk in body.split(b"--" + boundary.encode()):
if not chunk or chunk in (b"--\r\n", b"--"):
continue
if chunk.startswith(b"\r\n"):
chunk = chunk[2:]
if chunk.endswith(b"\r\n"):
chunk = chunk[:-2]
if b"\r\n\r\n" not in chunk:
continue
header_blob, data = chunk.split(b"\r\n\r\n", 1)
if data.endswith(b"\r\n"):
data = data[:-2]
headers = header_blob.decode("utf-8", errors="replace")
name = None
filename = None
for line in headers.split("\r\n"):
if line.lower().startswith("content-disposition:"):
for token in line.split(";"):
token = token.strip()
if token.startswith("name="):
name = token.split("=", 1)[1].strip().strip('"')
if token.startswith("filename="):
filename = token.split("=", 1)[1].strip().strip('"')
if not name:
continue
if filename is not None:
files.append((filename, data))
else:
fields[name] = data.decode("utf-8", errors="replace")
return fields, files
def do_GET(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
qs = urllib.parse.parse_qs(parsed.query)
if path in ("/", "/index.html"):
target = _static_file("index.html")
if not target:
self._json(500, {"error": "index.html missing"})
return
data = target.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
if path.startswith("/api/"):
try:
self._api_get(path, qs)
except Exception as exc:
self._json(400, {"error": str(exc)})
return
rel = path.lstrip("/")
target = _static_file(rel)
if not target:
self._json(404, {"error": "not found"})
return
data = target.read_bytes()
mime = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _api_get(self, path: str, qs: dict[str, list[str]]) -> None:
if path == "/api/status":
devices = list_devices()
cable = devices[0] if devices else None
self._json(
200,
{
"connected": _session_alive(),
"connecting": STATE.connecting,
"hostname": STATE.peer_name,
"host": STATE.peer_name or "",
"port": 0,
"bind_ip": None,
"cable_ip": cable.serial if cable else None,
"cable_only": True,
"this_host": socket.gethostname(),
"local_root": str(STATE.local_root),
"local_start": "",
"root": STATE.peer_name or "JUC500",
"remote_start": "",
"remote_receive": "",
"auto_status": STATE.auto_status,
"progress": STATE.last_progress,
"device": {
"serial": cable.serial if cable else None,
"product": cable.product if cable else None,
"count": len(devices),
},
"inputshare": {
"supported": False,
"enabled": False,
"trusted": False,
"status": "JUC500 빌드에서는 입력공유 미지원",
},
},
)
return
if path == "/api/settings/ports":
# Reuse settings modal: expose local_root / serial as "ports"-like rows
self._json(
200,
{
"config_path": str(STATE.local_root),
"ports": [
{
"key": "gui",
"label": "GUI 포트",
"description": "로컬 웹 UI 포트 (재시작 시 적용)",
"protocol": "HTTP",
"default": DEFAULT_GUI_PORT,
"value": STATE.gui_port,
}
],
},
)
return
if path == "/api/inputshare":
self._json(
200,
{
"supported": False,
"enabled": False,
"trusted": False,
"status": "미지원",
},
)
return
if path == "/api/progress":
self._json(200, STATE.last_progress)
return
if path == "/api/interfaces":
devices = [
{
"name": d.product,
"ip": d.serial,
"usb": True,
}
for d in list_devices()
]
self._json(200, {"interfaces": devices})
return
if path == "/api/discover":
devices = list_devices()
peers = []
if devices and _session_alive():
peers.append({"host": STATE.peer_name or "peer", "tcp_port": 0})
self._json(200, {"peers": peers})
return
if path == "/api/local/listdir":
rel = (qs.get("path") or [""])[0]
self._json(200, _list_local_entries(rel))
return
if path == "/api/listdir":
session = _require_session()
rel = (qs.get("path") or [""])[0]
result = session.rpc("listdir", path=rel, timeout=20)
self._json(200, result)
return
self._json(404, {"error": f"unknown api {path}"})
def do_POST(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
try:
self._api_post(path)
except Exception as e:
self._json(400, {"error": str(e), "ok": False, "code": type(e).__name__})
def _api_post(self, path: str) -> None:
if path in ("/api/connect", "/api/connect/auto"):
try:
data = connect_usb()
self._json(200, data)
except TimeoutError as e:
self._json(404, {"ok": False, "error": str(e), "code": "PEER_NOT_FOUND"})
return
if path == "/api/disconnect":
disconnect_usb()
self._json(200, {"ok": True})
return
if path == "/api/auto/start":
self._json(200, {"ok": True})
return
if path == "/api/mkdir":
body = self._read_json()
session = _require_session()
result = session.rpc("mkdir", path=body.get("path") or "", timeout=15)
self._json(200, result)
return
if path == "/api/local/send":
body = self._read_json()
result = _send_local_paths(body.get("paths") or [], body.get("dest_dir") or "")
self._json(200, result)
return
if path == "/api/remote/fetch":
body = self._read_json()
result = _fetch_remote_paths(body.get("paths") or [], body.get("dest_dir") or "")
self._json(200, result)
return
if path == "/api/upload":
fields, files = self._read_multipart()
dest = fields.get("dest_dir") or ""
session = _require_session()
# write temp then send
count = 0
with tempfile.TemporaryDirectory() as td:
for name, data in files:
tmp = Path(td) / Path(name).name
tmp.write_bytes(data)
remote_name = f"{dest}/{tmp.name}".strip("/") if dest else tmp.name
parent = Path(remote_name).parent.as_posix()
if parent and parent != ".":
try:
session.rpc("mkdir", path=parent, timeout=15)
except Exception:
pass
session.send_file(tmp, remote_name=remote_name)
count += 1
self._json(200, {"count": count, "dest_dir": dest})
return
if path == "/api/local/write":
fields, files = self._read_multipart()
dest = fields.get("dest_dir") or ""
dest_abs = _safe_local(dest)
dest_abs.mkdir(parents=True, exist_ok=True)
count = 0
for name, data in files:
(dest_abs / Path(name).name).write_bytes(data)
count += 1
self._json(200, {"count": count, "dest_dir": dest})
return
if path == "/api/settings/ports":
body = self._read_json()
ports = body.get("ports") or {}
if "gui" in ports:
STATE.gui_port = int(ports["gui"])
self._json(
200,
{
"ok": True,
"gui_port": STATE.gui_port,
"redirect": f"http://127.0.0.1:{STATE.gui_port}/",
"message": "포트는 다음 실행 시 적용됩니다 (재시작 없음)",
},
)
return
if path.startswith("/api/inputshare"):
self._json(200, {"supported": False, "ok": False, "error": "입력공유 미지원"})
return
self._json(404, {"error": f"unknown api {path}"})
def _pick_port(host: str, preferred: int) -> int:
for port in [preferred, *range(preferred + 1, preferred + 20)]:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind((host, port))
return port
except OSError:
continue
raise RuntimeError("GUI 포트를 열 수 없습니다")
def run_gui(open_browser: bool = True, port: int | None = None, host: str = "127.0.0.1") -> None:
preferred = port or STATE.gui_port or DEFAULT_GUI_PORT
listen_port = _pick_port(host, preferred)
STATE.gui_port = listen_port
server = ThreadingHTTPServer((host, listen_port), Handler)
url = f"http://{host}:{listen_port}/"
print(f"JUC500 GUI: {url}")
print("fileShare와 동일한 듀얼 패널 UI · 케이블 연결 후 「연결」을 누르세요.")
if open_browser:
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nGUI 종료")
finally:
disconnect_usb()
server.server_close()
+528
View File
@@ -0,0 +1,528 @@
"""Framed packet protocol over the JUC500 vendor bulk bridge.
Frame layout (little-endian):
magic : 4 bytes b"J5FX"
type : u8
flags : u8
seq : u32
length : u32
payload : length bytes
crc32 : u32 zlib.crc32(payload)
"""
from __future__ import annotations
import hashlib
import json
import os
import struct
import threading
import time
import uuid
import zlib
from concurrent.futures import Future
from dataclasses import dataclass
from enum import IntEnum
from pathlib import Path
from typing import Any, Callable, Optional
from .usb_link import Juc500Link, MAX_PACKET
MAGIC = b"J5FX"
HEADER_FMT = "<4sBBII"
HEADER_SIZE = struct.calcsize(HEADER_FMT)
CRC_SIZE = 4
MAX_PAYLOAD = 16 * 1024 * 1024
CHUNK_PAYLOAD = 60 * 1024
class MsgType(IntEnum):
HELLO = 1
HELLO_ACK = 2
PING = 3
PONG = 4
FILE_OFFER = 10
FILE_ACCEPT = 11
FILE_REJECT = 12
FILE_CHUNK = 13
FILE_DONE = 14
FILE_ACK = 15
RPC_REQ = 30
RPC_RESP = 31
ERROR = 255
@dataclass
class Frame:
type: MsgType
flags: int
seq: int
payload: bytes
def pack_frame(msg_type: MsgType, payload: bytes = b"", seq: int = 0, flags: int = 0) -> bytes:
crc = zlib.crc32(payload) & 0xFFFFFFFF
header = struct.pack(HEADER_FMT, MAGIC, int(msg_type), flags, seq & 0xFFFFFFFF, len(payload))
return header + payload + struct.pack("<I", crc)
class FrameReader:
def __init__(self) -> None:
self._buf = bytearray()
def feed(self, data: bytes) -> list[Frame]:
self._buf.extend(data)
frames: list[Frame] = []
while True:
if len(self._buf) < HEADER_SIZE + CRC_SIZE:
break
idx = self._buf.find(MAGIC)
if idx < 0:
self._buf = self._buf[-3:]
break
if idx > 0:
del self._buf[:idx]
if len(self._buf) < HEADER_SIZE + CRC_SIZE:
break
magic, typ, flags, seq, length = struct.unpack_from(HEADER_FMT, self._buf, 0)
if magic != MAGIC or length > MAX_PAYLOAD:
del self._buf[0:1]
continue
total = HEADER_SIZE + length + CRC_SIZE
if len(self._buf) < total:
break
payload = bytes(self._buf[HEADER_SIZE : HEADER_SIZE + length])
(crc,) = struct.unpack_from("<I", self._buf, HEADER_SIZE + length)
del self._buf[:total]
if (zlib.crc32(payload) & 0xFFFFFFFF) != crc:
continue
try:
frames.append(Frame(MsgType(typ), flags, seq, payload))
except ValueError:
continue
return frames
def _default_peer_name() -> str:
host = os.uname().nodename if hasattr(os, "uname") else "host"
try:
user = os.getlogin()
except OSError:
user = os.environ.get("USER") or os.environ.get("USERNAME") or "user"
return f"{host}-{user}"
class Session:
"""Bidirectional session: HELLO + file frames + JSON RPC."""
def __init__(
self,
link: Juc500Link,
peer_name: Optional[str] = None,
on_log: Optional[Callable[[str], None]] = None,
local_root: Optional[Path] = None,
):
self.link = link
self.peer_name = peer_name or _default_peer_name()
self.on_log = on_log or (lambda m: None)
self.local_root = (local_root or Path.home()).resolve()
self._reader = FrameReader()
self._seq = 0
self._lock = threading.Lock()
self._xfer_lock = threading.Lock()
self._stop = threading.Event()
self._rx_thread: Optional[threading.Thread] = None
self._alive_thread: Optional[threading.Thread] = None
self._handlers: dict[MsgType, Callable[[Frame], None]] = {}
self._pending_accept = threading.Event()
self._accept_result: Optional[bool] = None
self._peer_hello: Optional[str] = None
self._file_ack = threading.Event()
self._connected = threading.Event()
self._rpc_waiters: dict[str, Future] = {}
self._rpc_lock = threading.Lock()
# inbound file receive staging (when peer pushes)
self._inbound: dict[str, Any] = {}
self.inbound_dest = self.local_root
self.on_progress: Optional[Callable[[int, int, str], None]] = None
def log(self, msg: str) -> None:
self.on_log(msg)
def start(self) -> None:
self._stop.clear()
self._handlers[MsgType.HELLO] = self._on_hello
self._handlers[MsgType.HELLO_ACK] = self._on_hello_ack
self._handlers[MsgType.PING] = self._on_ping
self._handlers[MsgType.PONG] = lambda _f: None
self._handlers[MsgType.FILE_ACCEPT] = self._on_file_accept
self._handlers[MsgType.FILE_REJECT] = self._on_file_reject
self._handlers[MsgType.FILE_ACK] = self._on_file_ack
self._handlers[MsgType.FILE_OFFER] = self._on_inbound_offer
self._handlers[MsgType.FILE_CHUNK] = self._on_inbound_chunk
self._handlers[MsgType.FILE_DONE] = self._on_inbound_done
self._handlers[MsgType.RPC_REQ] = self._on_rpc_req
self._handlers[MsgType.RPC_RESP] = self._on_rpc_resp
self._rx_thread = threading.Thread(target=self._rx_loop, name="juc500-rx", daemon=True)
self._alive_thread = threading.Thread(target=self._alive_loop, name="juc500-alive", daemon=True)
self._rx_thread.start()
self._alive_thread.start()
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
def stop(self) -> None:
self._stop.set()
if self._rx_thread:
self._rx_thread.join(timeout=2)
if self._alive_thread:
self._alive_thread.join(timeout=2)
with self._rpc_lock:
for fut in self._rpc_waiters.values():
if not fut.done():
fut.set_exception(RuntimeError("session stopped"))
self._rpc_waiters.clear()
def wait_peer(self, timeout: float = 30.0) -> str:
deadline = time.monotonic() + timeout
while not self._connected.is_set():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(
"상대가 JUC500 전송 프로그램으로 연결되지 않았습니다. "
"양쪽에서 GUI/앱을 실행 중인지 확인하세요."
)
try:
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
except Exception as exc:
self.log(f"HELLO 재전송 실패: {exc}")
self._connected.wait(timeout=min(2.0, remaining))
return self._peer_hello or "peer"
def send(self, msg_type: MsgType, payload: bytes = b"", flags: int = 0) -> int:
with self._lock:
self._seq = (self._seq + 1) & 0xFFFFFFFF
seq = self._seq
frame = pack_frame(msg_type, payload, seq=seq, flags=flags)
return self.link.write_data(frame)
def rpc(self, op: str, timeout: float = 30.0, **kwargs: Any) -> Any:
req_id = uuid.uuid4().hex
body = {"id": req_id, "op": op, **kwargs}
fut: Future = Future()
with self._rpc_lock:
self._rpc_waiters[req_id] = fut
self.send(MsgType.RPC_REQ, json.dumps(body, ensure_ascii=False).encode("utf-8"))
try:
return fut.result(timeout=timeout)
finally:
with self._rpc_lock:
self._rpc_waiters.pop(req_id, None)
def _rx_loop(self) -> None:
while not self._stop.is_set():
chunk = self.link.read_data(MAX_PACKET, timeout_ms=200)
if not chunk:
continue
for frame in self._reader.feed(chunk):
handler = self._handlers.get(frame.type)
if handler:
try:
handler(frame)
except Exception as exc:
self.log(f"핸들러 오류 ({frame.type.name}): {exc}")
def _alive_loop(self) -> None:
while not self._stop.is_set():
try:
self.link.write_ctrl(b"\x00ALIVE" + struct.pack("<d", time.time()), timeout_ms=500)
except Exception:
pass
try:
self.link.read_ctrl(MAX_PACKET, timeout_ms=50)
except Exception:
pass
self._stop.wait(1.0)
if self._connected.is_set():
try:
self.send(MsgType.PING, struct.pack("<d", time.time()))
except Exception:
pass
self._stop.wait(2.0)
def _on_hello(self, frame: Frame) -> None:
name = frame.payload.decode("utf-8", errors="replace")
self._peer_hello = name
self.log(f"상대 HELLO: {name}")
self.send(MsgType.HELLO_ACK, self.peer_name.encode("utf-8"))
self._connected.set()
def _on_hello_ack(self, frame: Frame) -> None:
name = frame.payload.decode("utf-8", errors="replace")
self._peer_hello = name
self.log(f"상대 HELLO_ACK: {name}")
self._connected.set()
def _on_ping(self, frame: Frame) -> None:
self.send(MsgType.PONG, frame.payload)
def _on_file_accept(self, _frame: Frame) -> None:
self._accept_result = True
self._pending_accept.set()
def _on_file_reject(self, _frame: Frame) -> None:
self._accept_result = False
self._pending_accept.set()
def _on_file_ack(self, _frame: Frame) -> None:
self._file_ack.set()
def _safe_under_root(self, rel: str) -> Path:
rel = (rel or "").replace("\\", "/").lstrip("/")
target = (self.local_root / rel).resolve()
try:
target.relative_to(self.local_root)
except ValueError as exc:
raise PermissionError(f"경로가 루트를 벗어남: {rel}") from exc
return target
def list_local(self, rel: str = "") -> dict[str, Any]:
path = self._safe_under_root(rel)
if not path.exists():
raise FileNotFoundError(rel)
if not path.is_dir():
raise NotADirectoryError(rel)
entries = []
for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
try:
st = child.stat()
entries.append(
{
"name": child.name,
"is_dir": child.is_dir(),
"size": 0 if child.is_dir() else int(st.st_size),
"mtime": int(st.st_mtime),
}
)
except OSError:
continue
return {"path": rel.replace("\\", "/").strip("/"), "entries": entries}
def mkdir_local(self, rel: str) -> dict[str, Any]:
path = self._safe_under_root(rel)
path.mkdir(parents=True, exist_ok=True)
return {"path": rel.replace("\\", "/").strip("/")}
def _on_rpc_req(self, frame: Frame) -> None:
try:
req = json.loads(frame.payload.decode("utf-8"))
except Exception as exc:
self._rpc_reply({"id": "", "ok": False, "error": f"bad json: {exc}"})
return
req_id = req.get("id", "")
op = req.get("op")
try:
if op == "listdir":
result = self.list_local(req.get("path") or "")
elif op == "mkdir":
result = self.mkdir_local(req.get("path") or "")
elif op == "ping":
result = {"pong": True, "name": self.peer_name}
elif op == "pull":
# Peer asks us to push a file/folder to them.
rel = req.get("path") or ""
# Respond OK first; actual bytes go via FILE_OFFER stream in a worker.
self._rpc_reply({"id": req_id, "ok": True, "result": {"queued": True, "path": rel}})
threading.Thread(
target=self._push_path_for_pull,
args=(rel,),
daemon=True,
name="juc500-pull-push",
).start()
return
else:
raise ValueError(f"unknown op: {op}")
self._rpc_reply({"id": req_id, "ok": True, "result": result})
except Exception as exc:
self._rpc_reply({"id": req_id, "ok": False, "error": str(exc)})
def _rpc_reply(self, body: dict[str, Any]) -> None:
self.send(MsgType.RPC_RESP, json.dumps(body, ensure_ascii=False).encode("utf-8"))
def _on_rpc_resp(self, frame: Frame) -> None:
try:
body = json.loads(frame.payload.decode("utf-8"))
except Exception:
return
req_id = body.get("id")
with self._rpc_lock:
fut = self._rpc_waiters.get(req_id or "")
if not fut or fut.done():
return
if body.get("ok"):
fut.set_result(body.get("result"))
else:
fut.set_exception(RuntimeError(body.get("error") or "RPC failed"))
def _push_path_for_pull(self, rel: str) -> None:
try:
path = self._safe_under_root(rel)
if path.is_file():
self.send_file(path, remote_name=Path(rel).name)
elif path.is_dir():
for file_path in path.rglob("*"):
if file_path.is_file():
remote_name = str(file_path.relative_to(self.local_root)).replace("\\", "/")
# When pulled, receiver picks dest; send basename under relative tree
rel_under = str(file_path.relative_to(path)).replace("\\", "/")
self.send_file(file_path, remote_name=rel_under)
else:
self.log(f"pull 대상 없음: {rel}")
except Exception as exc:
self.log(f"pull push 실패: {exc}")
# --- outbound send ---
def send_file(
self,
path: Path,
remote_name: Optional[str] = None,
progress: Optional[Callable[[int, int], None]] = None,
accept_timeout: float = 120.0,
) -> None:
path = Path(path)
if not path.is_file():
raise FileNotFoundError(path)
with self._xfer_lock:
self._send_file_locked(path, remote_name or path.name, progress, accept_timeout)
def _send_file_locked(
self,
path: Path,
name: str,
progress: Optional[Callable[[int, int], None]],
accept_timeout: float,
) -> None:
size = path.stat().st_size
sha = hashlib.sha256()
with path.open("rb") as f:
while True:
b = f.read(1024 * 1024)
if not b:
break
sha.update(b)
digest = sha.hexdigest()
meta = f"{name}\n{size}\n{digest}".encode("utf-8")
self._pending_accept.clear()
self._accept_result = None
self.log(f"파일 제안: {name} ({size} bytes)")
self.send(MsgType.FILE_OFFER, meta)
if not self._pending_accept.wait(accept_timeout):
raise TimeoutError("상대가 파일 수신을 수락하지 않았습니다.")
if not self._accept_result:
raise RuntimeError("상대가 파일 수신을 거부했습니다.")
sent = 0
with path.open("rb") as f:
while True:
chunk = f.read(CHUNK_PAYLOAD)
if not chunk:
break
self._file_ack.clear()
self.send(MsgType.FILE_CHUNK, chunk)
if not self._file_ack.wait(60.0):
raise TimeoutError("청크 ACK 타임아웃")
sent += len(chunk)
if progress:
progress(sent, size)
if self.on_progress:
self.on_progress(sent, size, name)
self.send(MsgType.FILE_DONE, digest.encode("ascii"))
self.log(f"전송 완료: {name}")
# --- inbound receive (auto-accept into inbound_dest) ---
def _on_inbound_offer(self, frame: Frame) -> None:
# If we're mid outbound wait for ACCEPT, this is our counterpart — ignore auto path
# Actually inbound offers always handled here; outbound waits on _pending_accept which is set by ACCEPT from peer.
try:
name, size_s, digest = frame.payload.decode("utf-8").split("\n", 2)
size = int(size_s)
except Exception:
self.send(MsgType.FILE_REJECT, b"bad offer")
return
# sanitize name: no absolute, no ..
name = name.replace("\\", "/").lstrip("/")
parts = [p for p in name.split("/") if p and p != ".."]
safe = "/".join(parts)
if not safe:
self.send(MsgType.FILE_REJECT, b"bad name")
return
out = (Path(self.inbound_dest) / safe).resolve()
try:
out.relative_to(Path(self.inbound_dest).resolve())
except ValueError:
self.send(MsgType.FILE_REJECT, b"path escape")
return
out.parent.mkdir(parents=True, exist_ok=True)
self._inbound = {
"path": out,
"name": safe,
"size": size,
"digest": digest.strip(),
"fh": out.open("wb"),
"sha": hashlib.sha256(),
"received": 0,
}
self.send(MsgType.FILE_ACCEPT, b"ok")
self.log(f"수신 시작: {safe} ({size} bytes)")
def _on_inbound_chunk(self, frame: Frame) -> None:
st = self._inbound
fh = st.get("fh")
if not fh:
return
fh.write(frame.payload)
st["sha"].update(frame.payload)
st["received"] = st.get("received", 0) + len(frame.payload)
self.send(MsgType.FILE_ACK, b"")
if self.on_progress:
self.on_progress(st["received"], st["size"], st.get("name") or "")
def _on_inbound_done(self, frame: Frame) -> None:
st = self._inbound
fh = st.pop("fh", None)
if fh:
fh.close()
digest = st.get("sha").hexdigest() if st.get("sha") else ""
expected = st.get("digest", "")
remote = frame.payload.decode("ascii", errors="replace")
if digest != expected or (remote and remote != digest):
self.log(f"해시 불일치: {st.get('name')}")
else:
self.log(f"수신 완료: {st.get('path')}")
self._inbound = {}
# compatibility helpers used by CLI
def receive_file(
self,
dest_dir: Path,
allow: Optional[Callable[[str, int], bool]] = None,
progress: Optional[Callable[[int, int], None]] = None,
timeout: float = 600.0,
) -> Path:
"""Wait until an inbound transfer completes into dest_dir."""
self.inbound_dest = Path(dest_dir)
self.inbound_dest.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + timeout
last_path = None
while time.monotonic() < deadline:
if self._inbound.get("fh") is None and last_path and not self._inbound:
# completed
if progress and last_path:
pass
return Path(last_path)
if self._inbound.get("path"):
last_path = self._inbound["path"]
if progress and self._inbound.get("size"):
progress(self._inbound.get("received", 0), self._inbound["size"])
time.sleep(0.1)
raise TimeoutError("파일 수신 타임아웃")
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>JUC500</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="app">
<header class="top">
<div class="brand">
<span class="brand-mark">JUC500</span>
<span class="brand-sub" id="thisHost">Smart Data Link 파일 전송</span>
</div>
<div class="conn">
<span id="autoBadge" class="auto-badge wait">미연결</span>
<span id="inputBadge" class="auto-badge wait" title="마우스·키보드 공유">입력공유 미지원</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 GUI 포트입니다. 변경 후 다음 실행 시 적용됩니다.</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">연결을 누르면 상대 PC 폴더가 표시됩니다</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/>또는 패널로 드래그</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>
+744
View File
@@ -0,0 +1,744 @@
: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 {
max-width: 1440px;
margin: 0 auto;
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-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;
}
.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; }
}
+279
View File
@@ -0,0 +1,279 @@
"""Low-level USB access to the j5create JUC500 Smart Data Link cable.
The cable enumerates as USB VID:PID 0711:7500 with six interfaces:
0 CDC Communication (control notifications)
1 CDC Data (bulk 0x02 / 0x83) – proprietary control path
2 Mass Storage (WORMHOLE virtual CD, claimed by OS)
3 HID mouse (KM switch, claimed by OS)
4 HID keyboard (KM switch, claimed by OS)
5 Vendor Specific (bulk 0x08/0x89 + 0x0A/0x8B) – data bridge
Official Wormhole software is Intel/x86_64-only and uses the deprecated
IOUSBDevice stack, which does not work reliably on Apple Silicon. This
module opens interfaces 1 and 5 via libusb for a Silicon-native pathway.
"""
from __future__ import annotations
import os
import sys
import time
from dataclasses import dataclass
from typing import Optional
import usb.core
import usb.util
VID = 0x0711
PID = 0x7500
# Interface numbers
IF_CDC_COMM = 0
IF_CDC_DATA = 1
IF_VENDOR = 5
# Vendor interface endpoints (file payload)
EP_DATA_OUT = 0x08
EP_DATA_IN = 0x89
# Vendor interface endpoints (keepalive / control mux)
EP_CTRL_OUT = 0x0A
EP_CTRL_IN = 0x8B
# CDC data endpoints (alternate / reserved)
EP_CDC_OUT = 0x02
EP_CDC_IN = 0x83
MAX_PACKET = 512
def _libusb_backend():
"""Locate libusb-1.0 on macOS / Windows / Linux."""
import usb.backend.libusb1
candidates = []
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
candidates += [
os.path.join(mei, "libusb-1.0.dll"),
os.path.join(mei, "libusb-1.0.dylib"),
os.path.join(mei, "libusb-1.0.so"),
]
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
candidates += [
os.path.join(exe_dir, "libusb-1.0.dll"),
os.path.join(exe_dir, "_internal", "libusb-1.0.dll"),
]
if sys.platform == "darwin":
candidates += [
os.path.expanduser("~/homebrew/lib/libusb-1.0.dylib"),
"/opt/homebrew/lib/libusb-1.0.dylib",
"/usr/local/lib/libusb-1.0.dylib",
]
elif sys.platform == "win32":
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.dirname(here)
candidates += [
os.path.join(root, "vendor", "libusb-1.0.dll"),
os.path.join(os.environ.get("PROGRAMFILES", ""), "libusb", "libusb-1.0.dll"),
"libusb-1.0.dll",
]
else:
candidates += ["libusb-1.0.so", "libusb-1.0.so.0"]
for path in candidates:
if path and (path.endswith((".dll", ".dylib", ".so", ".so.0")) or os.path.exists(path)):
if path.endswith((".dll", ".dylib", ".so", ".so.0")) and not os.path.exists(path):
# bare DLL name: allow loader PATH search via find_library
if os.path.sep in path or (len(path) > 1 and path[1] == ":"):
continue
be = usb.backend.libusb1.get_backend(find_library=lambda _n, p=path: p)
if be is not None:
return be
be = usb.backend.libusb1.get_backend()
if be is None:
raise RuntimeError(
"libusb-1.0 를 찾을 수 없습니다. "
"macOS: brew install libusb / Windows: vendor/libusb-1.0.dll 배치"
)
return be
@dataclass
class DeviceInfo:
bus: int
address: int
serial: str
product: str
manufacturer: str
def list_devices() -> list[DeviceInfo]:
backend = _libusb_backend()
out: list[DeviceInfo] = []
for dev in usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend):
try:
serial = usb.util.get_string(dev, dev.iSerialNumber) or ""
except Exception:
serial = ""
try:
product = usb.util.get_string(dev, dev.iProduct) or "Smart Data Link"
except Exception:
product = "Smart Data Link"
try:
manufacturer = usb.util.get_string(dev, dev.iManufacturer) or ""
except Exception:
manufacturer = ""
out.append(
DeviceInfo(
bus=dev.bus,
address=dev.address,
serial=serial.strip(),
product=product.strip(),
manufacturer=manufacturer.strip(),
)
)
return out
class Juc500Link:
"""Claim CDC+Vendor interfaces and provide framed bulk I/O."""
def __init__(self, serial: Optional[str] = None, timeout_ms: int = 2000):
self.timeout_ms = timeout_ms
self._dev: Optional[usb.core.Device] = None
self._claimed: list[int] = []
self._serial = serial
self.open()
def open(self) -> None:
backend = _libusb_backend()
matches = list(
usb.core.find(find_all=True, idVendor=VID, idProduct=PID, backend=backend)
)
if not matches:
raise RuntimeError(
"JUC500(0711:7500 Smart Data Link) 를 찾지 못했습니다. "
"케이블이 연결되어 있는지 확인하세요."
)
chosen = None
for dev in matches:
if self._serial:
try:
s = (usb.util.get_string(dev, dev.iSerialNumber) or "").strip()
except Exception:
s = ""
if s != self._serial.strip():
continue
chosen = dev
break
if chosen is None:
raise RuntimeError(f"시리얼 '{self._serial}' 에 해당하는 JUC500 이 없습니다.")
self._dev = chosen
# Avoid set_configuration() if the OS already activated config 1
# (mass-storage CD is mounted). Re-setting can disrupt the volume.
try:
cfg = self._dev.get_active_configuration()
except usb.core.USBError:
self._dev.set_configuration()
cfg = self._dev.get_active_configuration()
if cfg.bConfigurationValue != 1:
self._dev.set_configuration(1)
for ifnum in (IF_CDC_DATA, IF_VENDOR):
self._claim(ifnum)
# CDC Comm is optional (interrupt notifications)
try:
self._claim(IF_CDC_COMM)
except usb.core.USBError:
pass
def _claim(self, ifnum: int) -> None:
assert self._dev is not None
try:
if self._dev.is_kernel_driver_active(ifnum):
self._dev.detach_kernel_driver(ifnum)
except (NotImplementedError, usb.core.USBError):
pass
usb.util.claim_interface(self._dev, ifnum)
self._claimed.append(ifnum)
@property
def serial(self) -> str:
assert self._dev is not None
try:
return (usb.util.get_string(self._dev, self._dev.iSerialNumber) or "").strip()
except Exception:
return ""
def write_data(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
return self._bulk_write(EP_DATA_OUT, payload, timeout_ms)
def read_data(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
return self._bulk_read(EP_DATA_IN, size, timeout_ms)
def write_ctrl(self, payload: bytes, timeout_ms: Optional[int] = None) -> int:
return self._bulk_write(EP_CTRL_OUT, payload, timeout_ms)
def read_ctrl(self, size: int = MAX_PACKET, timeout_ms: Optional[int] = None) -> bytes:
return self._bulk_read(EP_CTRL_IN, size, timeout_ms)
def _bulk_write(self, ep: int, payload: bytes, timeout_ms: Optional[int]) -> int:
assert self._dev is not None
to = self.timeout_ms if timeout_ms is None else timeout_ms
# Split into max-packet chunks for bridge chips that dislike large URBs
sent = 0
view = memoryview(payload)
while sent < len(payload):
chunk = view[sent : sent + MAX_PACKET]
n = self._dev.write(ep, chunk.tobytes(), timeout=to)
sent += n
if n == 0:
break
return sent
def _bulk_read(self, ep: int, size: int, timeout_ms: Optional[int]) -> bytes:
assert self._dev is not None
to = self.timeout_ms if timeout_ms is None else timeout_ms
try:
data = self._dev.read(ep, size, timeout=to)
return bytes(data)
except usb.core.USBTimeoutError:
return b""
def drain(self, duration_s: float = 0.2) -> None:
deadline = time.monotonic() + duration_s
while time.monotonic() < deadline:
got = False
for reader in (self.read_data, self.read_ctrl):
chunk = reader(MAX_PACKET, timeout_ms=50)
if chunk:
got = True
if not got:
break
def close(self) -> None:
if self._dev is None:
return
for ifnum in list(self._claimed):
try:
usb.util.release_interface(self._dev, ifnum)
except Exception:
pass
self._claimed.clear()
try:
usb.util.dispose_resources(self._dev)
except Exception:
pass
self._dev = None
def __enter__(self) -> "Juc500Link":
return self
def __exit__(self, *exc) -> None:
self.close()
+15
View File
@@ -0,0 +1,15 @@
JUC500 — Windows 11 사용 안내
==============================
1) JUC500 케이블을 연결합니다.
2) 공식 j5create Wormhole 소프트웨어가 떠 있으면 종료/제거합니다.
3) Zadig(https://zadig.akeo.ie/) 로 Smart Data Link 장치에 WinUSB 를 설치합니다.
- Options → List All Devices
- Smart Data Link → Interface 5 (Vendor) → WinUSB → Replace Driver
- (권장) Interface 1 (CDC Data) 도 WinUSB
- Interface 2(Mass Storage)·3·4(HID) 는 그대로 둡니다.
4) JUC500.exe 를 실행합니다. 브라우저가 열리면 「연결」을 누릅니다.
5) Mac 쪽에서도 같은 프로그램을 실행한 뒤 연결합니다.
포터블: JUC500-portable.exe 는 설치 없이 실행할 수 있습니다.
(같은 WinUSB/libusb 설정이 필요합니다.)
+63
View File
@@ -0,0 +1,63 @@
# JUC500 Windows 64-bit 설치 (Inno Setup 없이)
# PowerShell -ExecutionPolicy Bypass -File install.ps1
param(
[string]$SourceDir = "",
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\JUC500"
)
$ErrorActionPreference = "Stop"
if (-not $SourceDir) {
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$candidates = @(
(Join-Path $here "..\..\dist\JUC500"),
(Join-Path $here "JUC500"),
(Join-Path (Get-Location) "JUC500"),
(Join-Path (Get-Location) "dist\JUC500"),
$here
)
foreach ($c in $candidates) {
$exe = Join-Path $c "JUC500.exe"
if (Test-Path $exe) { $SourceDir = (Resolve-Path $c).Path; break }
}
}
if (-not $SourceDir -or -not (Test-Path (Join-Path $SourceDir "JUC500.exe"))) {
Write-Host "JUC500.exe 를 찾을 수 없습니다. SourceDir=$SourceDir" -ForegroundColor Red
exit 1
}
Write-Host "==> 설치: $SourceDir -> $InstallDir"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
Get-Process -Name "JUC500","JUC500-portable" -ErrorAction SilentlyContinue |
Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 400
Copy-Item -Path (Join-Path $SourceDir "*") -Destination $InstallDir -Recurse -Force
$exe = Join-Path $InstallDir "JUC500.exe"
$startMenu = Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\JUC500"
New-Item -ItemType Directory -Force -Path $startMenu | Out-Null
$wsh = New-Object -ComObject WScript.Shell
$lnk = $wsh.CreateShortcut((Join-Path $startMenu "JUC500.lnk"))
$lnk.TargetPath = $exe
$lnk.WorkingDirectory = $InstallDir
$lnk.Description = "JUC500 Smart Data Link 파일 전송"
$lnk.Save()
$desk = [Environment]::GetFolderPath("Desktop")
$dlnk = $wsh.CreateShortcut((Join-Path $desk "JUC500.lnk"))
$dlnk.TargetPath = $exe
$dlnk.WorkingDirectory = $InstallDir
$dlnk.Save()
Write-Host ""
Write-Host "설치 완료: $InstallDir" -ForegroundColor Green
Write-Host "WinUSB(Zadig) 설정이 필요하면 packaging\windows\README-WIN.txt 를 보세요."
$launch = Read-Host "지금 실행할까요? (Y/N)"
if ($launch -match '^[Yy]') {
Start-Process $exe
}
+52
View File
@@ -0,0 +1,52 @@
; JUC500 Windows 64-bit installer (Inno Setup 6+)
; 빌드: ISCC.exe packaging\windows\juc500.iss
#define MyAppName "JUC500"
#define MyAppVersion "0.1.0"
#define MyAppPublisher "juc500"
#define MyAppURL "https://github.com"
#define MyAppExeName "JUC500.exe"
[Setup]
AppId={{B8D4F0A2-3C5E-4F9B-8D2A-1E6F7B8C9D0E}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
OutputDir=..\..\dist-windows
OutputBaseFilename=JUC500-Setup-win64
Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
UninstallDisplayIcon={app}\{#MyAppExeName}
CloseApplications=yes
InfoBeforeFile=README-WIN.txt
[Languages]
Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\..\dist\JUC500\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "README-WIN.txt"; DestDir: "{app}"; Flags: ignoreversion
Source: "zadig-hint.url"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{group}\Zadig (WinUSB 설정 안내)"; Filename: "{app}\zadig-hint.url"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
Filename: "{app}\README-WIN.txt"; Description: "WinUSB(Zadig) 설정 안내 열기"; Flags: postinstall shellexec skipifsilent unchecked
+5
View File
@@ -0,0 +1,5 @@
@echo off
REM JUC500 — 현재 폴더의 앱을 %LOCALAPPDATA%\Programs\JUC500 에 설치
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" -SourceDir "%~dp0"
if errorlevel 1 pause
+2
View File
@@ -0,0 +1,2 @@
[InternetShortcut]
URL=https://zadig.akeo.ie/
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "juc500-xfer"
version = "0.1.0"
description = "Apple Silicon Mac ↔ Windows 11 file transfer over j5create JUC500"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["pyusb>=1.2.1"]
[project.scripts]
juc500 = "juc500_xfer.cli:main"
[tool.setuptools.packages.find]
include = ["juc500_xfer*"]
[tool.setuptools.package-data]
juc500_xfer = ["static/*"]
+1
View File
@@ -0,0 +1 @@
pyusb>=1.2.1
+25
View File
@@ -0,0 +1,25 @@
#!/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
pip install -r requirements.txt
pip install -e .
echo
echo "설치 완료. 예:"
echo " source $ROOT/.venv/bin/activate"
echo " juc500 info"
echo " juc500 recv -y --dest ~/Downloads"
echo " juc500 send /path/to/file"
+16
View File
@@ -0,0 +1,16 @@
@echo off
setlocal
cd /d %~dp0..
py -3 -m venv .venv
call .venv\Scripts\activate.bat
python -m pip install -U pip
pip install -r requirements.txt
pip install -e .
echo.
echo Setup done. Next:
echo .venv\Scripts\activate
echo python -m juc500_xfer info
echo See docs\WINDOWS.md for Zadig / WinUSB steps.
endlocal
+2
View File
@@ -0,0 +1,2 @@
# Place libusb-1.0.dll here for Windows (x64).
# Download: https://github.com/libusb/libusb/releases
BIN
View File
Binary file not shown.