commit 8774a4843e5f6aaaab279ff28937b1ad340a8b8c Author: joyharam <150105283@users.noreply.github.com> Date: Wed Jul 15 10:26:10 2026 +0900 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 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml new file mode 100644 index 0000000..d95a7e5 --- /dev/null +++ b/.github/workflows/build-windows.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac35511 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..fba98d0 --- /dev/null +++ b/README.md @@ -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) 를 참고하세요. diff --git a/build-installer-windows.bat b/build-installer-windows.bat new file mode 100644 index 0000000..95db75a --- /dev/null +++ b/build-installer-windows.bat @@ -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 diff --git a/build-installer-windows.sh b/build-installer-windows.sh new file mode 100755 index 0000000..c4b601c --- /dev/null +++ b/build-installer-windows.sh @@ -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 diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md new file mode 100644 index 0000000..af10edc --- /dev/null +++ b/docs/ANALYSIS.md @@ -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** (``, `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을 유저랜드에 노출해야 한다. diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 0000000..d6f7a71 --- /dev/null +++ b/docs/WINDOWS.md @@ -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` 하면 전송이 시작됩니다. diff --git a/juc500-windows-onefile.spec b/juc500-windows-onefile.spec new file mode 100644 index 0000000..3b88e23 --- /dev/null +++ b/juc500-windows-onefile.spec @@ -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, +) diff --git a/juc500-windows.spec b/juc500-windows.spec new file mode 100644 index 0000000..cb34478 --- /dev/null +++ b/juc500-windows.spec @@ -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", +) diff --git a/juc500_app.py b/juc500_app.py new file mode 100644 index 0000000..1ad276a --- /dev/null +++ b/juc500_app.py @@ -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()) diff --git a/juc500_xfer/__init__.py b/juc500_xfer/__init__.py new file mode 100644 index 0000000..621e022 --- /dev/null +++ b/juc500_xfer/__init__.py @@ -0,0 +1,3 @@ +"""JUC500 cross-platform file transfer for Apple Silicon Mac ↔ Windows 11.""" + +__version__ = "0.1.0" diff --git a/juc500_xfer/__main__.py b/juc500_xfer/__main__.py new file mode 100644 index 0000000..bfdcd0c --- /dev/null +++ b/juc500_xfer/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/juc500_xfer/cli.py b/juc500_xfer/cli.py new file mode 100644 index 0000000..f655bbc --- /dev/null +++ b/juc500_xfer/cli.py @@ -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()) diff --git a/juc500_xfer/gui.py b/juc500_xfer/gui.py new file mode 100644 index 0000000..f71f9c5 --- /dev/null +++ b/juc500_xfer/gui.py @@ -0,0 +1,5 @@ +"""호환용 — 웹 GUI로 위임.""" + +from .gui_server import run_gui + +__all__ = ["run_gui"] diff --git a/juc500_xfer/gui_server.py b/juc500_xfer/gui_server.py new file mode 100644 index 0000000..17fcb9d --- /dev/null +++ b/juc500_xfer/gui_server.py @@ -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() diff --git a/juc500_xfer/protocol.py b/juc500_xfer/protocol.py new file mode 100644 index 0000000..2e717ac --- /dev/null +++ b/juc500_xfer/protocol.py @@ -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(" 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(" 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(" 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("파일 수신 타임아웃") diff --git a/juc500_xfer/static/app.js b/juc500_xfer/static/app.js new file mode 100644 index 0000000..94f073e --- /dev/null +++ b/juc500_xfer/static/app.js @@ -0,0 +1,1254 @@ +const state = { + connected: false, + connecting: false, + currentPath: "", + entries: [], + localPath: "", + localEntries: [], + localSelected: new Set(), + remoteSelected: new Set(), + remoteFocus: "", + localFocus: "", + localSort: { key: "name", dir: 1 }, + remoteSort: { key: "name", dir: 1 }, + remoteStartPath: "", + remoteReceivePath: "", +}; + +const $ = (id) => document.getElementById(id); + +const ICON_FOLDER = ``; +const ICON_FILE = ``; + +function joinPath(base, name) { + if (!base) return name; + return `${base.replace(/\/+$/, "")}/${name}`; +} + +function parentPath(path) { + const parts = String(path || "") + .replace(/^\/+/, "") + .split("/") + .filter(Boolean); + if (!parts.length) return null; + parts.pop(); + return parts.join("/"); +} + +function appendParentRow(tbody, { canGo, onGo }) { + const tr = document.createElement("tr"); + tr.className = canGo ? "parent-row" : "parent-row parent-row-root"; + tr.dataset.isdir = "1"; + tr.dataset.parent = "1"; + tr.innerHTML = ` +
${ICON_FOLDER}..
+ — + — + 상위 폴더 + `; + tr.title = canGo ? "상위 폴더로 이동" : "최상위 폴더입니다"; + const go = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!canGo) { + setStatus("이미 최상위 폴더입니다"); + return; + } + onGo(); + }; + tr.onclick = go; + tr.ondblclick = go; + // 맨 위 고정 + tbody.insertBefore(tr, tbody.firstChild); +} + +function displayPath(p) { + return "/" + (p || ""); +} + +function breadcrumbParts(path) { + const parts = String(path || "") + .replace(/^\/+/, "") + .split("/") + .filter(Boolean); + const crumbs = [{ label: "루트", path: "" }]; + let acc = ""; + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part; + crumbs.push({ label: part, path: acc }); + } + return crumbs; +} + +function renderPathBar(el, path, onNavigate) { + el.innerHTML = ""; + breadcrumbParts(path).forEach((c, idx) => { + if (idx > 0) { + const sep = document.createElement("span"); + sep.className = "path-sep"; + sep.textContent = "›"; + el.appendChild(sep); + } + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "path-crumb"; + btn.textContent = c.label; + btn.title = displayPath(c.path); + btn.onclick = () => onNavigate(c.path); + el.appendChild(btn); + }); +} + +function human(n) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let f = Number(n) || 0; + for (const u of units) { + if (f < 1024 || u === "TB") { + return u === "B" ? `${Math.round(f)}B` : `${f.toFixed(1)} ${u}`; + } + f /= 1024; + } + return `${n}B`; +} + +function formatMtime(ts) { + if (!ts) return "—"; + const d = new Date(Number(ts) * (Number(ts) < 1e12 ? 1000 : 1)); + if (Number.isNaN(d.getTime())) return "—"; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${y}. ${m}. ${day}. ${hh}:${mm}`; +} + +function fileKind(name, isDir) { + if (isDir) return "폴더"; + const ext = String(name).includes(".") ? String(name).split(".").pop().toLowerCase() : ""; + const map = { + png: "PNG 이미지", + jpg: "JPEG 이미지", + jpeg: "JPEG 이미지", + gif: "GIF 이미지", + webp: "이미지", + pdf: "PDF 문서", + txt: "텍스트", + md: "Markdown", + json: "JSON", + js: "JavaScript", + ts: "TypeScript", + py: "Python", + zip: "ZIP 아카이브", + dmg: "디스크 이미지", + app: "응용 프로그램", + }; + if (!ext) return "문서"; + return map[ext] || `${ext.toUpperCase()} 파일`; +} + +function setStatus(text, isError = false) { + const el = $("statusText"); + el.textContent = text; + el.classList.toggle("error", !!isError); +} + +let settingsPortsCache = []; + +function openSettingsModal() { + const modal = $("settingsModal"); + if (!modal) return; + modal.classList.remove("hidden"); + loadSettingsPorts(); +} + +function closeSettingsModal() { + const modal = $("settingsModal"); + if (!modal) return; + modal.classList.add("hidden"); + const msg = $("settingsMsg"); + if (msg) { + msg.hidden = true; + msg.textContent = ""; + } +} + +function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderPortsForm(ports) { + const form = $("portsForm"); + if (!form) return; + form.innerHTML = ""; + settingsPortsCache = ports || []; + for (const p of settingsPortsCache) { + const row = document.createElement("div"); + row.className = "port-row"; + row.innerHTML = ` +
+ ${escapeHtml(p.label)} + ${escapeHtml(p.description || "")} + ${escapeHtml(p.protocol)} · 기본 ${p.default} +
+ + `; + form.appendChild(row); + } +} + +function collectPortsFromForm() { + const out = {}; + document.querySelectorAll("[data-port-key]").forEach((input) => { + out[input.getAttribute("data-port-key")] = Number(input.value); + }); + return out; +} + +async function loadSettingsPorts() { + const msg = $("settingsMsg"); + try { + const data = await api("/api/settings/ports"); + renderPortsForm(data.ports || []); + const pathEl = $("portsConfigPath"); + if (pathEl) { + pathEl.textContent = data.config_path ? `설정 파일: ${data.config_path}` : ""; + } + if (msg) { + msg.hidden = true; + msg.textContent = ""; + } + } catch (e) { + if (msg) { + msg.hidden = false; + msg.className = "settings-msg error"; + msg.textContent = e.message || String(e); + } + } +} + +async function waitForGui(url, tries = 40) { + const base = url.replace(/\/?$/, "/"); + for (let i = 0; i < tries; i++) { + try { + const res = await fetch(base + "api/status", { cache: "no-store" }); + if (res.ok) return true; + } catch { + /* waiting */ + } + await new Promise((r) => setTimeout(r, 400)); + } + return false; +} + +async function applyPortsAndRestart() { + const msg = $("settingsMsg"); + const btn = $("portsApplyBtn"); + const ports = collectPortsFromForm(); + if (btn) btn.disabled = true; + if (msg) { + msg.hidden = false; + msg.className = "settings-msg"; + msg.textContent = "적용 중… 서비스를 재시작합니다"; + } + try { + const data = await api("/api/settings/ports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ports, restart: true }), + }); + const redirect = data.redirect || `http://127.0.0.1:${data.gui_port || ports.gui}/`; + if (msg) msg.textContent = data.message || "재시작 완료 대기 중…"; + const ok = await waitForGui(redirect); + if (ok) { + window.location.replace(redirect); + return; + } + if (msg) { + msg.className = "settings-msg error"; + msg.textContent = "재시작 대기가 지연됩니다. 잠시 후 " + redirect + " 로 접속하세요."; + } + } catch (e) { + if (msg) { + msg.hidden = false; + msg.className = "settings-msg error"; + msg.textContent = e.message || String(e); + } + } finally { + if (btn) btn.disabled = false; + } +} + +function bindSettingsUi() { + const openBtn = $("settingsBtn"); + if (openBtn) openBtn.onclick = () => openSettingsModal(); + document.querySelectorAll("[data-close-settings]").forEach((el) => { + el.addEventListener("click", () => closeSettingsModal()); + }); + const resetBtn = $("portsResetBtn"); + if (resetBtn) { + resetBtn.onclick = () => { + for (const p of settingsPortsCache) { + const input = document.querySelector(`[data-port-key="${p.key}"]`); + if (input) input.value = p.default; + } + }; + } + const applyBtn = $("portsApplyBtn"); + if (applyBtn) applyBtn.onclick = () => applyPortsAndRestart(); + window.addEventListener("keydown", (ev) => { + const modal = $("settingsModal"); + if (ev.key === "Escape" && modal && !modal.classList.contains("hidden")) { + closeSettingsModal(); + } + }); +} + +function updatePathHints() { + const local = displayPath(state.localPath); + const remote = displayPath(state.currentPath); + if ($("localPathHint")) $("localPathHint").textContent = local; + if ($("remotePathHint")) $("remotePathHint").textContent = remote; + if ($("railLocalPath")) $("railLocalPath").textContent = local; + if ($("railRemotePath")) $("railRemotePath").textContent = remote; +} + +function remoteUploadDest(explicit) { + if (explicit != null && explicit !== undefined) return explicit; + return state.currentPath || state.remoteReceivePath || state.remoteStartPath || ""; +} + +function updateInputShareUI(info) { + const badge = $("inputBadge"); + const btn = $("inputShareBtn"); + const localBtn = $("controlLocalBtn"); + const remoteBtn = $("controlRemoteBtn"); + if (!badge || !btn) return; + // 수신 측(being_controlled)도 버튼 활성 — 제어권 회수/탈취 + const linkOk = !!( + state.connected || + (info && (info.peer || info.being_controlled || info.primary_ready)) + ); + const shareActive = !!( + info && + info.enabled && + linkOk && + (info.trusted || info.being_controlled) + ); + const controlReady = shareActive; + if (localBtn && remoteBtn) { + localBtn.disabled = !controlReady; + remoteBtn.disabled = !controlReady; + const remote = !!(info && info.controlling_remote); + const controlled = !!(info && info.being_controlled); + localBtn.classList.toggle("active", controlReady && !remote && !controlled); + remoteBtn.classList.toggle("active", controlReady && remote); + localBtn.title = controlled + ? "이 PC 제어 회수 (상대 조종 해제)" + : "이 PC 제어 — 상대 제어 중 왼쪽 끝으로 복귀"; + remoteBtn.title = controlled + ? "상대 PC 제어 가져오기 (제어권 탈취)" + : "상대 PC 제어 — 이후 화면 오른쪽 끝으로도 전환"; + } + if (!info || !info.supported) { + badge.textContent = "입력공유 미지원"; + badge.className = "auto-badge wait"; + btn.disabled = true; + return; + } + btn.disabled = !linkOk; + if (!info.trusted) { + badge.textContent = "손쉬운 사용 권한 필요"; + badge.className = "auto-badge wait"; + const pick = info.pick_path || info.python_app || info.executable || ""; + const pickName = pick.split("/").pop() || "앱"; + badge.title = pick + ? `선택할 항목: ${pickName}\n전체 경로: ${pick}\n(Finder는 상위 폴더를 열고 ${pickName} 을 선택합니다)` + : "시스템 설정 → 손쉬운 사용"; + btn.textContent = pickName.endsWith(".app") ? `${pickName} 열기` : "앱 경로 열기"; + return; + } + if (info.being_controlled) { + badge.textContent = "상대가 조종 중 — [이 PC]/[상대 PC]로 회수"; + badge.className = "auto-badge wait"; + badge.title = "이 PC: 조종 해제 / 상대 PC: 제어권 가져오기"; + btn.textContent = "입력공유 끄기"; + } else if (info.enabled && info.peer) { + badge.textContent = info.controlling_remote ? "제어: 상대 PC" : "제어: 이 PC"; + badge.className = "auto-badge ok"; + badge.title = "가장자리 전환: 오른쪽→상대, 왼쪽→이 PC · 단축키 Ctrl+Shift+U/←/→"; + btn.textContent = "입력공유 끄기"; + } else if (info.enabled) { + badge.textContent = info.status || "입력공유 대기"; + badge.className = "auto-badge wait"; + btn.textContent = "입력공유 끄기"; + } else { + badge.textContent = "입력공유 꺼짐"; + badge.className = "auto-badge wait"; + btn.textContent = "입력공유 켜기"; + } +} + +async function switchInputControl(target) { + try { + const res = await fetch("/api/inputshare/control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target }), + }); + const info = await res.json().catch(() => ({})); + updateInputShareUI(info); + if (!res.ok || info.ok === false) { + setStatus(info.error || "제어권 전환 실패", true); + return; + } + setStatus( + target === "remote" + ? "키보드·마우스 제어: 상대 PC (Ctrl+Shift+← 또는 [이 PC]로 복귀)" + : "키보드·마우스 제어: 이 PC (Ctrl+Shift+→ 또는 [상대 PC]로 전환)" + ); + } catch (e) { + setStatus(e.message, true); + } +} + +function updateTransferButtons() { + const canUp = state.connected && state.localSelected.size > 0; + const canDown = state.connected && state.remoteSelected.size > 0; + if ($("uploadBtn")) $("uploadBtn").disabled = !canUp; + if ($("downloadBtn")) $("downloadBtn").disabled = !canDown; +} + +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +async function api(path, options = {}) { + const res = await fetch(path, options); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || res.statusText || "요청 실패"); + err.code = data.code || ""; + err.status = res.status; + throw err; + } + return data; +} + +function updateBadge(connected, text) { + const badge = $("autoBadge"); + badge.textContent = text; + badge.className = connected ? "auto-badge ok" : "auto-badge wait"; +} + +function sortEntries(entries, sort) { + const dir = sort.dir; + const key = sort.key; + return [...entries].sort((a, b) => { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + let av; + let bv; + if (key === "size") { + av = a.is_dir ? -1 : Number(a.size) || 0; + bv = b.is_dir ? -1 : Number(b.size) || 0; + } else if (key === "mtime") { + av = Number(a.mtime) || 0; + bv = Number(b.mtime) || 0; + } else if (key === "kind") { + av = fileKind(a.name, a.is_dir); + bv = fileKind(b.name, b.is_dir); + } else { + av = String(a.name || "").toLowerCase(); + bv = String(b.name || "").toLowerCase(); + } + if (av < bv) return -1 * dir; + if (av > bv) return 1 * dir; + return String(a.name).localeCompare(String(b.name), "ko"); + }); +} + +function updateSortButtons(prefix, sort) { + document.querySelectorAll(`[data-sort-${prefix}]`).forEach((btn) => { + const k = btn.getAttribute(`data-sort-${prefix}`); + if (!btn.dataset.label) btn.dataset.label = btn.textContent.replace(/ [▲▼]$/, ""); + btn.classList.toggle("active", k === sort.key); + btn.textContent = + k === sort.key ? `${btn.dataset.label} ${sort.dir > 0 ? "▲" : "▼"}` : btn.dataset.label; + }); +} + +async function refreshStatus() { + const s = await api("/api/status"); + state.connected = !!s.connected; + $("disconnectBtn").disabled = !state.connected; + $("connectBtn").disabled = state.connected || state.connecting; + updateTransferButtons(); + $("mkdirBtn").disabled = !state.connected; + + const cable = s.device?.serial + ? `JUC500 ${s.device.serial}` + : s.cable_ip + ? `JUC500 ${s.cable_ip}` + : "JUC500 케이블"; + if (s.this_host) $("thisHost").textContent = `${s.this_host} · ${cable}`; + if (s.local_root) $("localRootHint").textContent = s.local_root; + + updateInputShareUI(s.inputshare); + + if (state.connected) { + updateBadge(true, "연결됨"); + $("peerInfo").textContent = s.hostname || s.host || "상대 PC"; + $("ifaceInfo").textContent = s.device?.serial ? `IF5 Vendor · ${s.device.serial}` : "JUC500 bulk bridge"; + $("remoteRootHint").textContent = s.root || s.host || "연결됨"; + if (s.remote_start != null) state.remoteStartPath = s.remote_start || ""; + if (s.remote_receive != null) state.remoteReceivePath = s.remote_receive || state.remoteStartPath; + } else if (!state.connecting) { + const hasCable = !!(s.device?.serial || s.cable_ip); + updateBadge(false, hasCable ? "케이블 감지됨" : "케이블 대기"); + $("peerInfo").textContent = + s.auto_status || + (hasCable + ? "연결을 누르면 상대 JUC500 프로그램과 핸드셰이크합니다" + : "JUC500(Smart Data Link) 케이블을 연결하세요"); + $("ifaceInfo").textContent = hasCable ? `시리얼 ${s.device?.serial || s.cable_ip}` : ""; + $("remoteRootHint").textContent = "연결 필요"; + } + + if (s.progress?.busy) { + const pct = s.progress.total ? Math.round((100 * s.progress.done) / s.progress.total) : 0; + $("progressBar").style.width = `${pct}%`; + setStatus(`전송 중 ${pct}% — ${s.progress.label || ""}`); + } + return s; +} + +async function connectOneClick() { + if (state.connecting || state.connected) return; + state.connecting = true; + $("connectBtn").disabled = true; + updateBadge(false, "연결 중…"); + setStatus("상대 JUC500 프로그램 대기 중…"); + $("peerInfo").textContent = "USB 핸드셰이크 중… 상대에서도 GUI를 실행하세요"; + + try { + const data = await api("/api/connect/auto", { method: "POST" }); + if (data.ok === false || data.code === "PEER_NOT_FOUND") { + const err = new Error(data.error || "상대 PC를 찾지 못했습니다."); + err.code = data.code || "PEER_NOT_FOUND"; + throw err; + } + + await api("/api/auto/start", { method: "POST" }).catch(() => {}); + state.connected = true; + state.connecting = false; + updateBadge(true, "연결됨"); + setStatus("연결됨 — 상대 폴더를 불러오는 중…"); + $("peerInfo").textContent = data.hostname || data.host || "상대 PC"; + $("disconnectBtn").disabled = false; + $("connectBtn").disabled = true; + + state.currentPath = ""; + state.remoteStartPath = data.start_path || ""; + state.remoteReceivePath = data.receive_path || data.start_path || ""; + updatePathHints(); + await loadDir(state.remoteStartPath || ""); + setStatus("연결 완료 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다"); + } catch (e) { + state.connecting = false; + state.connected = false; + updateBadge(false, "미연결"); + $("connectBtn").disabled = false; + $("disconnectBtn").disabled = true; + const msg = (e && e.message) || "연결 실패"; + setStatus(msg, true); + $("peerInfo").textContent = + e.code === "PEER_NOT_FOUND" + ? "상대 없음 — 양쪽 JUC500 GUI 실행·케이블·WinUSB(Zadig)를 확인하세요" + : "연결 실패 — 다시 연결을 누르세요"; + showRemoteMessage(msg, true); + } +} + +function showRemoteMessage(msg, isError = false) { + const tbody = $("dirList"); + tbody.innerHTML = ""; + $("remoteEmpty").classList.add("hidden"); + $("remoteCount").textContent = "—"; + const tr = document.createElement("tr"); + tr.innerHTML = `${escapeHtml(msg)}`; + tbody.appendChild(tr); +} + +async function disconnect() { + await api("/api/disconnect", { method: "POST" }); + state.connected = false; + state.connecting = false; + state.entries = []; + state.remoteFocus = ""; + showRemoteMessage("연결 후 여기에 상대 폴더가 표시됩니다"); + $("remoteEmpty").textContent = "연결 후 여기에 상대 폴더가 표시됩니다"; + $("remoteEmpty").classList.remove("hidden"); + $("dirList").innerHTML = ""; + renderPathBar($("pathBar"), "", () => {}); + $("upBtn").disabled = true; + $("mkdirBtn").disabled = true; + updatePathHints(); + await refreshStatus(); + setStatus("연결 해제됨 — 연결 버튼을 누르세요"); +} + +async function loadDir(path) { + if (!state.connected) { + setStatus("먼저 연결 버튼을 누르세요", true); + return; + } + const rel = String(path || "").replace(/^\/+/, ""); + const data = await api(`/api/listdir?path=${encodeURIComponent(rel)}`); + state.currentPath = String(data.path || rel || "").replace(/^\/+/, ""); + state.entries = data.entries || []; + state.remoteFocus = ""; + state.remoteSelected.clear(); + renderPathBar($("pathBar"), state.currentPath, (p) => { + loadDir(p).catch((err) => setStatus(err.message, true)); + }); + renderRemote(); + updatePathHints(); + updateTransferButtons(); + $("upBtn").disabled = !state.currentPath; +} + +function renderRemote() { + const tbody = $("dirList"); + tbody.innerHTML = ""; + updateSortButtons("remote", state.remoteSort); + const entries = sortEntries(state.entries, state.remoteSort); + $("remoteCount").textContent = + state.remoteSelected.size > 0 + ? `${state.remoteSelected.size}개 선택됨 / ${entries.length}개 항목` + : `${entries.length}개 항목`; + + if (!entries.length) { + $("remoteEmpty").textContent = state.connected + ? "폴더가 비어 있습니다" + : "연결 후 여기에 상대 폴더가 표시됩니다"; + $("remoteEmpty").classList.toggle("hidden", !!state.connected && !!state.currentPath); + if (!state.connected) { + $("remoteEmpty").classList.remove("hidden"); + return; + } + appendParentRow(tbody, { + canGo: !!state.currentPath, + onGo: () => goUp().catch((err) => setStatus(err.message, true)), + }); + if (state.currentPath) $("remoteEmpty").classList.add("hidden"); + else $("remoteEmpty").classList.remove("hidden"); + return; + } + $("remoteEmpty").classList.add("hidden"); + + entries.forEach((e) => { + const full = joinPath(state.currentPath, e.name); + const tr = document.createElement("tr"); + tr.dataset.path = full; + tr.dataset.isdir = e.is_dir ? "1" : "0"; + tr.draggable = true; + if (e.is_dir) tr.dataset.dropPath = full; + if (state.remoteSelected.has(full) || state.remoteFocus === full) { + tr.classList.add("selected"); + } + tr.innerHTML = ` +
${e.is_dir ? ICON_FOLDER : ICON_FILE}${escapeHtml(e.name)}
+ ${formatMtime(e.mtime)} + ${e.is_dir ? "—" : human(e.size)} + ${escapeHtml(fileKind(e.name, e.is_dir))} + `; + tr.onclick = (ev) => { + state.remoteFocus = full; + if (ev.metaKey || ev.ctrlKey) { + if (state.remoteSelected.has(full)) state.remoteSelected.delete(full); + else state.remoteSelected.add(full); + } else { + state.remoteSelected.clear(); + state.remoteSelected.add(full); + } + tbody.querySelectorAll("tr").forEach((x) => { + x.classList.toggle("selected", state.remoteSelected.has(x.dataset.path)); + }); + $("remoteCount").textContent = `${state.remoteSelected.size}개 선택됨 / ${entries.length}개 항목`; + updateTransferButtons(); + }; + tr.ondblclick = () => { + if (e.is_dir) loadDir(full).catch((err) => setStatus(err.message, true)); + }; + tr.addEventListener("dragstart", (ev) => { + if (!state.remoteSelected.has(full)) { + state.remoteSelected.clear(); + state.remoteSelected.add(full); + tbody.querySelectorAll("tr").forEach((x) => { + x.classList.toggle("selected", state.remoteSelected.has(x.dataset.path)); + }); + } + ev.dataTransfer.setData( + "application/x-fileshare-remote", + JSON.stringify([...state.remoteSelected]) + ); + ev.dataTransfer.effectAllowed = "copy"; + tr.classList.add("dragging"); + }); + tr.addEventListener("dragend", () => tr.classList.remove("dragging")); + tbody.appendChild(tr); + }); + appendParentRow(tbody, { + canGo: !!state.currentPath, + onGo: () => goUp().catch((err) => setStatus(err.message, true)), + }); +} + +async function loadLocal(path) { + const rel = String(path || "").replace(/^\/+/, ""); + const data = await api(`/api/local/listdir?path=${encodeURIComponent(rel)}`); + state.localPath = String(data.path || rel || "").replace(/^\/+/, ""); + state.localEntries = data.entries || []; + state.localSelected.clear(); + state.localFocus = ""; + renderPathBar($("localPathBar"), state.localPath, (p) => { + loadLocal(p).catch((err) => setStatus(err.message, true)); + }); + $("localUpBtn").disabled = !state.localPath; + updatePathHints(); + updateTransferButtons(); + renderLocal(); +} + +function renderLocal() { + const tbody = $("localDirList"); + tbody.innerHTML = ""; + updateSortButtons("local", state.localSort); + const entries = sortEntries(state.localEntries, state.localSort); + $("localCount").textContent = `${entries.length}개 항목`; + + if (!entries.length) { + $("localEmpty").classList.remove("hidden"); + appendParentRow(tbody, { + canGo: !!state.localPath, + onGo: () => localGoUp().catch((err) => setStatus(err.message, true)), + }); + if (state.localPath) $("localEmpty").classList.add("hidden"); + return; + } + $("localEmpty").classList.add("hidden"); + + entries.forEach((e) => { + const full = joinPath(state.localPath, e.name); + const tr = document.createElement("tr"); + tr.dataset.path = full; + tr.dataset.isdir = e.is_dir ? "1" : "0"; + tr.draggable = true; + if (e.is_dir) tr.dataset.dropPath = full; + if (state.localSelected.has(full) || state.localFocus === full) { + tr.classList.add(e.is_dir ? "selected" : "checked"); + } + tr.innerHTML = ` +
${e.is_dir ? ICON_FOLDER : ICON_FILE}${escapeHtml(e.name)}
+ ${formatMtime(e.mtime)} + ${e.is_dir ? "—" : human(e.size)} + ${escapeHtml(fileKind(e.name, e.is_dir))} + `; + tr.onclick = (ev) => { + state.localFocus = full; + if (e.is_dir) { + if (ev.metaKey || ev.ctrlKey) { + if (state.localSelected.has(full)) state.localSelected.delete(full); + else state.localSelected.add(full); + } else { + state.localSelected.clear(); + state.localSelected.add(full); + } + tbody.querySelectorAll("tr").forEach((x) => { + x.classList.toggle("checked", state.localSelected.has(x.dataset.path)); + x.classList.toggle("selected", state.localSelected.has(x.dataset.path) && x.dataset.isdir === "1"); + }); + $("localCount").textContent = + state.localSelected.size > 0 + ? `${state.localSelected.size}개 선택됨 / ${entries.length}개 항목` + : `${entries.length}개 항목`; + updateTransferButtons(); + return; + } + if (ev.metaKey || ev.ctrlKey) { + if (state.localSelected.has(full)) state.localSelected.delete(full); + else state.localSelected.add(full); + } else if (ev.shiftKey && state.localSelected.size) { + state.localSelected.add(full); + } else { + state.localSelected.clear(); + state.localSelected.add(full); + } + tbody.querySelectorAll("tr").forEach((x) => { + x.classList.toggle("checked", state.localSelected.has(x.dataset.path)); + x.classList.remove("selected"); + }); + $("localCount").textContent = + state.localSelected.size > 0 + ? `${state.localSelected.size}개 선택됨 / ${entries.length}개 항목` + : `${entries.length}개 항목`; + updateTransferButtons(); + }; + tr.ondblclick = () => { + if (e.is_dir) loadLocal(full).catch((err) => setStatus(err.message, true)); + }; + tr.addEventListener("dragstart", (ev) => { + if (!state.localSelected.has(full)) { + state.localSelected.clear(); + state.localSelected.add(full); + tbody.querySelectorAll("tr").forEach((x) => { + x.classList.toggle("checked", state.localSelected.has(x.dataset.path)); + }); + } + ev.dataTransfer.setData( + "application/x-fileshare-local", + JSON.stringify([...state.localSelected]) + ); + ev.dataTransfer.effectAllowed = "copy"; + tr.classList.add("dragging"); + }); + tr.addEventListener("dragend", () => tr.classList.remove("dragging")); + tbody.appendChild(tr); + }); + appendParentRow(tbody, { + canGo: !!state.localPath, + onGo: () => localGoUp().catch((err) => setStatus(err.message, true)), + }); +} + +async function goUp() { + if (!state.connected) { + setStatus("먼저 연결 버튼을 누르세요", true); + return; + } + const parent = parentPath(state.currentPath); + if (parent === null) { + updatePathHints(); + setStatus("이미 최상위 폴더입니다"); + return; + } + await loadDir(parent); +} + +async function localGoUp() { + const parent = parentPath(state.localPath); + if (parent === null) { + setStatus("이미 최상위 폴더입니다"); + return; + } + await loadLocal(parent); +} + +async function mkdir() { + if (!state.connected) return; + const name = prompt("새 폴더 이름"); + if (!name) return; + const path = joinPath(state.currentPath, name.trim()); + try { + await api("/api/mkdir", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + await loadDir(state.currentPath); + updatePathHints(); + setStatus(`폴더 생성: ${displayPath(path)}`); + } catch (e) { + setStatus(e.message, true); + } +} + +async function watchProgress(run) { + $("progressBar").style.width = "2%"; + const poll = setInterval(async () => { + try { + const p = await api("/api/progress"); + if (p.total) { + const pct = Math.max(2, Math.round((100 * p.done) / p.total)); + $("progressBar").style.width = `${pct}%`; + setStatus(`전송 중 ${pct}% — ${p.label || ""}`); + } + if (p.error) setStatus(p.error, true); + } catch (_) {} + }, 250); + try { + await run(); + $("progressBar").style.width = "100%"; + } finally { + clearInterval(poll); + setTimeout(() => { + $("progressBar").style.width = "0%"; + }, 1200); + } +} + +async function uploadFiles(fileList, destDir) { + if (!state.connected) { + setStatus("먼저 연결 버튼을 누르세요", true); + return; + } + const files = [...fileList].filter((f) => f && f.size >= 0 && f.name); + if (!files.length) { + setStatus("폴더는 OS에서 바로 드롭할 수 없습니다. 파일을 드롭하거나 패널에서 드래그하세요", true); + return; + } + const dest = remoteUploadDest(destDir); + const fd = new FormData(); + fd.append("dest_dir", dest); + files.forEach((f) => fd.append("file", f, f.name)); + setStatus(`${files.length}개 파일 → 상대 ${displayPath(dest)} 전송…`); + await watchProgress(async () => { + const res = await fetch("/api/upload", { method: "POST", body: fd }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "전송 실패"); + setStatus(`${data.count}개 전송 완료 → ${displayPath(data.dest_dir)}`); + await loadDir(state.currentPath); + }).catch((e) => setStatus(e.message, true)); +} + +async function writeLocalFiles(fileList, destDir) { + const files = [...fileList].filter((f) => f && f.size >= 0 && f.name); + if (!files.length) { + setStatus("폴더는 OS에서 바로 드롭할 수 없습니다. 파일을 선택해 드롭하세요", true); + return; + } + const dest = destDir != null ? destDir : state.localPath || ""; + const fd = new FormData(); + fd.append("dest_dir", dest); + files.forEach((f) => fd.append("file", f, f.name)); + setStatus(`${files.length}개 파일 → 이 PC ${displayPath(dest)} 저장…`); + try { + const res = await fetch("/api/local/write", { method: "POST", body: fd }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "저장 실패"); + setStatus(`${data.count}개 저장 완료 → ${displayPath(data.dest_dir)}`); + await loadLocal(state.localPath); + } catch (e) { + setStatus(e.message, true); + } +} + +async function sendLocalSelected(destDir) { + if (!state.connected) { + setStatus("먼저 연결 버튼을 누르세요", true); + return; + } + const paths = [...state.localSelected]; + if (!paths.length) { + setStatus("로컬 파일/폴더를 선택하세요", true); + return; + } + const dest = remoteUploadDest(destDir); + setStatus(`${paths.length}개 → 상대 ${displayPath(dest)} 전송…`); + await watchProgress(async () => { + const data = await api("/api/local/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dest_dir: dest, paths }), + }); + setStatus(`${data.count}개 전송 완료 → ${displayPath(data.dest_dir)}`); + await loadDir(state.currentPath); + }).catch((e) => setStatus(e.message, true)); +} + +async function fetchRemoteSelected(destDir) { + if (!state.connected) { + setStatus("먼저 연결 버튼을 누르세요", true); + return; + } + const paths = [...state.remoteSelected]; + if (!paths.length) { + setStatus("상대 파일/폴더를 선택하세요", true); + return; + } + const dest = destDir != null ? destDir : state.localPath || ""; + setStatus(`${paths.length}개 → 이 PC ${displayPath(dest)} 가져오는 중…`); + await watchProgress(async () => { + const data = await api("/api/remote/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dest_dir: dest, paths }), + }); + setStatus(`${data.count}개 가져오기 완료 → ${displayPath(data.dest_dir)}`); + await loadLocal(state.localPath); + }).catch((e) => setStatus(e.message, true)); +} + +function dropDestFromEvent(e, fallbackPath) { + const row = e.target.closest?.("tr[data-drop-path]"); + if (row?.dataset?.dropPath) return row.dataset.dropPath; + return fallbackPath || ""; +} + +function clearDropHighlights() { + document.querySelectorAll(".finder.drop-target, tr.drop-target").forEach((el) => { + el.classList.remove("drop-target"); + }); +} + +function bindPaneDrop(pane, side) { + const prevent = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const onDragOver = (e) => { + prevent(e); + e.dataTransfer.dropEffect = "copy"; + pane.classList.add("drop-target"); + const row = e.target.closest?.("tr[data-drop-path]"); + document.querySelectorAll("tr.drop-target").forEach((r) => r.classList.remove("drop-target")); + if (row) row.classList.add("drop-target"); + }; + + const onDragLeave = (e) => { + if (!pane.contains(e.relatedTarget)) { + pane.classList.remove("drop-target"); + pane.querySelectorAll("tr.drop-target").forEach((r) => r.classList.remove("drop-target")); + } + }; + + const onDrop = async (e) => { + prevent(e); + clearDropHighlights(); + const dest = dropDestFromEvent( + e, + side === "remote" ? remoteUploadDest() : state.localPath || "" + ); + + const localPayload = e.dataTransfer.getData("application/x-fileshare-local"); + const remotePayload = e.dataTransfer.getData("application/x-fileshare-remote"); + const osFiles = e.dataTransfer?.files; + + try { + if (side === "remote") { + if (localPayload) { + state.localSelected = new Set(JSON.parse(localPayload)); + await sendLocalSelected(dest); + return; + } + if (remotePayload) { + setStatus("같은 상대 패널 안에서는 이동할 수 없습니다", true); + return; + } + if (osFiles?.length) { + await uploadFiles(osFiles, dest); + return; + } + } else { + // local pane + if (remotePayload) { + state.remoteSelected = new Set(JSON.parse(remotePayload)); + await fetchRemoteSelected(dest); + return; + } + if (localPayload) { + setStatus("같은 이 PC 패널 안에서는 이동할 수 없습니다", true); + return; + } + if (osFiles?.length) { + await writeLocalFiles(osFiles, dest); + return; + } + } + setStatus("드롭할 파일이 없습니다", true); + } catch (err) { + setStatus(err.message || String(err), true); + } + }; + + ["dragenter", "dragover", "dragleave", "drop"].forEach((ev) => { + pane.addEventListener(ev, prevent); + }); + pane.addEventListener("dragover", onDragOver); + pane.addEventListener("dragleave", onDragLeave); + pane.addEventListener("drop", onDrop); +} + +function bindDrop() { + bindPaneDrop(document.querySelector(".remote-browser"), "remote"); + bindPaneDrop(document.querySelector(".local-browser"), "local"); +} + +function bindSort(prefix, getSort, setSort, rerender) { + document.querySelectorAll(`[data-sort-${prefix}]`).forEach((btn) => { + btn.onclick = () => { + const key = btn.getAttribute(`data-sort-${prefix}`); + const cur = getSort(); + if (cur.key === key) setSort({ key, dir: cur.dir * -1 }); + else setSort({ key, dir: 1 }); + rerender(); + }; + }); +} + +function bindUi() { + $("connectBtn").onclick = () => connectOneClick(); + $("disconnectBtn").onclick = () => disconnect().catch((e) => setStatus(e.message, true)); + $("upBtn").onclick = () => goUp().catch((e) => setStatus(e.message, true)); + $("refreshBtn").onclick = () => { + if (!state.connected) { + setStatus("먼저 연결 버튼을 누르세요", true); + return; + } + loadDir(state.currentPath) + .then(() => setStatus("상대 폴더 새로고침")) + .catch((e) => setStatus(e.message, true)); + }; + $("mkdirBtn").onclick = () => mkdir(); + $("localUpBtn").onclick = () => localGoUp().catch((e) => setStatus(e.message, true)); + $("localRefreshBtn").onclick = () => + loadLocal(state.localPath) + .then(() => setStatus("이 PC 폴더 새로고침")) + .catch((e) => setStatus(e.message, true)); + + // FTP 스타일: 선택 → 상대/로컬 현재 폴더로 전송 + $("uploadBtn").onclick = () => sendLocalSelected(remoteUploadDest()); + $("downloadBtn").onclick = () => fetchRemoteSelected(state.localPath || ""); + $("inputShareBtn").onclick = async () => { + try { + const cur = await api("/api/inputshare"); + if (!cur.trusted) { + let prompted; + try { + prompted = await api("/api/inputshare/prompt-trust", { method: "POST" }); + } catch (e) { + prompted = await api("/api/inputshare/prompt-trust").catch(() => null); + if (!prompted) { + setStatus("권한 API를 찾을 수 없습니다. fileShare 앱을 재시작하세요", true); + return; + } + } + updateInputShareUI(prompted); + const lines = []; + if (prompted.message) lines.push(prompted.message); + if (Array.isArray(prompted.howto)) lines.push(...prompted.howto.map((x) => `• ${x}`)); + setStatus( + lines.join("\n") || + "시스템 창에서 「시스템 설정 열기」 → fileShare ON (앱이 권한을 스스로 켤 수는 없습니다)", + !prompted.trusted + ); + // 사용자가 설정에서 ON 한 뒤 감지 + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 1000)); + const again = await api("/api/inputshare").catch(() => null); + if (again && again.trusted) { + updateInputShareUI(again); + setStatus("손쉬운 사용 권한이 허용되었습니다. 앱을 재시작하면 마우스 전환이 됩니다."); + return; + } + } + return; + } + const next = !cur.enabled; + const info = await api("/api/inputshare/enable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: next }), + }); + updateInputShareUI(info); + setStatus( + next + ? "입력공유 켜짐 — [이 PC]/[상대 PC] 버튼 또는 Ctrl+Shift+U/←/→" + : "입력공유 꺼짐" + ); + } catch (e) { + setStatus(e.message, true); + } + }; + + if ($("controlLocalBtn")) { + $("controlLocalBtn").onclick = () => switchInputControl("local"); + } + if ($("controlRemoteBtn")) { + $("controlRemoteBtn").onclick = () => switchInputControl("remote"); + } + + bindSettingsUi(); + + // UI에서도 동일 단축키 (이 PC 제어 중일 때) + window.addEventListener("keydown", (ev) => { + if (!(ev.ctrlKey && ev.shiftKey)) return; + const localBtn = $("controlLocalBtn"); + if (!localBtn || localBtn.disabled) return; + if (ev.key === "u" || ev.key === "U") { + ev.preventDefault(); + const remote = $("controlRemoteBtn")?.classList.contains("active"); + switchInputControl(remote ? "local" : "remote"); + } else if (ev.key === "ArrowLeft") { + ev.preventDefault(); + switchInputControl("local"); + } else if (ev.key === "ArrowRight") { + ev.preventDefault(); + switchInputControl("remote"); + } + }); + + bindSort( + "local", + () => state.localSort, + (s) => { + state.localSort = s; + }, + () => renderLocal() + ); + bindSort( + "remote", + () => state.remoteSort, + (s) => { + state.remoteSort = s; + }, + () => renderRemote() + ); + bindDrop(); +} + +(async function init() { + bindUi(); + renderPathBar($("localPathBar"), "", (p) => loadLocal(p)); + renderPathBar($("pathBar"), "", () => {}); + try { + const s = await refreshStatus(); + await loadLocal(s.local_start || ""); + if (state.connected) { + await loadDir(state.remoteStartPath || ""); + setStatus("자동 연결됨 — .. 또는 ↑ 로 상대 PC 루트까지 이동할 수 있습니다"); + } else { + setStatus(s.auto_status || "JUC500 케이블을 연결한 뒤 「연결」을 누르세요"); + } + } catch (e) { + setStatus(e.message, true); + } + + // 백그라운드 자동 연결을 UI에 반영 + let wasConnected = state.connected; + setInterval(async () => { + if (state.connecting) return; + try { + const s = await refreshStatus(); + if (!wasConnected && state.connected) { + await loadDir(state.remoteStartPath || ""); + setStatus("자동 연결됨 — 상대 폴더를 불러왔습니다"); + } else if (!state.connected && s.auto_status) { + setStatus(s.auto_status); + } + wasConnected = state.connected; + } catch { + /* ignore */ + } + }, 1500); +})(); diff --git a/juc500_xfer/static/index.html b/juc500_xfer/static/index.html new file mode 100644 index 0000000..b928684 --- /dev/null +++ b/juc500_xfer/static/index.html @@ -0,0 +1,158 @@ + + + + + + JUC500 + + + +
+
+
+ JUC500 + Smart Data Link 파일 전송 +
+
+ 미연결 + 입력공유 미지원 + +
+ + +
+ + + +
+
+ + + +
+ 연결을 누르면 상대 PC 폴더가 표시됩니다 + +
+ +
+ + + + + +
+
+ + + diff --git a/juc500_xfer/static/styles.css b/juc500_xfer/static/styles.css new file mode 100644 index 0000000..6404b6b --- /dev/null +++ b/juc500_xfer/static/styles.css @@ -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; } +} diff --git a/juc500_xfer/usb_link.py b/juc500_xfer/usb_link.py new file mode 100644 index 0000000..732cf35 --- /dev/null +++ b/juc500_xfer/usb_link.py @@ -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() diff --git a/packaging/windows/README-WIN.txt b/packaging/windows/README-WIN.txt new file mode 100644 index 0000000..2877e2c --- /dev/null +++ b/packaging/windows/README-WIN.txt @@ -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 설정이 필요합니다.) diff --git a/packaging/windows/install.ps1 b/packaging/windows/install.ps1 new file mode 100644 index 0000000..5835123 --- /dev/null +++ b/packaging/windows/install.ps1 @@ -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 +} diff --git a/packaging/windows/juc500.iss b/packaging/windows/juc500.iss new file mode 100644 index 0000000..05e7db7 --- /dev/null +++ b/packaging/windows/juc500.iss @@ -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 diff --git a/packaging/windows/setup.bat b/packaging/windows/setup.bat new file mode 100644 index 0000000..f82b940 --- /dev/null +++ b/packaging/windows/setup.bat @@ -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 diff --git a/packaging/windows/zadig-hint.url b/packaging/windows/zadig-hint.url new file mode 100644 index 0000000..2d61b90 --- /dev/null +++ b/packaging/windows/zadig-hint.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://zadig.akeo.ie/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..031f56d --- /dev/null +++ b/pyproject.toml @@ -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/*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65e783e --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyusb>=1.2.1 diff --git a/scripts/setup_macos.sh b/scripts/setup_macos.sh new file mode 100755 index 0000000..589b2ab --- /dev/null +++ b/scripts/setup_macos.sh @@ -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" diff --git a/scripts/setup_windows.bat b/scripts/setup_windows.bat new file mode 100644 index 0000000..67bda90 --- /dev/null +++ b/scripts/setup_windows.bat @@ -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 diff --git a/vendor/README.md b/vendor/README.md new file mode 100644 index 0000000..3f6825f --- /dev/null +++ b/vendor/README.md @@ -0,0 +1,2 @@ +# Place libusb-1.0.dll here for Windows (x64). +# Download: https://github.com/libusb/libusb/releases diff --git a/vendor/libusb-1.0.dll b/vendor/libusb-1.0.dll new file mode 100644 index 0000000..137897c Binary files /dev/null and b/vendor/libusb-1.0.dll differ