From 8774a4843e5f6aaaab279ff28937b1ad340a8b8c Mon Sep 17 00:00:00 2001 From: joyharam <150105283@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:26:10 +0900 Subject: [PATCH] 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 --- .github/workflows/build-windows.yml | 94 ++ .gitignore | 16 + README.md | 130 +++ build-installer-windows.bat | 103 +++ build-installer-windows.sh | 80 ++ docs/ANALYSIS.md | 73 ++ docs/WINDOWS.md | 47 + juc500-windows-onefile.spec | 65 ++ juc500-windows.spec | 71 ++ juc500_app.py | 23 + juc500_xfer/__init__.py | 3 + juc500_xfer/__main__.py | 4 + juc500_xfer/cli.py | 162 ++++ juc500_xfer/gui.py | 5 + juc500_xfer/gui_server.py | 637 ++++++++++++++ juc500_xfer/protocol.py | 528 +++++++++++ juc500_xfer/static/app.js | 1254 +++++++++++++++++++++++++++ juc500_xfer/static/index.html | 158 ++++ juc500_xfer/static/styles.css | 744 ++++++++++++++++ juc500_xfer/usb_link.py | 279 ++++++ packaging/windows/README-WIN.txt | 15 + packaging/windows/install.ps1 | 63 ++ packaging/windows/juc500.iss | 52 ++ packaging/windows/setup.bat | 5 + packaging/windows/zadig-hint.url | 2 + pyproject.toml | 20 + requirements.txt | 1 + scripts/setup_macos.sh | 25 + scripts/setup_windows.bat | 16 + vendor/README.md | 2 + vendor/libusb-1.0.dll | Bin 0 -> 161280 bytes 31 files changed, 4677 insertions(+) create mode 100644 .github/workflows/build-windows.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build-installer-windows.bat create mode 100755 build-installer-windows.sh create mode 100644 docs/ANALYSIS.md create mode 100644 docs/WINDOWS.md create mode 100644 juc500-windows-onefile.spec create mode 100644 juc500-windows.spec create mode 100644 juc500_app.py create mode 100644 juc500_xfer/__init__.py create mode 100644 juc500_xfer/__main__.py create mode 100644 juc500_xfer/cli.py create mode 100644 juc500_xfer/gui.py create mode 100644 juc500_xfer/gui_server.py create mode 100644 juc500_xfer/protocol.py create mode 100644 juc500_xfer/static/app.js create mode 100644 juc500_xfer/static/index.html create mode 100644 juc500_xfer/static/styles.css create mode 100644 juc500_xfer/usb_link.py create mode 100644 packaging/windows/README-WIN.txt create mode 100644 packaging/windows/install.ps1 create mode 100644 packaging/windows/juc500.iss create mode 100644 packaging/windows/setup.bat create mode 100644 packaging/windows/zadig-hint.url create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100755 scripts/setup_macos.sh create mode 100644 scripts/setup_windows.bat create mode 100644 vendor/README.md create mode 100644 vendor/libusb-1.0.dll 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 0000000000000000000000000000000000000000..137897ca6cb833816bc2d1d7fac10a19ec591159 GIT binary patch literal 161280 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
Myj2c2b3MO^vG760Haxi+$VpQs3lqJPu7rNr$ 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
~dx@v6L!8jPVxU%5?AnQ* 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%0D9_Z?8#RXn= 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 y9WEcP9cFEFGJOF&)b1jD+(Nn?L`D&;I}>bK`6P literal 0 HcmV?d00001