Add Windows installer packaging (Setup.exe + portable exe)
PyInstaller onedir/onefile specs, Inno Setup script, and GitHub Actions workflow to produce JUC500-Setup-win64.exe and portable builds. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
name: Build Windows 64-bit
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "juc500_xfer/**"
|
||||
- "juc500_app.py"
|
||||
- "juc500-windows.spec"
|
||||
- "juc500-windows-onefile.spec"
|
||||
- "build-installer-windows.bat"
|
||||
- "build-installer-windows.sh"
|
||||
- "packaging/windows/**"
|
||||
- "vendor/libusb-1.0.dll"
|
||||
- "requirements.txt"
|
||||
- ".github/workflows/build-windows.yml"
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify libusb DLL
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "vendor\libusb-1.0.dll")) {
|
||||
throw "vendor/libusb-1.0.dll missing — commit the Windows x64 DLL"
|
||||
}
|
||||
|
||||
- name: Build with PyInstaller
|
||||
shell: cmd
|
||||
run: |
|
||||
python -m pip install -U pip pyinstaller pyusb
|
||||
python -m PyInstaller --noconfirm --clean juc500-windows.spec
|
||||
if not exist dist\JUC500\JUC500.exe exit /b 1
|
||||
if not exist dist\JUC500\_internal\static\index.html (
|
||||
if not exist dist\JUC500\_internal\juc500_xfer\static\index.html (
|
||||
echo ERROR: static index.html missing
|
||||
dir /s /b dist\JUC500\_internal 2>nul
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
python -m PyInstaller --noconfirm --clean juc500-windows-onefile.spec
|
||||
if not exist dist\JUC500-portable.exe exit /b 1
|
||||
copy /Y packaging\windows\install.ps1 dist\JUC500\install.ps1
|
||||
copy /Y packaging\windows\setup.bat dist\JUC500\setup.bat
|
||||
copy /Y packaging\windows\README-WIN.txt dist\JUC500\README-WIN.txt
|
||||
copy /Y packaging\windows\zadig-hint.url dist\JUC500\zadig-hint.url
|
||||
copy /Y vendor\libusb-1.0.dll dist\JUC500\libusb-1.0.dll
|
||||
|
||||
- name: Smoke help
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$p = Start-Process -FilePath "dist\JUC500\JUC500.exe" -ArgumentList "info" -PassThru -Wait -NoNewWindow `
|
||||
-RedirectStandardOutput "smoke-out.txt" -RedirectStandardError "smoke-err.txt"
|
||||
Write-Host "--- stdout ---"; Get-Content smoke-out.txt -ErrorAction SilentlyContinue
|
||||
Write-Host "--- stderr ---"; Get-Content smoke-err.txt -ErrorAction SilentlyContinue
|
||||
$err = ((Get-Content smoke-err.txt -Raw -ErrorAction SilentlyContinue) + (Get-Content smoke-out.txt -Raw -ErrorAction SilentlyContinue))
|
||||
if ($err -match "No module named") { throw "import failed: $err" }
|
||||
Write-Host "smoke ok exit=$($p.ExitCode)"
|
||||
|
||||
- name: Zip portable
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path dist-windows | Out-Null
|
||||
Compress-Archive -Path dist\JUC500\* -DestinationPath dist-windows\JUC500-win64-portable.zip -Force
|
||||
Copy-Item dist\JUC500-portable.exe dist-windows\JUC500-portable.exe -Force
|
||||
Copy-Item packaging\windows\README-WIN.txt dist-windows\README-WIN.txt -Force
|
||||
Copy-Item packaging\windows\install.ps1 dist-windows\install.ps1 -Force
|
||||
|
||||
- name: Install Inno Setup and build Setup.exe
|
||||
shell: pwsh
|
||||
run: |
|
||||
choco install innosetup -y --no-progress
|
||||
& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" packaging\windows\juc500.iss
|
||||
if (-not (Test-Path "dist-windows\JUC500-Setup-win64.exe")) {
|
||||
throw "Setup.exe was not created"
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: juc500-windows-x64
|
||||
path: |
|
||||
dist-windows/JUC500-Setup-win64.exe
|
||||
dist-windows/JUC500-win64-portable.zip
|
||||
dist-windows/JUC500-portable.exe
|
||||
if-no-files-found: error
|
||||
+16
@@ -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
|
||||
@@ -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) 를 참고하세요.
|
||||
@@ -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
|
||||
Executable
+80
@@ -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
|
||||
@@ -0,0 +1,73 @@
|
||||
# JUC500 분석 노트
|
||||
|
||||
분석일: 2026-07-15
|
||||
환경: Apple Silicon (T6000 USB XHCI), 케이블 연결 상태
|
||||
|
||||
## 하드웨어 식별
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| VID:PID | `0711:7500` |
|
||||
| 제품명 | Smart Data Link |
|
||||
| bcdUSB | 2.10 |
|
||||
| Device Class | `EF:02:01` (Miscellaneous + IAD) |
|
||||
| 시리얼 예 | `D264CCA25B431D2E` |
|
||||
| 미디어 | `/dev/disk4` → `/Volumes/WORMHOLE` (FAT12, ~16.7MB, read-only) |
|
||||
| Media Name | SD Card Reader |
|
||||
| 링크 속도 | USB 2.0 High Speed (480 Mb/s) — 허브 경유 시 SuperSpeed 미적용 가능 |
|
||||
|
||||
## 구성 인터페이스
|
||||
|
||||
1. **IF0** CDC Communication (`02:02:ff`) EP `0x81` INT
|
||||
2. **IF1** CDC Data (`0a:00:00`) EP `0x02` OUT / `0x83` IN BULK 512
|
||||
3. **IF2** Mass Storage BOT — WORMHOLE 가상 CD
|
||||
4. **IF3** HID (mouse proto) EP `0x87`
|
||||
5. **IF4** HID keyboard EP `0x86`
|
||||
6. **IF5** Vendor `ff:00:00`
|
||||
- `0x08` OUT / `0x89` IN
|
||||
- `0x0A` OUT / `0x8B` IN
|
||||
|
||||
Apple Silicon에서 **IF0/IF1/IF5** 는 libusb로 claim 가능. IF2–4는 커널 드라이버 점유(권한 없이 detach 불가).
|
||||
|
||||
## 번들 소프트웨어 (WORMHOLE 볼륨)
|
||||
|
||||
- `Wormhole.exe` — PE32
|
||||
- `Wormhole.app` / shell: **i386 + ppc** (케이블에 들어 있는 2015 페이로드)
|
||||
- `Deployment/GSDD.zz` — 커스텀 압축(OT…) Windows 로더 페이로드
|
||||
|
||||
제조사 OEM: **KaiJet Technology / OTi**, 제품명 Wormhole / GO Bridge.
|
||||
|
||||
## 공식 다운로드 (2024-09, v1.0.1463.47)
|
||||
|
||||
- Mac: `U3Wormhole.app` + `OTiTransfer.framework` → **x86_64 only**
|
||||
- 여전히 `IOUSBDevice` / `IOUSBDeviceStruct320` / `Query kIOUSBDeviceInterfaceID500`
|
||||
- Windows: `JUC500_1463.47 Update FW for Windows.exe`
|
||||
|
||||
→ **arm64 네이티브 미제공**. Rosetta로 떠도 USB user client 경로가 Silicon에서 불안정할 수 있음.
|
||||
|
||||
## 소프트웨어 아키텍처 (공식)
|
||||
|
||||
```
|
||||
Wormhole UI
|
||||
└── GoBridgeDemon (daemon)
|
||||
├── OTiTransfer.framework — USB open, alive pipe, data pipe, bounding
|
||||
├── KMKeyMouse.dylib — HID KM 공유
|
||||
└── KMClipboard.dylib — 클립보드
|
||||
```
|
||||
|
||||
주요 API: `OTi_Open`, `OTi_SendData`, `OTi_ReadData`, `OTi_CheckBounding`, `OTi_SendHIDPacket`
|
||||
알림: `OTi_NotifyDevicePlugin`, `OTi_NotifyRemoteAppActive`, …
|
||||
상위 메시지: XML 기반 **UPipe** (`<OTIMSG>`, `NP_Cmd_*`, 파일/클립보드/KM)
|
||||
|
||||
데이터 경로: USB bulk 위 alive/data 파이프 분리 + XML 패킷 (`isXMLPacket`, `Will send an XML packet`).
|
||||
|
||||
## Silicon 미동작 원인 (종합)
|
||||
|
||||
1. **바이너리 아키텍처** — arm64 없음 (최신도 x86_64)
|
||||
2. **레거시 IOKit USB API** — IOUSBDevice 경로
|
||||
3. **커널/유저 권한·드라이버 클레임** 차이
|
||||
4. j5create M1 지원 목록에 **JUC500 없음** (디스플레이/이더넷 어댑터 위주)
|
||||
|
||||
## 본 프로젝트 전략
|
||||
|
||||
공식 OTi/UPipe를 재구현하지 않고, **IF5 bulk 브릿지** 위에 자체 `J5FX` 프레임 프로토콜을 올려 Mac↔Win 양쪽에서 동일 스택으로 통신한다. Windows는 WinUSB(Zadig)로 IF5/IF1을 유저랜드에 노출해야 한다.
|
||||
@@ -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` 하면 전송이 시작됩니다.
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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())
|
||||
@@ -0,0 +1,3 @@
|
||||
"""JUC500 cross-platform file transfer for Apple Silicon Mac ↔ Windows 11."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,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())
|
||||
@@ -0,0 +1,5 @@
|
||||
"""호환용 — 웹 GUI로 위임."""
|
||||
|
||||
from .gui_server import run_gui
|
||||
|
||||
__all__ = ["run_gui"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,528 @@
|
||||
"""Framed packet protocol over the JUC500 vendor bulk bridge.
|
||||
|
||||
Frame layout (little-endian):
|
||||
magic : 4 bytes b"J5FX"
|
||||
type : u8
|
||||
flags : u8
|
||||
seq : u32
|
||||
length : u32
|
||||
payload : length bytes
|
||||
crc32 : u32 zlib.crc32(payload)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zlib
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from .usb_link import Juc500Link, MAX_PACKET
|
||||
|
||||
MAGIC = b"J5FX"
|
||||
HEADER_FMT = "<4sBBII"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FMT)
|
||||
CRC_SIZE = 4
|
||||
MAX_PAYLOAD = 16 * 1024 * 1024
|
||||
CHUNK_PAYLOAD = 60 * 1024
|
||||
|
||||
|
||||
class MsgType(IntEnum):
|
||||
HELLO = 1
|
||||
HELLO_ACK = 2
|
||||
PING = 3
|
||||
PONG = 4
|
||||
FILE_OFFER = 10
|
||||
FILE_ACCEPT = 11
|
||||
FILE_REJECT = 12
|
||||
FILE_CHUNK = 13
|
||||
FILE_DONE = 14
|
||||
FILE_ACK = 15
|
||||
RPC_REQ = 30
|
||||
RPC_RESP = 31
|
||||
ERROR = 255
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
type: MsgType
|
||||
flags: int
|
||||
seq: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
def pack_frame(msg_type: MsgType, payload: bytes = b"", seq: int = 0, flags: int = 0) -> bytes:
|
||||
crc = zlib.crc32(payload) & 0xFFFFFFFF
|
||||
header = struct.pack(HEADER_FMT, MAGIC, int(msg_type), flags, seq & 0xFFFFFFFF, len(payload))
|
||||
return header + payload + struct.pack("<I", crc)
|
||||
|
||||
|
||||
class FrameReader:
|
||||
def __init__(self) -> None:
|
||||
self._buf = bytearray()
|
||||
|
||||
def feed(self, data: bytes) -> list[Frame]:
|
||||
self._buf.extend(data)
|
||||
frames: list[Frame] = []
|
||||
while True:
|
||||
if len(self._buf) < HEADER_SIZE + CRC_SIZE:
|
||||
break
|
||||
idx = self._buf.find(MAGIC)
|
||||
if idx < 0:
|
||||
self._buf = self._buf[-3:]
|
||||
break
|
||||
if idx > 0:
|
||||
del self._buf[:idx]
|
||||
if len(self._buf) < HEADER_SIZE + CRC_SIZE:
|
||||
break
|
||||
magic, typ, flags, seq, length = struct.unpack_from(HEADER_FMT, self._buf, 0)
|
||||
if magic != MAGIC or length > MAX_PAYLOAD:
|
||||
del self._buf[0:1]
|
||||
continue
|
||||
total = HEADER_SIZE + length + CRC_SIZE
|
||||
if len(self._buf) < total:
|
||||
break
|
||||
payload = bytes(self._buf[HEADER_SIZE : HEADER_SIZE + length])
|
||||
(crc,) = struct.unpack_from("<I", self._buf, HEADER_SIZE + length)
|
||||
del self._buf[:total]
|
||||
if (zlib.crc32(payload) & 0xFFFFFFFF) != crc:
|
||||
continue
|
||||
try:
|
||||
frames.append(Frame(MsgType(typ), flags, seq, payload))
|
||||
except ValueError:
|
||||
continue
|
||||
return frames
|
||||
|
||||
|
||||
def _default_peer_name() -> str:
|
||||
host = os.uname().nodename if hasattr(os, "uname") else "host"
|
||||
try:
|
||||
user = os.getlogin()
|
||||
except OSError:
|
||||
user = os.environ.get("USER") or os.environ.get("USERNAME") or "user"
|
||||
return f"{host}-{user}"
|
||||
|
||||
|
||||
class Session:
|
||||
"""Bidirectional session: HELLO + file frames + JSON RPC."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
link: Juc500Link,
|
||||
peer_name: Optional[str] = None,
|
||||
on_log: Optional[Callable[[str], None]] = None,
|
||||
local_root: Optional[Path] = None,
|
||||
):
|
||||
self.link = link
|
||||
self.peer_name = peer_name or _default_peer_name()
|
||||
self.on_log = on_log or (lambda m: None)
|
||||
self.local_root = (local_root or Path.home()).resolve()
|
||||
self._reader = FrameReader()
|
||||
self._seq = 0
|
||||
self._lock = threading.Lock()
|
||||
self._xfer_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._rx_thread: Optional[threading.Thread] = None
|
||||
self._alive_thread: Optional[threading.Thread] = None
|
||||
self._handlers: dict[MsgType, Callable[[Frame], None]] = {}
|
||||
self._pending_accept = threading.Event()
|
||||
self._accept_result: Optional[bool] = None
|
||||
self._peer_hello: Optional[str] = None
|
||||
self._file_ack = threading.Event()
|
||||
self._connected = threading.Event()
|
||||
self._rpc_waiters: dict[str, Future] = {}
|
||||
self._rpc_lock = threading.Lock()
|
||||
# inbound file receive staging (when peer pushes)
|
||||
self._inbound: dict[str, Any] = {}
|
||||
self.inbound_dest = self.local_root
|
||||
self.on_progress: Optional[Callable[[int, int, str], None]] = None
|
||||
|
||||
def log(self, msg: str) -> None:
|
||||
self.on_log(msg)
|
||||
|
||||
def start(self) -> None:
|
||||
self._stop.clear()
|
||||
self._handlers[MsgType.HELLO] = self._on_hello
|
||||
self._handlers[MsgType.HELLO_ACK] = self._on_hello_ack
|
||||
self._handlers[MsgType.PING] = self._on_ping
|
||||
self._handlers[MsgType.PONG] = lambda _f: None
|
||||
self._handlers[MsgType.FILE_ACCEPT] = self._on_file_accept
|
||||
self._handlers[MsgType.FILE_REJECT] = self._on_file_reject
|
||||
self._handlers[MsgType.FILE_ACK] = self._on_file_ack
|
||||
self._handlers[MsgType.FILE_OFFER] = self._on_inbound_offer
|
||||
self._handlers[MsgType.FILE_CHUNK] = self._on_inbound_chunk
|
||||
self._handlers[MsgType.FILE_DONE] = self._on_inbound_done
|
||||
self._handlers[MsgType.RPC_REQ] = self._on_rpc_req
|
||||
self._handlers[MsgType.RPC_RESP] = self._on_rpc_resp
|
||||
self._rx_thread = threading.Thread(target=self._rx_loop, name="juc500-rx", daemon=True)
|
||||
self._alive_thread = threading.Thread(target=self._alive_loop, name="juc500-alive", daemon=True)
|
||||
self._rx_thread.start()
|
||||
self._alive_thread.start()
|
||||
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._rx_thread:
|
||||
self._rx_thread.join(timeout=2)
|
||||
if self._alive_thread:
|
||||
self._alive_thread.join(timeout=2)
|
||||
with self._rpc_lock:
|
||||
for fut in self._rpc_waiters.values():
|
||||
if not fut.done():
|
||||
fut.set_exception(RuntimeError("session stopped"))
|
||||
self._rpc_waiters.clear()
|
||||
|
||||
def wait_peer(self, timeout: float = 30.0) -> str:
|
||||
deadline = time.monotonic() + timeout
|
||||
while not self._connected.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
"상대가 JUC500 전송 프로그램으로 연결되지 않았습니다. "
|
||||
"양쪽에서 GUI/앱을 실행 중인지 확인하세요."
|
||||
)
|
||||
try:
|
||||
self.send(MsgType.HELLO, self.peer_name.encode("utf-8"))
|
||||
except Exception as exc:
|
||||
self.log(f"HELLO 재전송 실패: {exc}")
|
||||
self._connected.wait(timeout=min(2.0, remaining))
|
||||
return self._peer_hello or "peer"
|
||||
|
||||
def send(self, msg_type: MsgType, payload: bytes = b"", flags: int = 0) -> int:
|
||||
with self._lock:
|
||||
self._seq = (self._seq + 1) & 0xFFFFFFFF
|
||||
seq = self._seq
|
||||
frame = pack_frame(msg_type, payload, seq=seq, flags=flags)
|
||||
return self.link.write_data(frame)
|
||||
|
||||
def rpc(self, op: str, timeout: float = 30.0, **kwargs: Any) -> Any:
|
||||
req_id = uuid.uuid4().hex
|
||||
body = {"id": req_id, "op": op, **kwargs}
|
||||
fut: Future = Future()
|
||||
with self._rpc_lock:
|
||||
self._rpc_waiters[req_id] = fut
|
||||
self.send(MsgType.RPC_REQ, json.dumps(body, ensure_ascii=False).encode("utf-8"))
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
finally:
|
||||
with self._rpc_lock:
|
||||
self._rpc_waiters.pop(req_id, None)
|
||||
|
||||
def _rx_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
chunk = self.link.read_data(MAX_PACKET, timeout_ms=200)
|
||||
if not chunk:
|
||||
continue
|
||||
for frame in self._reader.feed(chunk):
|
||||
handler = self._handlers.get(frame.type)
|
||||
if handler:
|
||||
try:
|
||||
handler(frame)
|
||||
except Exception as exc:
|
||||
self.log(f"핸들러 오류 ({frame.type.name}): {exc}")
|
||||
|
||||
def _alive_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.link.write_ctrl(b"\x00ALIVE" + struct.pack("<d", time.time()), timeout_ms=500)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.link.read_ctrl(MAX_PACKET, timeout_ms=50)
|
||||
except Exception:
|
||||
pass
|
||||
self._stop.wait(1.0)
|
||||
if self._connected.is_set():
|
||||
try:
|
||||
self.send(MsgType.PING, struct.pack("<d", time.time()))
|
||||
except Exception:
|
||||
pass
|
||||
self._stop.wait(2.0)
|
||||
|
||||
def _on_hello(self, frame: Frame) -> None:
|
||||
name = frame.payload.decode("utf-8", errors="replace")
|
||||
self._peer_hello = name
|
||||
self.log(f"상대 HELLO: {name}")
|
||||
self.send(MsgType.HELLO_ACK, self.peer_name.encode("utf-8"))
|
||||
self._connected.set()
|
||||
|
||||
def _on_hello_ack(self, frame: Frame) -> None:
|
||||
name = frame.payload.decode("utf-8", errors="replace")
|
||||
self._peer_hello = name
|
||||
self.log(f"상대 HELLO_ACK: {name}")
|
||||
self._connected.set()
|
||||
|
||||
def _on_ping(self, frame: Frame) -> None:
|
||||
self.send(MsgType.PONG, frame.payload)
|
||||
|
||||
def _on_file_accept(self, _frame: Frame) -> None:
|
||||
self._accept_result = True
|
||||
self._pending_accept.set()
|
||||
|
||||
def _on_file_reject(self, _frame: Frame) -> None:
|
||||
self._accept_result = False
|
||||
self._pending_accept.set()
|
||||
|
||||
def _on_file_ack(self, _frame: Frame) -> None:
|
||||
self._file_ack.set()
|
||||
|
||||
def _safe_under_root(self, rel: str) -> Path:
|
||||
rel = (rel or "").replace("\\", "/").lstrip("/")
|
||||
target = (self.local_root / rel).resolve()
|
||||
try:
|
||||
target.relative_to(self.local_root)
|
||||
except ValueError as exc:
|
||||
raise PermissionError(f"경로가 루트를 벗어남: {rel}") from exc
|
||||
return target
|
||||
|
||||
def list_local(self, rel: str = "") -> dict[str, Any]:
|
||||
path = self._safe_under_root(rel)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(rel)
|
||||
if not path.is_dir():
|
||||
raise NotADirectoryError(rel)
|
||||
entries = []
|
||||
for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
try:
|
||||
st = child.stat()
|
||||
entries.append(
|
||||
{
|
||||
"name": child.name,
|
||||
"is_dir": child.is_dir(),
|
||||
"size": 0 if child.is_dir() else int(st.st_size),
|
||||
"mtime": int(st.st_mtime),
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
return {"path": rel.replace("\\", "/").strip("/"), "entries": entries}
|
||||
|
||||
def mkdir_local(self, rel: str) -> dict[str, Any]:
|
||||
path = self._safe_under_root(rel)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return {"path": rel.replace("\\", "/").strip("/")}
|
||||
|
||||
def _on_rpc_req(self, frame: Frame) -> None:
|
||||
try:
|
||||
req = json.loads(frame.payload.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
self._rpc_reply({"id": "", "ok": False, "error": f"bad json: {exc}"})
|
||||
return
|
||||
req_id = req.get("id", "")
|
||||
op = req.get("op")
|
||||
try:
|
||||
if op == "listdir":
|
||||
result = self.list_local(req.get("path") or "")
|
||||
elif op == "mkdir":
|
||||
result = self.mkdir_local(req.get("path") or "")
|
||||
elif op == "ping":
|
||||
result = {"pong": True, "name": self.peer_name}
|
||||
elif op == "pull":
|
||||
# Peer asks us to push a file/folder to them.
|
||||
rel = req.get("path") or ""
|
||||
# Respond OK first; actual bytes go via FILE_OFFER stream in a worker.
|
||||
self._rpc_reply({"id": req_id, "ok": True, "result": {"queued": True, "path": rel}})
|
||||
threading.Thread(
|
||||
target=self._push_path_for_pull,
|
||||
args=(rel,),
|
||||
daemon=True,
|
||||
name="juc500-pull-push",
|
||||
).start()
|
||||
return
|
||||
else:
|
||||
raise ValueError(f"unknown op: {op}")
|
||||
self._rpc_reply({"id": req_id, "ok": True, "result": result})
|
||||
except Exception as exc:
|
||||
self._rpc_reply({"id": req_id, "ok": False, "error": str(exc)})
|
||||
|
||||
def _rpc_reply(self, body: dict[str, Any]) -> None:
|
||||
self.send(MsgType.RPC_RESP, json.dumps(body, ensure_ascii=False).encode("utf-8"))
|
||||
|
||||
def _on_rpc_resp(self, frame: Frame) -> None:
|
||||
try:
|
||||
body = json.loads(frame.payload.decode("utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
req_id = body.get("id")
|
||||
with self._rpc_lock:
|
||||
fut = self._rpc_waiters.get(req_id or "")
|
||||
if not fut or fut.done():
|
||||
return
|
||||
if body.get("ok"):
|
||||
fut.set_result(body.get("result"))
|
||||
else:
|
||||
fut.set_exception(RuntimeError(body.get("error") or "RPC failed"))
|
||||
|
||||
def _push_path_for_pull(self, rel: str) -> None:
|
||||
try:
|
||||
path = self._safe_under_root(rel)
|
||||
if path.is_file():
|
||||
self.send_file(path, remote_name=Path(rel).name)
|
||||
elif path.is_dir():
|
||||
for file_path in path.rglob("*"):
|
||||
if file_path.is_file():
|
||||
remote_name = str(file_path.relative_to(self.local_root)).replace("\\", "/")
|
||||
# When pulled, receiver picks dest; send basename under relative tree
|
||||
rel_under = str(file_path.relative_to(path)).replace("\\", "/")
|
||||
self.send_file(file_path, remote_name=rel_under)
|
||||
else:
|
||||
self.log(f"pull 대상 없음: {rel}")
|
||||
except Exception as exc:
|
||||
self.log(f"pull push 실패: {exc}")
|
||||
|
||||
# --- outbound send ---
|
||||
|
||||
def send_file(
|
||||
self,
|
||||
path: Path,
|
||||
remote_name: Optional[str] = None,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
accept_timeout: float = 120.0,
|
||||
) -> None:
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(path)
|
||||
with self._xfer_lock:
|
||||
self._send_file_locked(path, remote_name or path.name, progress, accept_timeout)
|
||||
|
||||
def _send_file_locked(
|
||||
self,
|
||||
path: Path,
|
||||
name: str,
|
||||
progress: Optional[Callable[[int, int], None]],
|
||||
accept_timeout: float,
|
||||
) -> None:
|
||||
size = path.stat().st_size
|
||||
sha = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
b = f.read(1024 * 1024)
|
||||
if not b:
|
||||
break
|
||||
sha.update(b)
|
||||
digest = sha.hexdigest()
|
||||
meta = f"{name}\n{size}\n{digest}".encode("utf-8")
|
||||
self._pending_accept.clear()
|
||||
self._accept_result = None
|
||||
self.log(f"파일 제안: {name} ({size} bytes)")
|
||||
self.send(MsgType.FILE_OFFER, meta)
|
||||
if not self._pending_accept.wait(accept_timeout):
|
||||
raise TimeoutError("상대가 파일 수신을 수락하지 않았습니다.")
|
||||
if not self._accept_result:
|
||||
raise RuntimeError("상대가 파일 수신을 거부했습니다.")
|
||||
|
||||
sent = 0
|
||||
with path.open("rb") as f:
|
||||
while True:
|
||||
chunk = f.read(CHUNK_PAYLOAD)
|
||||
if not chunk:
|
||||
break
|
||||
self._file_ack.clear()
|
||||
self.send(MsgType.FILE_CHUNK, chunk)
|
||||
if not self._file_ack.wait(60.0):
|
||||
raise TimeoutError("청크 ACK 타임아웃")
|
||||
sent += len(chunk)
|
||||
if progress:
|
||||
progress(sent, size)
|
||||
if self.on_progress:
|
||||
self.on_progress(sent, size, name)
|
||||
self.send(MsgType.FILE_DONE, digest.encode("ascii"))
|
||||
self.log(f"전송 완료: {name}")
|
||||
|
||||
# --- inbound receive (auto-accept into inbound_dest) ---
|
||||
|
||||
def _on_inbound_offer(self, frame: Frame) -> None:
|
||||
# If we're mid outbound wait for ACCEPT, this is our counterpart — ignore auto path
|
||||
# Actually inbound offers always handled here; outbound waits on _pending_accept which is set by ACCEPT from peer.
|
||||
try:
|
||||
name, size_s, digest = frame.payload.decode("utf-8").split("\n", 2)
|
||||
size = int(size_s)
|
||||
except Exception:
|
||||
self.send(MsgType.FILE_REJECT, b"bad offer")
|
||||
return
|
||||
# sanitize name: no absolute, no ..
|
||||
name = name.replace("\\", "/").lstrip("/")
|
||||
parts = [p for p in name.split("/") if p and p != ".."]
|
||||
safe = "/".join(parts)
|
||||
if not safe:
|
||||
self.send(MsgType.FILE_REJECT, b"bad name")
|
||||
return
|
||||
out = (Path(self.inbound_dest) / safe).resolve()
|
||||
try:
|
||||
out.relative_to(Path(self.inbound_dest).resolve())
|
||||
except ValueError:
|
||||
self.send(MsgType.FILE_REJECT, b"path escape")
|
||||
return
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._inbound = {
|
||||
"path": out,
|
||||
"name": safe,
|
||||
"size": size,
|
||||
"digest": digest.strip(),
|
||||
"fh": out.open("wb"),
|
||||
"sha": hashlib.sha256(),
|
||||
"received": 0,
|
||||
}
|
||||
self.send(MsgType.FILE_ACCEPT, b"ok")
|
||||
self.log(f"수신 시작: {safe} ({size} bytes)")
|
||||
|
||||
def _on_inbound_chunk(self, frame: Frame) -> None:
|
||||
st = self._inbound
|
||||
fh = st.get("fh")
|
||||
if not fh:
|
||||
return
|
||||
fh.write(frame.payload)
|
||||
st["sha"].update(frame.payload)
|
||||
st["received"] = st.get("received", 0) + len(frame.payload)
|
||||
self.send(MsgType.FILE_ACK, b"")
|
||||
if self.on_progress:
|
||||
self.on_progress(st["received"], st["size"], st.get("name") or "")
|
||||
|
||||
def _on_inbound_done(self, frame: Frame) -> None:
|
||||
st = self._inbound
|
||||
fh = st.pop("fh", None)
|
||||
if fh:
|
||||
fh.close()
|
||||
digest = st.get("sha").hexdigest() if st.get("sha") else ""
|
||||
expected = st.get("digest", "")
|
||||
remote = frame.payload.decode("ascii", errors="replace")
|
||||
if digest != expected or (remote and remote != digest):
|
||||
self.log(f"해시 불일치: {st.get('name')}")
|
||||
else:
|
||||
self.log(f"수신 완료: {st.get('path')}")
|
||||
self._inbound = {}
|
||||
|
||||
# compatibility helpers used by CLI
|
||||
def receive_file(
|
||||
self,
|
||||
dest_dir: Path,
|
||||
allow: Optional[Callable[[str, int], bool]] = None,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
timeout: float = 600.0,
|
||||
) -> Path:
|
||||
"""Wait until an inbound transfer completes into dest_dir."""
|
||||
self.inbound_dest = Path(dest_dir)
|
||||
self.inbound_dest.mkdir(parents=True, exist_ok=True)
|
||||
deadline = time.monotonic() + timeout
|
||||
last_path = None
|
||||
while time.monotonic() < deadline:
|
||||
if self._inbound.get("fh") is None and last_path and not self._inbound:
|
||||
# completed
|
||||
if progress and last_path:
|
||||
pass
|
||||
return Path(last_path)
|
||||
if self._inbound.get("path"):
|
||||
last_path = self._inbound["path"]
|
||||
if progress and self._inbound.get("size"):
|
||||
progress(self._inbound.get("received", 0), self._inbound["size"])
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError("파일 수신 타임아웃")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>JUC500</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">JUC500</span>
|
||||
<span class="brand-sub" id="thisHost">Smart Data Link 파일 전송</span>
|
||||
</div>
|
||||
<div class="conn">
|
||||
<span id="autoBadge" class="auto-badge wait">미연결</span>
|
||||
<span id="inputBadge" class="auto-badge wait" title="마우스·키보드 공유">입력공유 미지원</span>
|
||||
<button id="inputShareBtn" type="button" class="ghost" disabled>입력공유</button>
|
||||
<div class="control-switch" title="입력공유 미지원">
|
||||
<button id="controlLocalBtn" type="button" class="ghost control-btn" disabled>이 PC</button>
|
||||
<button id="controlRemoteBtn" type="button" class="ghost control-btn" disabled>상대 PC</button>
|
||||
</div>
|
||||
<button id="connectBtn" type="button" class="primary">연결</button>
|
||||
<button id="disconnectBtn" type="button" class="ghost" disabled>끊기</button>
|
||||
<button id="settingsBtn" type="button" class="ghost settings-btn" title="설정" aria-label="설정">
|
||||
<svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true">
|
||||
<path fill="currentColor" d="M10 6.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zm7.1 2.4-.9-.2a5.9 5.9 0 0 0-.5-1.2l.5-.8a.8.8 0 0 0-.1-.9l-1.4-1.4a.8.8 0 0 0-.9-.1l-.8.5c-.4-.2-.8-.4-1.2-.5l-.2-.9a.8.8 0 0 0-.8-.6H8.2a.8.8 0 0 0-.8.6l-.2.9c-.4.1-.8.3-1.2.5l-.8-.5a.8.8 0 0 0-.9.1L2.9 5.8a.8.8 0 0 0-.1.9l.5.8c-.2.4-.4.8-.5 1.2l-.9.2a.8.8 0 0 0-.6.8v2a.8.8 0 0 0 .6.8l.9.2c.1.4.3.8.5 1.2l-.5.8a.8.8 0 0 0 .1.9l1.4 1.4a.8.8 0 0 0 .9.1l.8-.5c.4.2.8.4 1.2.5l.2.9a.8.8 0 0 0 .8.6h2a.8.8 0 0 0 .8-.6l.2-.9c.4-.1.8-.3 1.2-.5l.8.5a.8.8 0 0 0 .9-.1l1.4-1.4a.8.8 0 0 0 .1-.9l-.5-.8c.2-.4.4-.8.5-1.2l.9-.2a.8.8 0 0 0 .6-.8v-2a.8.8 0 0 0-.6-.8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="settingsModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
|
||||
<div class="modal-backdrop" data-close-settings></div>
|
||||
<div class="modal-panel">
|
||||
<div class="modal-head">
|
||||
<h2 id="settingsTitle">설정</h2>
|
||||
<button type="button" class="ghost modal-close" data-close-settings aria-label="닫기">닫기</button>
|
||||
</div>
|
||||
<p class="modal-lead">JUC500 GUI 포트입니다. 변경 후 다음 실행 시 적용됩니다.</p>
|
||||
<div id="portsForm" class="ports-form"></div>
|
||||
<p id="portsConfigPath" class="ports-path muted"></p>
|
||||
<p id="settingsMsg" class="settings-msg" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="portsResetBtn" class="ghost">기본값</button>
|
||||
<button type="button" id="portsApplyBtn" class="primary">적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="peerBar" class="peer-bar">
|
||||
<span id="peerInfo">연결을 누르면 상대 PC 폴더가 표시됩니다</span>
|
||||
<span id="ifaceInfo"></span>
|
||||
</section>
|
||||
|
||||
<main class="main ftp-layout">
|
||||
<aside class="finder local-browser" aria-label="이 PC 파일">
|
||||
<div class="finder-titlebar">
|
||||
<span class="finder-title">이 PC</span>
|
||||
<span class="finder-subtitle" id="localRootHint">로컬 파일</span>
|
||||
</div>
|
||||
<div class="finder-toolbar">
|
||||
<div class="nav-group">
|
||||
<button id="localUpBtn" type="button" class="tool-btn" title="상위 폴더" disabled>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5 3 8.5h3.2V13h3.6V8.5H13L8 3.5z"/></svg>
|
||||
</button>
|
||||
<button id="localRefreshBtn" type="button" class="tool-btn" title="새로고침">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.4-3.6l.9.9V3.2h-2.1l1.1 1.1A4.2 4.2 0 1 0 12.2 8h1.3z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="localPathBar" class="address-bar" title="경로"></div>
|
||||
</div>
|
||||
<div class="finder-cols" aria-hidden="true">
|
||||
<button type="button" class="col-btn" data-sort-local="name">이름</button>
|
||||
<button type="button" class="col-btn" data-sort-local="mtime">수정한 날짜</button>
|
||||
<button type="button" class="col-btn" data-sort-local="size">크기</button>
|
||||
<button type="button" class="col-btn" data-sort-local="kind">종류</button>
|
||||
</div>
|
||||
<div class="finder-scroll">
|
||||
<table class="finder-table" aria-label="이 PC 목록">
|
||||
<tbody id="localDirList"></tbody>
|
||||
</table>
|
||||
<div id="localEmpty" class="finder-empty hidden">폴더가 비어 있습니다</div>
|
||||
</div>
|
||||
<div class="finder-status">
|
||||
<span id="localCount">0개 항목</span>
|
||||
<span id="localPathHint" class="path-hint">/</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="transfer-rail" aria-label="전송">
|
||||
<div class="rail-paths">
|
||||
<div class="rail-path">
|
||||
<span class="rail-label">이 PC</span>
|
||||
<strong id="railLocalPath">/</strong>
|
||||
</div>
|
||||
<div class="rail-path">
|
||||
<span class="rail-label">상대</span>
|
||||
<strong id="railRemotePath">/</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button id="uploadBtn" type="button" class="xfer-btn" disabled title="선택한 항목을 상대 현재 폴더로 전송">
|
||||
<span class="xfer-arrow">→</span>
|
||||
<span class="xfer-text">전송</span>
|
||||
</button>
|
||||
<button id="downloadBtn" type="button" class="xfer-btn" disabled title="선택한 항목을 이 PC 현재 폴더로 가져오기">
|
||||
<span class="xfer-arrow">←</span>
|
||||
<span class="xfer-text">가져오기</span>
|
||||
</button>
|
||||
<p class="rail-hint">선택 후 버튼<br/>또는 패널로 드래그</p>
|
||||
<div class="rail-progress">
|
||||
<div class="progress-wrap">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
<div id="statusText" class="status">연결 버튼을 누르세요</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="finder remote-browser" aria-label="상대 PC 폴더">
|
||||
<div class="finder-titlebar">
|
||||
<span class="finder-title">상대 PC</span>
|
||||
<span class="finder-subtitle" id="remoteRootHint">연결 필요</span>
|
||||
</div>
|
||||
<div class="finder-toolbar">
|
||||
<div class="nav-group">
|
||||
<button id="upBtn" type="button" class="tool-btn" title="상위 폴더" disabled>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5 3 8.5h3.2V13h3.6V8.5H13L8 3.5z"/></svg>
|
||||
</button>
|
||||
<button id="refreshBtn" type="button" class="tool-btn" title="새로고침">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 8a5.5 5.5 0 1 1-1.4-3.6l.9.9V3.2h-2.1l1.1 1.1A4.2 4.2 0 1 0 12.2 8h1.3z"/></svg>
|
||||
</button>
|
||||
<button id="mkdirBtn" type="button" class="tool-btn wide" title="새 폴더">새 폴더</button>
|
||||
</div>
|
||||
<div id="pathBar" class="address-bar" title="경로"></div>
|
||||
</div>
|
||||
<div class="finder-cols" aria-hidden="true">
|
||||
<button type="button" class="col-btn" data-sort-remote="name">이름</button>
|
||||
<button type="button" class="col-btn" data-sort-remote="mtime">수정한 날짜</button>
|
||||
<button type="button" class="col-btn" data-sort-remote="size">크기</button>
|
||||
<button type="button" class="col-btn" data-sort-remote="kind">종류</button>
|
||||
</div>
|
||||
<div class="finder-scroll">
|
||||
<table class="finder-table" aria-label="상대 PC 목록">
|
||||
<tbody id="dirList"></tbody>
|
||||
</table>
|
||||
<div id="remoteEmpty" class="finder-empty">연결 후 여기에 상대 폴더가 표시됩니다</div>
|
||||
</div>
|
||||
<div class="finder-status">
|
||||
<span id="remoteCount">연결되지 않음</span>
|
||||
<span id="remotePathHint" class="path-hint">/</span>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 설정이 필요합니다.)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
[InternetShortcut]
|
||||
URL=https://zadig.akeo.ie/
|
||||
@@ -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/*"]
|
||||
@@ -0,0 +1 @@
|
||||
pyusb>=1.2.1
|
||||
Executable
+25
@@ -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"
|
||||
@@ -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
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
# Place libusb-1.0.dll here for Windows (x64).
|
||||
# Download: https://github.com/libusb/libusb/releases
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user