diff --git a/.gitignore b/.gitignore index 915bf3e7..4b69c948 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ Package.StoreAssociation.xml **/fastlane/report.xml **/fastlane/screenshots **/fastlane/test_output + +# Visual Studio project upgrade artifacts +UpgradeLog*.htm +_UpgradeReport_Files/ +Backup*/ diff --git a/Documentation/WindowsDevSetup-VS2026.md b/Documentation/WindowsDevSetup-VS2026.md new file mode 100644 index 00000000..75f95de1 --- /dev/null +++ b/Documentation/WindowsDevSetup-VS2026.md @@ -0,0 +1,139 @@ +# Windows 개발 환경 세팅 (Visual Studio 2026) + +이 문서는 **Visual Studio 2026** 으로 Scratch Link 윈도우 버전을 빌드/디버깅하기 위한 환경 세팅 절차를 정리한 것이다. Visual Studio 2022 와는 워크로드 이름과 일부 컴포넌트 구성이 달라서 별도 가이드가 필요하다. + +## 0. 사전 정보 + +- 솔루션 파일 `scratch-link.sln` 은 VS 2022 (v17) 포맷이지만 VS 2026 에서 그대로 열 수 있다. **버전 변환 프롬프트가 떠도 변환하지 말 것** (sln 포맷이 바뀌어 PR 이 지저분해진다). +- 윈도우 버전 프로젝트: + - `scratch-link-win` — WinUI 3 기반 본체 EXE + - `scratch-link-win-msix` — `.wapproj` (Desktop Bridge) 형식의 MSIX 패키징 프로젝트 + - `scratch-link-common` — 공유 C# 코드 (`.shproj`) +- 맥용 `scratch-link-mac` 은 솔루션을 열면 "Unsupported" 로 표시되는데 **정상이다**. 윈도우 VS 에서는 어차피 빌드하지 않으므로 무시한다 (솔루션에서 제거하지 말 것 — `.sln` 이 수정되어 git diff 에 잡힌다). + +## 1. Visual Studio Installer 워크로드 + +VS Installer 를 열고 **수정(Modify)** 으로 다음 워크로드를 체크한다. + +### 워크로드 (Workloads 탭) + +- ☑ **.NET 데스크톱 개발** (.NET desktop development) +- ☑ **C++를 사용한 데스크톱 개발** (Desktop development with C++) +- ☑ **WinUI 애플리케이션 개발** (WinUI application development) + - VS 2022 의 "Windows 응용 프로그램 개발" 워크로드가 VS 2026 에서 이 이름으로 바뀌었다. + +### 각 워크로드의 선택 사항 + +워크로드를 체크한 뒤 우측 "설치 세부 정보" 패널에서 추가로 다음 항목을 켠다. + +**`.NET 데스크톱 개발` 의 선택 사항:** + +- ☑ **MSIX Packaging Tools** — `.wapproj` 빌드에 필수. VS 2026 에서는 개별 구성 요소 검색에 안 나오고 이 워크로드 안에 들어 있다. + +**`WinUI 애플리케이션 개발` 의 선택 사항:** + +- ☑ **Windows 11 SDK (10.0.22621.0)** — `scratch-link-win.csproj` 의 `TargetFramework=net6.0-windows10.0.22621.0` 가 요구하는 SDK. + +`유니버설 Windows 플랫폼 도구` 는 이 프로젝트에 필요 없다. + +## 2. .NET 6 SDK 별도 설치 + +VS 2026 인스톨러에는 **.NET 6 런타임만 포함되어 있고 SDK 는 빠져 있다** (.NET 6 은 2024-11 EOL). 프로젝트가 `net6.0-windows10.0.22621.0` 을 타겟팅하므로 SDK 를 따로 받아야 한다. + +1. 접속 +2. 표에서 **Windows 행 → 설치 관리자(Installer) 열 → `x64`** 클릭 + - `전체 (dotnet-install scripts)` 는 CI/스크립트용이므로 선택하지 말 것 + - `바이너리(Binaries)` 도 압축본이므로 일반 설치엔 부적합 +3. 다운받은 `dotnet-sdk-6.0.xxx-win-x64.exe` 실행 +4. 설치 후 **새 PowerShell** 을 열어 확인: + + ```powershell + dotnet --list-sdks + ``` + + `6.0.xxx [C:\Program Files\dotnet\sdk]` 가 보이면 OK. + +## 3. Windows App Runtime 1.3 설치 + +이 프로젝트는 `Microsoft.WindowsAppSDK 1.3.230331000` 을 framework-dependent 모드로 참조한다 (`SharedProps/WindowsSDK.props`, `scratch-link-win.csproj` 의 `false`). 따라서 **Windows App Runtime 1.3 이 시스템에 설치되어 있어야** 디버그 실행이 된다. + +설치 안 된 상태에서 F5 를 누르면 다음 다이얼로그가 뜬다: + +> This application requires the Windows App Runtime Version 1.3 (MSIX package version >= 3000.820.152.0). Do you want to install a compatible Windows App Runtime now? + +다이얼로그에서 **예(Y)** 를 누르면 Microsoft 사이트로 안내된다. 자동 안내가 실패할 경우 수동 설치: + +1. 접속 +2. **"Windows App SDK 1.3"** 섹션을 찾아 **`WindowsAppRuntimeInstall-x64.exe`** 다운로드 (ARM PC 라면 `-arm64.exe`) +3. 실행하여 설치 +4. 설치 확인: + + ```powershell + Get-AppxPackage -Name "Microsoft.WindowsAppRuntime.1.3*" + ``` + + `Microsoft.WindowsAppRuntime.1.3.x64` 가 보이면 OK. + +> 이 런타임은 최종 사용자 PC 에도 필요하지만, 배포용 `.msixbundle` 은 자동으로 프레임워크 설치를 트리거하므로 일반 사용자는 따로 깔 필요가 없다. 개발자만 수동 설치한다. + +## 4. 솔루션 열기 + +1. `scratch-link.sln` 더블클릭으로 VS 2026 에서 열기 +2. "Migration Report" 가 뜨면 **OK** 로 닫는다. `scratch-link-mac` 이 Unsupported 로 나오는 것은 정상. +3. 솔루션 탐색기에서 `scratch-link-mac` 은 회색으로 표시된다 — 그대로 둔다. + +## 5. 빌드/실행 설정 + +VS 상단 툴바에서: + +| 항목 | 값 | +|---|---| +| Solution Configurations | **`Debug_Win`** | +| Solution Platforms | **`x64`** (또는 본인 PC 에 맞는 플랫폼) | +| Startup Project | **`scratch-link-win`** ← 중요 | + +**시작 프로젝트는 `scratch-link-win` 이어야 한다.** `scratch-link-win-msix` 를 시작 프로젝트로 잡으면 F5 시 다음 에러가 난다: + +``` +MddBootstrapInitialize called in a process with package identity +0x80070032 지원되지 않는 요청입니다 +``` + +이유: csproj 가 `None` (언패키지 모드) 로 빌드되는데, wapproj 가 그 EXE 를 MSIX 로 배포하면 패키지 ID 를 갖게 되어 `MddBootstrap.Initialize()` 호출이 충돌한다. `README.md` 의 "Windows platforms and installer size" 섹션 참고. + +### 시작 프로젝트 설정 방법 + +솔루션 탐색기에서 **`scratch-link-win` 우클릭 → Set as Startup Project**. 프로젝트 이름이 굵게(bold) 변하면 적용된 것. + +## 6. 워크플로우 + +| 목적 | Startup Project | Configuration | 결과물 | +|---|---|---|---| +| **일상 개발/디버깅 (F5)** | `scratch-link-win` | `Debug_Win` / `x64` | 언패키지 EXE 직접 실행. 평소 작업은 이걸로. | +| **MSIX 패키지 동작 확인** | `scratch-link-win-msix` | `Release_Win` / `x64` | publish profile (`Properties/PublishProfiles/win10-x64.pubxml`) 이 `WindowsPackageType=Desktop` 으로 오버라이드하여 진짜 패키지 빌드. | +| **배포용 msixbundle 생성** | `scratch-link-win-msix` | `Release_Win`, 모든 플랫폼 | x86/x64/ARM64 번들 `.msixbundle` 생성. | + +## 7. MSIX 사이드로드 준비 (선택) + +MSIX Debug 빌드는 임시 자체 서명 인증서로 서명된다 (`scratch-link-win-msix.wapproj` 의 `GenerateTemporaryStoreCertificate=True`). 자체 서명 MSIX 를 신뢰하려면: + +- `설정 → 개인 정보 및 보안 → 개발자용` → **개발자 모드 켜기** (또는 최소한 "사이드로드 앱" 허용) + +## 8. 자주 막히는 곳 + +| 증상 | 원인 / 해결 | +|---|---| +| `NETSDK1045: The current .NET SDK does not support targeting .NET 6.0` | .NET 6 SDK 미설치. §2 참고. | +| `Microsoft.DesktopBridge.props was not found` | MSIX Packaging Tools 누락. §1 의 ".NET 데스크톱 개발" 선택 사항 확인. | +| `Windows 10 SDK version 10.0.22621.0 was not found` | Windows 11 SDK 22621 미설치. §1 의 "WinUI 애플리케이션 개발" 선택 사항 확인. | +| `scratch-link-win-msix` 가 보이지 않거나 회색 | 솔루션 Configuration 이 `*_Win` 이 아닌 `*_Mac` 으로 되어 있을 때 흔하다. | +| F5 시 `MddBootstrapInitialize ... 0x80070032` | 시작 프로젝트가 wapproj 로 설정됨. §5 참고. | +| F5 시 "This application requires the Windows App Runtime 1.3" | 런타임 미설치. §3 참고. | +| StyleCop 경고가 에러로 처리됨 | 원본 동작. `SharedProps/StyleCop.props` 참고. 거슬리면 임시로 `TreatWarningsAsErrors` 만 끄기. | + +## 9. 참고 문서 + +- [`README.md`](../README.md) 의 "Windows platforms and installer size" 섹션 — 패키지 형태와 배포 크기 트레이드오프 배경 설명 +- [`scratch-link-win/scratch-link-win.csproj`](../scratch-link-win/scratch-link-win.csproj) — 본체 프로젝트 설정 +- [`scratch-link-win-msix/scratch-link-win-msix.wapproj`](../scratch-link-win-msix/scratch-link-win-msix.wapproj) — MSIX 패키징 프로젝트 설정 +- [`SharedProps/WindowsSDK.props`](../SharedProps/WindowsSDK.props) — Windows App SDK 버전 핀 diff --git a/README.md b/README.md index d4355c91..3382b52e 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,100 @@ -# Scratch Link 2.0 +# Alux Scratch Link -Scratch Link is a helper application which allows Scratch 3.0 to communicate with hardware peripherals. Scratch Link -replaces the Scratch Device Manager and Scratch Device Plug-in. +Alux Scratch Link는 Scratch 3.0과 PC에 연결된 하드웨어 주변기기를 중계하는 도우미 앱입니다. +[scratchfoundation/scratch-link](https://github.com/scratchfoundation/scratch-link)의 **Windows 전용 포크**이며, +원본의 AGPL-3.0-only 라이선스를 그대로 따릅니다. -System Requirements: +원본 Scratch Link와의 차이: -| | Minimum -| --- | --- -| macOS | 10.15 "Catalina" -| Windows | Windows 10 build 17763 +- **Windows 전용** — macOS 빌드와 Safari 확장은 빼고 Windows 패키징에 집중합니다. +- **Serial 전송 추가** — 기존 BLE / Bluetooth Classic에 더해 USB 시리얼(CDC/CH340 등) 장치를 + `/scratch/serial` JSON-RPC 엔드포인트로 지원합니다. 구현은 `scratch-link-common/Serial/`과 + `scratch-link-win/Serial/`을 참고하세요. +- **포트 20211 사용** — 원본 Scratch Link(20110/20111)와 한 PC에서 공존할 수 있도록 별도 포트를 씁니다. -The Windows version requires the Windows App Runtime version 1.2, and will install it automatically if possible. +## 시스템 요구사항 -Manual installation is available here (choose your platform): +| | 최소 사양 | +| --- | --- | +| Windows | Windows 10 build 17763 | + +Windows App Runtime 1.2가 필요하며 가능한 경우 자동 설치됩니다. 수동 설치가 필요하면 아키텍처에 맞게 받으세요: * https://aka.ms/windowsappsdk/1.2/latest/windowsappruntimeinstall-x64.exe * https://aka.ms/windowsappsdk/1.2/latest/windowsappruntimeinstall-x86.exe * https://aka.ms/windowsappsdk/1.2/latest/windowsappruntimeinstall-ARM64.exe -## Using Scratch Link with Scratch 3.0 +## Scratch 3.0과 함께 쓰기 -To use Scratch Link with Scratch 3.0: +1. Alux Scratch Link 설치 후 실행 +2. [Scratch 3.0](https://scratch.mit.edu) 열기 +3. 블록 카테고리 아래쪽의 "확장 기능 추가" 버튼(블록 모양 + 아이콘) 선택 +4. micro:bit, LEGO EV3 같은 지원 확장 선택 +5. 안내에 따라 주변기기 연결 +6. 새 블록으로 프로젝트 작성. Alux Scratch Link가 Scratch와 하드웨어 사이의 통신을 중계합니다. -1. Install and run Scratch Link -2. Open [Scratch 3.0](https://scratch.mit.edu) -3. Select the "Add Extension" button (looks like Scratch blocks with a `+` at the bottom of the block categories list) -4. Select a compatible extension such as the micro:bit or LEGO EV3 extension. -5. Follow the prompts to connect your peripheral. -6. Build a project with the new extension blocks. Scratch Link will help Scratch communicate with your peripheral. +## 개발 -## Development: Getting started +### 문서 -### Documentation +전반적인 네트워크 프로토콜과 지원 하드웨어 프로토콜은 `Documentation/` 아래에 마크다운으로 정리되어 있습니다 +(Architecture, Bluetooth, BluetoothLE, NetworkProtocol, TestPlans). 프로토콜 호환성/안정성은 +중요한 우선순위이므로, 프로토콜을 바꾸는 PR은 충분한 정당화와 문서 갱신이 동반되어야 합니다. -The general network protocol and all supported hardware protocols are documented in Markdown files in the -`Documentation` subdirectory. Please note that network protocol stability and compatibility are high priorities for -this project. Changes to the protocol are unlikely to be accepted without very strong justification combined with -thorough documentation. +문서 PR을 보내기 전 [markdownlint](https://www.npmjs.com/package/markdownlint)로 점검해주세요. -Please use [markdownlint](https://www.npmjs.com/package/markdownlint) to check documentation changes before submitting -a pull request. +### 버전 번호 -### Version numbers +이 포크는 [SharedProps/ScratchVersion.targets](SharedProps/ScratchVersion.targets)에서 base 버전을 +`1.0.0`으로 고정해두고, 빌드 번호는 git commit 수에서 가져옵니다. 결과 4-part 버전은 +`1.0.0.` 형태로 EXE 파일 속성과 트레이 메뉴에 노출됩니다. -Scratch Link 2.0 uses [semantic-release](https://semantic-release.gitbook.io/semantic-release/) to control its version -number. The `develop` branch is treated as a pre-release branch, and `main` is treated as a release branch. Each time -a change is merged to either of those branches, `semantic-release` will calculate a new version number. +정식 릴리즈를 끊을 때는 `git tag v1.1.0`처럼 semver 태그를 찍으세요. GitInfo가 태그를 감지하면 +위의 1.0.0 고정 로직이 자동으로 비켜나 태그값을 따라갑니다. -Apple requires that `CFBundleShortVersionString` is unique for published releases. The App Store will also reject an -upload unless the `CFBundleVersion` tuple is greater than that of previously uploaded builds. To make this easy, we -set `CFBundleShortVersionString` to the version calculated by `semantic-release`, and `CFBundleVersion` is calculated -from the date and time of the build commit. +확장 버전 정보(`git describe`와 유사한 상세 문자열)는 트레이 메뉴의 버전 항목을 클릭해 +클립보드로 복사할 수 있습니다. -Extended version information is available within the application. This extended information is similar to `git -describe`. +### 브랜드 자산 -### Secure WebSockets +앱/트레이/MSIX에 쓰이는 모든 아이콘은 [brand/labs-l.svg](brand/labs-l.svg) 하나에서 파생됩니다. +SVG가 갱신되면 다음 명령으로 ICO/PNG를 재생성하세요: -Some previous versions of Scratch Link used Secure WebSockets (`wss://`) to communicate with Scratch. This is no -longer the case: new versions of Scratch Link use regular WebSockets (`ws://`). It is no longer necessary to prepare -an SSL certificate for Scratch Link. +``` +pip install Pillow # 최초 1회 +python brand/build_icons.py +``` -This change caused an incompatibility with some browsers, including Safari. The macOS version of Scratch Link 2.0 -includes a Safari extension to resolve this incompatibility. +생성물은 모두 커밋되어 있어 일반 빌드 시에는 이 스크립트를 돌릴 필요가 없습니다. -### Windows platforms and installer size +### Windows 패키징과 설치 파일 크기 -The `PublishReadyToRun` (R2R) setting enables ahead-of-time (AOT) compilation, as opposed to just-in-time (JIT) -compilation. This can improve performance, especially at startup. The drawback is [R2R binaries are larger because -they contain both intermediate language (IL) code, which is still needed for some scenarios, and the native version -of the same code.](https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run) +`PublishReadyToRun`(R2R) 설정은 ahead-of-time(AOT) 컴파일을 활성화합니다(반대는 JIT). 시작 시간 등 +성능에는 유리하지만, [R2R 바이너리는 IL 코드와 네이티브 코드를 모두 포함하기 때문에 +크기가 더 커집니다](https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run). -Recent versions of .NET (5.0 and above) can build a "Framework-Dependent Application" or a "Self-Contained -Application" depending on settings. +.NET 5.0 이상에서는 설정에 따라 "Framework-Dependent Application" 또는 "Self-Contained Application"으로 +빌드할 수 있습니다. -* A self-contained application includes the .NET runtime framework. This includes a platform-specific (x86, x64, or - ARM64) version of `dotnet.exe` to host the application. - * Cannot be built for "AnyCPU" because it must include the native portion of the runtime. - * The app can be "trimmed" to include only the portions of the framework needed by the application, but it'll - still be larger than a framework-dependent application. -* A framework-dependent application does not include the framework at all; it must be installed separately. - * The generated MSIX will trigger automatic framework installation if necessary (requires Internet connection). - * Can be built for "AnyCPU" since it doesn't include the native portion (or any other portion) of the runtime. - * Can be built for a specific CPU if desired. - * Debugging this requires setting `None` in the project file. +* **Self-contained** — .NET 런타임을 함께 번들합니다. 플랫폼별(x86/x64/ARM64) `dotnet.exe`가 + 포함되어야 해서 빌드 결과가 커집니다. + * 네이티브 런타임 일부를 포함하므로 "AnyCPU"로는 빌드할 수 없습니다. + * 앱이 쓰는 부분만 남기는 "trimming"이 가능하지만, 그래도 framework-dependent보다는 큽니다. +* **Framework-dependent** — 런타임을 포함하지 않으며 별도 설치가 필요합니다. + * 생성된 MSIX는 필요 시 자동 설치를 트리거합니다(인터넷 연결 필요). + * 네이티브 부분이 없으므로 "AnyCPU"로 빌드할 수 있습니다. + * 원하면 특정 CPU로도 빌드 가능합니다. + * 디버깅 시에는 프로젝트 파일에서 `None` 설정이 필요합니다. -When packaging an application: +패키징 시: -* An MSIX file (`*.msix`) can contain exactly one platform (x86, x64, ARM64). -* An MSIX Bundle (`*.msixbundle`) can contain more than one MSIX -- one for each platform, for example. +* MSIX 파일(`*.msix`)은 한 번에 하나의 플랫폼(x86, x64, ARM64)만 담을 수 있습니다. +* MSIX 번들(`*.msixbundle`)은 여러 MSIX를 묶을 수 있어 플랫폼별 MSIX를 한 번에 배포하기 좋습니다. -Ideally, it would be possible to package a single "AnyCPU" build of the app with stub MSIX files to install each -platform-specific copy of the framework, resulting in a Bundle that's only a little larger than a single copy of the -app. More investigation needed. +이상적으로는 단일 "AnyCPU" 빌드를 stub MSIX와 함께 묶어 플랫폼별 런타임을 설치하게 하면 +번들 크기를 최소화할 수 있습니다. 다만 이 구성은 추가 조사가 필요합니다. -However, it is possible to build a platform-specific MSIX containing an AnyCPU build of the app. That's much smaller -than a platform-specific build of the app, so even with 3 full copies of the AnyCPU app -- one each packaged for x86, -x64, and ARM64 -- the resulting bundle is significantly smaller. +대안으로, 플랫폼별 MSIX 안에 AnyCPU 빌드를 담을 수 있습니다. 이 경우 x86/x64/ARM64 세 카피를 묶어도 +플랫폼별 self-contained 번들보다 훨씬 작습니다. -Disabling R2R and bundling AnyCPU builds of the app generated a bundle roughly 12% of the size of a bundle of -self-contained apps for the same set of platforms. +R2R을 끄고 AnyCPU 빌드를 묶은 결과는, 같은 플랫폼 세트의 self-contained 번들 대비 약 12% 크기였습니다. diff --git a/SharedProps/ScratchVersion.targets b/SharedProps/ScratchVersion.targets index 469911a4..51cd3651 100644 --- a/SharedProps/ScratchVersion.targets +++ b/SharedProps/ScratchVersion.targets @@ -24,7 +24,14 @@ This file sets up version properties in our own Scratch way. --> - $(GitSemVerMajor).$(GitSemVerMinor).$(GitSemVerPatch) + + 1.0.0 + $(GitSemVerMajor).$(GitSemVerMinor).$(GitSemVerPatch) $(ScratchVersionTriplet)$(GitSemVerDashLabel) $(GitCommit) $(ScratchVersionFull)+$(ScratchVersionHash) diff --git a/brand/build_icons.py b/brand/build_icons.py new file mode 100644 index 00000000..59081a85 --- /dev/null +++ b/brand/build_icons.py @@ -0,0 +1,107 @@ +"""Rebuild Windows .ico + MSIX .png assets from the Alux brand SVG. + +Source of truth: + brand/labs-l.svg + +Outputs (overwrites in place; all paths relative to repo root): + scratch-link-win/scratch-link.ico app icon, 16..256 sizes + scratch-link-win/scratch-link-tray.ico tray icon, 16/24/32 + scratch-link-win-msix/Images/*.png MSIX tile/splash/store/lock assets + +How to run (from repo root): + pip install Pillow # one-time + python brand/build_icons.py + +When to re-run: + Whenever brand/labs-l.svg changes, or when a new MSIX asset slot needs + to be filled. Generated files are committed alongside the source so + that builds work without running this script. + +How it works: + labs-l.svg is a thin SVG wrapper around a single base64-encoded PNG + (non-square). We extract that PNG once, then for each target + slot fit it preserving aspect ratio onto a transparent canvas of the + required size. No external SVG renderer (cairosvg, rsvg, etc.) is + needed - Pillow is the only dependency. + +Editing the targets: + To add or change output sizes, edit ICO_TARGETS / PNG_TARGETS below. + ICO sizes must match the slots the existing scratch-link*.ico files + advertise; MSIX PNG dimensions are dictated by Windows + (Square44x44Logo.scale-200 must be 88x88, etc.). +""" + +from __future__ import annotations + +import base64 +import re +from io import BytesIO +from pathlib import Path + +from PIL import Image + +REPO = Path(__file__).resolve().parent.parent +SVG = REPO / "brand" / "labs-l.svg" + +WIN = REPO / "scratch-link-win" +MSIX = REPO / "scratch-link-win-msix" / "Images" + +ICO_TARGETS = { + WIN / "scratch-link.ico": [16, 24, 32, 48, 64, 96, 128, 256], + WIN / "scratch-link-tray.ico": [16, 24, 32], +} + +PNG_TARGETS = { + MSIX / "LockScreenLogo.scale-200.png": (48, 48), + MSIX / "SplashScreen.scale-200.png": (1240, 600), + MSIX / "Square150x150Logo.scale-200.png": (300, 300), + MSIX / "Square44x44Logo.scale-200.png": (88, 88), + MSIX / "Square44x44Logo.targetsize-24_altform-unplated.png": (24, 24), + MSIX / "StoreLogo.png": (50, 50), + MSIX / "Wide310x150Logo.scale-200.png": (620, 300), +} + + +def extract_source() -> Image.Image: + svg_text = SVG.read_text(encoding="utf-8") + match = re.search(r'xlink:href="data:image/png;base64,([^"]+)"', svg_text) + if not match: + raise SystemExit("Could not find embedded base64 PNG in SVG.") + png_bytes = base64.b64decode(match.group(1)) + img = Image.open(BytesIO(png_bytes)).convert("RGBA") + print(f"source image: {img.size}, mode={img.mode}") + return img + + +def fit_padded(src: Image.Image, target: tuple[int, int]) -> Image.Image: + """Scale src to fit inside `target` preserving aspect, center on transparent.""" + tw, th = target + sw, sh = src.size + scale = min(tw / sw, th / sh) + nw, nh = max(1, round(sw * scale)), max(1, round(sh * scale)) + resized = src.resize((nw, nh), Image.LANCZOS) + canvas = Image.new("RGBA", target, (0, 0, 0, 0)) + canvas.paste(resized, ((tw - nw) // 2, (th - nh) // 2), resized) + return canvas + + +def main() -> None: + src = extract_source() + + for path, sizes in ICO_TARGETS.items(): + biggest = max(sizes) + base = fit_padded(src, (biggest, biggest)) + # Pillow's ICO writer accepts a list of (w,h) sizes; it down-samples + # `base` for each entry. Providing pre-rendered frames isn't supported + # directly, but Lanczos downscaling from the 256x256 master is fine. + base.save(path, format="ICO", sizes=[(s, s) for s in sizes]) + print(f"wrote {path.relative_to(REPO)} with sizes {sorted(sizes)}") + + for path, target in PNG_TARGETS.items(): + out = fit_padded(src, target) + out.save(path, format="PNG", optimize=True) + print(f"wrote {path.relative_to(REPO)} {target}") + + +if __name__ == "__main__": + main() diff --git a/brand/labs-l.svg b/brand/labs-l.svg new file mode 100644 index 00000000..0cf24797 --- /dev/null +++ b/brand/labs-l.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/scratch-link-common/ScratchLinkApp.cs b/scratch-link-common/ScratchLinkApp.cs index 52626e00..fa7077a1 100644 --- a/scratch-link-common/ScratchLinkApp.cs +++ b/scratch-link-common/ScratchLinkApp.cs @@ -14,7 +14,7 @@ namespace ScratchLink; /// public class ScratchLinkApp { - private const int WebSocketPort = 20111; + private const int WebSocketPort = 20211; private readonly SessionManager sessionManager; private readonly WebSocketListener webSocketListener; diff --git a/scratch-link-common/Serial/SerialDiscoveryFilter.cs b/scratch-link-common/Serial/SerialDiscoveryFilter.cs new file mode 100644 index 00000000..1e2efd37 --- /dev/null +++ b/scratch-link-common/Serial/SerialDiscoveryFilter.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) 2026 ALUX, Inc. All rights reserved. +// Based on scratch-link by Scratch Foundation, licensed under AGPL-3.0-only. +// + +namespace ScratchLink.Serial; + +/// +/// A single filter entry passed by the client in a serial "discover" request. +/// A port is reported when it matches any filter in the list. +/// +internal class SerialDiscoveryFilter +{ + /// + /// Gets or sets the USB vendor ID to match, as a decimal integer. + /// + public int? UsbVendorId { get; set; } + + /// + /// Gets or sets the USB product ID to match, as a decimal integer. + /// + public int? UsbProductId { get; set; } + + /// + /// Gets or sets an optional port path substring to match (e.g. "COM7"). + /// + public string PathHint { get; set; } +} diff --git a/scratch-link-common/Serial/SerialOpenParams.cs b/scratch-link-common/Serial/SerialOpenParams.cs new file mode 100644 index 00000000..2b52f66e --- /dev/null +++ b/scratch-link-common/Serial/SerialOpenParams.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2026 ALUX, Inc. All rights reserved. +// Based on scratch-link by Scratch Foundation, licensed under AGPL-3.0-only. +// + +namespace ScratchLink.Serial; + +/// +/// Parameters extracted from a serial "connect" request. +/// +internal class SerialOpenParams +{ + /// + /// Gets or sets the baud rate. Required. + /// + public int BaudRate { get; set; } + + /// + /// Gets or sets the data bits. Defaults to 8. + /// + public int DataBits { get; set; } + + /// + /// Gets or sets the parity setting: "none", "even", or "odd". Defaults to "none". + /// + public string Parity { get; set; } + + /// + /// Gets or sets the stop bits: "one", "onePointFive", or "two". Defaults to "one". + /// + public string StopBits { get; set; } + + /// + /// Gets or sets the flow control: "none", "rtsCts", or "xonXoff". Defaults to "none". + /// + public string FlowControl { get; set; } +} diff --git a/scratch-link-common/Serial/SerialSession.cs b/scratch-link-common/Serial/SerialSession.cs new file mode 100644 index 00000000..b6f9ce01 --- /dev/null +++ b/scratch-link-common/Serial/SerialSession.cs @@ -0,0 +1,340 @@ +// +// Copyright (c) 2026 ALUX, Inc. All rights reserved. +// Based on scratch-link by Scratch Foundation, licensed under AGPL-3.0-only. +// + +namespace ScratchLink.Serial; + +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Fleck; +using ScratchLink.Extensions; +using ScratchLink.JsonRpc; + +/// +/// Cross-platform base for a USB Serial transport session. Uses Serial-specific +/// notification names (serialDidReceiveData, serialDidDisconnect) +/// so callers cannot confuse Serial events with BLE characteristic events or +/// BT message events. +/// +/// Platform-specific port handle, passed back to . +internal abstract class SerialSession : PeripheralSession + where TPort : class +{ + /// + /// Initializes a new instance of the class. + /// + /// + public SerialSession(IWebSocketConnection webSocket) + : base(webSocket) + { + this.Handlers["discover"] = this.HandleDiscover; + this.Handlers["write"] = this.HandleWrite; + this.Handlers["disconnect"] = this.HandleDisconnect; + this.Handlers["startReading"] = this.HandleStartReading; + this.Handlers["stopReading"] = this.HandleStopReading; + } + + /// + /// Implement the JSON-RPC "discover" request. Parses the filter list and + /// kicks off platform-specific enumeration. Discovered ports are streamed + /// back via . + /// + /// The name of the method being called ("discover"). + /// A JSON object optionally containing a filters array. + /// A resolving to an empty result; discoveries are streamed via notifications. + protected Task HandleDiscover(string methodName, JsonElement? args) + { + var filters = ParseFilters(args); + Trace.WriteLine($"received serial discover request with {filters.Count} filter(s)"); + + this.ClearDiscoveredPeripherals(); + return this.DoDiscover(filters); + } + + /// + /// Platform-specific implementation for port discovery. Implementations + /// should return promptly and stream results via . + /// + /// The filter list from the client. Empty means "match all". + /// A representing the asynchronous operation. + protected abstract Task DoDiscover(IReadOnlyList filters); + + /// + protected override Task DoConnect(TPort port, JsonElement? args) + { + var openParams = ParseOpenParams(args); + return this.DoConnect(port, openParams); + } + + /// + /// Platform-specific implementation for opening the given port. On success, + /// RX should be active and incoming bytes should be reported via + /// . + /// + /// The port handle previously registered via . + /// Open parameters extracted from the connect request. + /// A representing the asynchronous operation. + protected abstract Task DoConnect(TPort port, SerialOpenParams openParams); + + /// + /// Implement the JSON-RPC "write" request. Decodes the base64 payload and + /// forwards to the platform implementation. + /// + /// The name of the method being called ("write"). + /// A JSON object containing message and encoding. + /// An object with sentBytes equal to the number of bytes written. + protected async Task HandleWrite(string methodName, JsonElement? args) + { + if (args == null) + { + throw JsonRpc2Error.InvalidParams("write requires a message buffer").ToException(); + } + + var buffer = EncodingHelpers.DecodeBuffer(args.Value); + var sentBytes = await this.DoWrite(buffer); + + return new Dictionary { ["sentBytes"] = sentBytes }; + } + + /// + /// Platform-specific implementation for sending bytes to the port. + /// + /// The bytes to send. + /// The number of bytes actually written. + protected abstract Task DoWrite(byte[] data); + + /// + /// Implement the JSON-RPC "disconnect" request. Closes the port without + /// firing a serialDidDisconnect notification (that is reserved for + /// external-cause disconnects). + /// + /// The name of the method being called ("disconnect"). + /// Unused. + /// An empty result. + protected async Task HandleDisconnect(string methodName, JsonElement? args) + { + await this.DoDisconnect(); + return new Dictionary(); + } + + /// + /// Platform-specific implementation for closing the port. + /// + /// A representing the asynchronous operation. + protected abstract Task DoDisconnect(); + + /// + /// Implement the JSON-RPC "startReading" request. RX is enabled automatically + /// on connect, so the default implementation is a no-op. Subclasses may + /// override to re-enable RX after a stopReading. + /// + /// The name of the method being called ("startReading"). + /// Unused. + /// An empty result. + protected virtual Task HandleStartReading(string methodName, JsonElement? args) + { + return Task.FromResult(new Dictionary()); + } + + /// + /// Implement the JSON-RPC "stopReading" request. Default is a no-op. + /// + /// The name of the method being called ("stopReading"). + /// Unused. + /// An empty result. + protected virtual Task HandleStopReading(string methodName, JsonElement? args) + { + return Task.FromResult(new Dictionary()); + } + + /// + /// Report received bytes to the client as a serialDidReceiveData + /// notification. The payload is base64-encoded. + /// + /// The bytes received. + /// A representing the asynchronous operation. + protected async Task DidReceiveData(byte[] data) + { + var encoded = EncodingHelpers.EncodeBuffer(data, "base64"); + await this.SendNotification("serialDidReceiveData", new SerialDataReceived + { + Encoding = "base64", + Message = encoded, + }); + } + + /// + /// Report an external-cause disconnect to the client as a + /// serialDidDisconnect notification. Does not fire for the + /// client-initiated disconnect request. + /// + /// One of "user", "device", "error", "shutdown". + /// Optional human-readable detail. + /// A representing the asynchronous operation. + protected async Task DidDisconnect(string reason, string message = null) + { + await this.SendNotification("serialDidDisconnect", new SerialDisconnectMessage + { + Reason = reason, + Message = message, + }); + } + + /// + /// Track a discovered port and report it to the client. Uses + /// to + /// obtain a session-scoped peripheral ID. + /// + /// Platform-specific port handle. + /// OS-level port path used as the address (e.g. "COM7"). + /// User-visible name, may include the path. + /// Vendor ID as a hex string (e.g. "0x1A86"), or null. + /// Product ID as a hex string (e.g. "0x7523"), or null. + /// A representing the asynchronous operation. + protected async Task OnPortDiscovered(TPort port, string path, string displayName, string vendorIdHex, string productIdHex) + { + var peripheralId = this.RegisterPeripheral(port, path); + + await this.SendNotification("didDiscoverPeripheral", new SerialPortDiscovered + { + PeripheralId = peripheralId, + Name = displayName, + Path = path, + VendorId = vendorIdHex, + ProductId = productIdHex, + RSSI = 0, + }); + } + + private static IReadOnlyList ParseFilters(JsonElement? args) + { + var result = new List(); + + var filtersElement = args?.TryGetProperty("filters"); + if (filtersElement == null) + { + return result; + } + + if (filtersElement.Value.ValueKind != JsonValueKind.Array) + { + throw JsonRpc2Error.InvalidParams("'filters' must be an array").ToException(); + } + + foreach (var item in filtersElement.Value.EnumerateArray()) + { + result.Add(new SerialDiscoveryFilter + { + UsbVendorId = item.TryGetProperty("usbVendorId")?.GetInt32(), + UsbProductId = item.TryGetProperty("usbProductId")?.GetInt32(), + PathHint = item.TryGetProperty("pathHint")?.GetString(), + }); + } + + return result; + } + + private static SerialOpenParams ParseOpenParams(JsonElement? args) + { + var baudRate = args?.TryGetProperty("baudRate")?.GetInt32(); + if (baudRate == null) + { + throw JsonRpc2Error.InvalidParams("connect requires baudRate").ToException(); + } + + return new SerialOpenParams + { + BaudRate = baudRate.Value, + DataBits = args?.TryGetProperty("dataBits")?.GetInt32() ?? 8, + Parity = args?.TryGetProperty("parity")?.GetString() ?? "none", + StopBits = args?.TryGetProperty("stopBits")?.GetString() ?? "one", + FlowControl = args?.TryGetProperty("flowControl")?.GetString() ?? "none", + }; + } + + /// + /// Payload of a serialDidReceiveData notification. + /// + protected class SerialDataReceived + { + /// + /// Gets or sets the encoding identifier; always "base64" for serial RX. + /// + [JsonPropertyName("encoding")] + public string Encoding { get; set; } + + /// + /// Gets or sets the encoded payload. + /// + [JsonPropertyName("message")] + public string Message { get; set; } + } + + /// + /// Payload of a serialDidDisconnect notification. + /// + protected class SerialDisconnectMessage + { + /// + /// Gets or sets the disconnect reason: "user", "device", "error", or "shutdown". + /// + [JsonPropertyName("reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Reason { get; set; } + + /// + /// Gets or sets an optional human-readable detail message. + /// + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string Message { get; set; } + } + + /// + /// Payload of a didDiscoverPeripheral notification on the serial transport. + /// + protected class SerialPortDiscovered + { + /// + /// Gets or sets the session-scoped peripheral ID used by the client to connect. + /// + [JsonPropertyName("peripheralId")] + public string PeripheralId { get; set; } + + /// + /// Gets or sets the user-visible name of the port. + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// Gets or sets the OS-level port path (e.g. "COM7"). + /// + [JsonPropertyName("path")] + public string Path { get; set; } + + /// + /// Gets or sets the USB vendor ID as a hex string (e.g. "0x1A86"), if known. + /// + [JsonPropertyName("vendorId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string VendorId { get; set; } + + /// + /// Gets or sets the USB product ID as a hex string (e.g. "0x7523"), if known. + /// + [JsonPropertyName("productId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string ProductId { get; set; } + + /// + /// Gets or sets a placeholder RSSI value for cross-transport message compatibility. + /// + [JsonPropertyName("rssi")] + public int RSSI { get; set; } + } +} diff --git a/scratch-link-common/scratch-link-common.projitems b/scratch-link-common/scratch-link-common.projitems index 9ce3b456..569953fd 100644 --- a/scratch-link-common/scratch-link-common.projitems +++ b/scratch-link-common/scratch-link-common.projitems @@ -13,6 +13,9 @@ + + + diff --git a/scratch-link-win-msix/Images/LockScreenLogo.scale-200.png b/scratch-link-win-msix/Images/LockScreenLogo.scale-200.png index ff37a993..de5f4da9 100644 Binary files a/scratch-link-win-msix/Images/LockScreenLogo.scale-200.png and b/scratch-link-win-msix/Images/LockScreenLogo.scale-200.png differ diff --git a/scratch-link-win-msix/Images/SplashScreen.scale-200.png b/scratch-link-win-msix/Images/SplashScreen.scale-200.png index afc0fdd2..38974c6e 100644 Binary files a/scratch-link-win-msix/Images/SplashScreen.scale-200.png and b/scratch-link-win-msix/Images/SplashScreen.scale-200.png differ diff --git a/scratch-link-win-msix/Images/Square150x150Logo.scale-200.png b/scratch-link-win-msix/Images/Square150x150Logo.scale-200.png index 5f8efebb..e8446163 100644 Binary files a/scratch-link-win-msix/Images/Square150x150Logo.scale-200.png and b/scratch-link-win-msix/Images/Square150x150Logo.scale-200.png differ diff --git a/scratch-link-win-msix/Images/Square44x44Logo.scale-200.png b/scratch-link-win-msix/Images/Square44x44Logo.scale-200.png index 777e7e6d..85715c35 100644 Binary files a/scratch-link-win-msix/Images/Square44x44Logo.scale-200.png and b/scratch-link-win-msix/Images/Square44x44Logo.scale-200.png differ diff --git a/scratch-link-win-msix/Images/Square44x44Logo.targetsize-24_altform-unplated.png b/scratch-link-win-msix/Images/Square44x44Logo.targetsize-24_altform-unplated.png index 93133511..d0ad3681 100644 Binary files a/scratch-link-win-msix/Images/Square44x44Logo.targetsize-24_altform-unplated.png and b/scratch-link-win-msix/Images/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/scratch-link-win-msix/Images/StoreLogo.png b/scratch-link-win-msix/Images/StoreLogo.png index d14b2627..dba26ba4 100644 Binary files a/scratch-link-win-msix/Images/StoreLogo.png and b/scratch-link-win-msix/Images/StoreLogo.png differ diff --git a/scratch-link-win-msix/Images/Wide310x150Logo.scale-200.png b/scratch-link-win-msix/Images/Wide310x150Logo.scale-200.png index 1530f6bd..d35fa8c1 100644 Binary files a/scratch-link-win-msix/Images/Wide310x150Logo.scale-200.png and b/scratch-link-win-msix/Images/Wide310x150Logo.scale-200.png differ diff --git a/scratch-link-win-msix/Package.appxmanifest b/scratch-link-win-msix/Package.appxmanifest index 9d911758..72092f3a 100644 --- a/scratch-link-win-msix/Package.appxmanifest +++ b/scratch-link-win-msix/Package.appxmanifest @@ -9,11 +9,11 @@ + Version="1.0.0.0" /> - Scratch Link - Scratch Foundation + Alux Scratch Link + Alux (based on Scratch Foundation) Images\StoreLogo.png @@ -31,8 +31,8 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> @@ -45,5 +45,10 @@ + + + + + diff --git a/scratch-link-win/Serial/WinSerialPortEnumerator.cs b/scratch-link-win/Serial/WinSerialPortEnumerator.cs new file mode 100644 index 00000000..4167521a --- /dev/null +++ b/scratch-link-win/Serial/WinSerialPortEnumerator.cs @@ -0,0 +1,143 @@ +// +// Copyright (c) 2026 ALUX, Inc. All rights reserved. +// Based on scratch-link by Scratch Foundation, licensed under AGPL-3.0-only. +// + +namespace ScratchLink.Win.Serial; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Management; +using System.Text.RegularExpressions; +using ScratchLink.Serial; + +/// +/// Enumerates USB serial ports on Windows via WMI (Win32_PnPEntity), extracting +/// the COM port name plus USB VID/PID from the PNPDeviceID. Used by +/// for discovery. +/// +internal static class WinSerialPortEnumerator +{ + private const string WmiQuery = + "SELECT DeviceID, PNPDeviceID, Caption, Name FROM Win32_PnPEntity " + + "WHERE PNPClass = 'Ports' AND PNPDeviceID LIKE 'USB%'"; + + private static readonly Regex VidPidRegex = new ( + @"VID_([0-9A-F]{4})&PID_([0-9A-F]{4})", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex ComPortRegex = new ( + @"\((COM\d+)\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// Synchronously query WMI for USB serial ports matching any of the given filters. + /// + /// Filter list. Empty means "return all matching USB serial ports". + /// List of matching ports. May be empty. + public static IReadOnlyList Query(IReadOnlyList filters) + { + var results = new List(); + + try + { + using var searcher = new ManagementObjectSearcher(WmiQuery); + using var collection = searcher.Get(); + + foreach (var item in collection) + { + using var mo = (ManagementObject)item; + var info = BuildPortInfo(mo); + if (info == null) + { + continue; + } + + if (!MatchesAnyFilter(info, filters)) + { + continue; + } + + results.Add(info); + } + } + catch (ManagementException e) + { + Trace.WriteLine($"WMI query failed during serial port enumeration: {e}"); + } + catch (Exception e) + { + Trace.WriteLine($"Unexpected error during serial port enumeration: {e}"); + } + + return results; + } + + private static WinSerialPortInfo BuildPortInfo(ManagementObject mo) + { + var pnpId = mo["PNPDeviceID"] as string ?? string.Empty; + var caption = mo["Caption"] as string ?? string.Empty; + var name = mo["Name"] as string ?? caption; + + var comMatch = ComPortRegex.Match(caption); + if (!comMatch.Success) + { + // No COM port number means this isn't a usable serial port from our point of view. + return null; + } + + int? vendorId = null; + int? productId = null; + var vidPidMatch = VidPidRegex.Match(pnpId); + if (vidPidMatch.Success) + { + vendorId = int.Parse(vidPidMatch.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + productId = int.Parse(vidPidMatch.Groups[2].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + } + + return new WinSerialPortInfo + { + Path = comMatch.Groups[1].Value, + DisplayName = name, + VendorId = vendorId, + ProductId = productId, + PnpDeviceId = pnpId, + }; + } + + private static bool MatchesAnyFilter(WinSerialPortInfo info, IReadOnlyList filters) + { + if (filters == null || filters.Count == 0) + { + return true; + } + + foreach (var filter in filters) + { + if (filter.UsbVendorId.HasValue && filter.UsbVendorId.Value != info.VendorId) + { + continue; + } + + if (filter.UsbProductId.HasValue && filter.UsbProductId.Value != info.ProductId) + { + continue; + } + + if (!string.IsNullOrEmpty(filter.PathHint)) + { + if (info.Path == null || + info.Path.IndexOf(filter.PathHint, StringComparison.OrdinalIgnoreCase) < 0) + { + continue; + } + } + + return true; + } + + return false; + } +} diff --git a/scratch-link-win/Serial/WinSerialPortInfo.cs b/scratch-link-win/Serial/WinSerialPortInfo.cs new file mode 100644 index 00000000..b28f27d2 --- /dev/null +++ b/scratch-link-win/Serial/WinSerialPortInfo.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2026 ALUX, Inc. All rights reserved. +// Based on scratch-link by Scratch Foundation, licensed under AGPL-3.0-only. +// + +namespace ScratchLink.Win.Serial; + +/// +/// A single port returned by . +/// +internal class WinSerialPortInfo +{ + /// + /// Gets or sets the OS-level port path (e.g. "COM7"). + /// + public string Path { get; set; } + + /// + /// Gets or sets a user-visible name (typically includes the COM number). + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the USB vendor ID, or null if not parseable. + /// + public int? VendorId { get; set; } + + /// + /// Gets or sets the USB product ID, or null if not parseable. + /// + public int? ProductId { get; set; } + + /// + /// Gets or sets the raw PNPDeviceID, useful for surprise-removal matching. + /// + public string PnpDeviceId { get; set; } +} diff --git a/scratch-link-win/Serial/WinSerialSession.cs b/scratch-link-win/Serial/WinSerialSession.cs new file mode 100644 index 00000000..06cf5c58 --- /dev/null +++ b/scratch-link-win/Serial/WinSerialSession.cs @@ -0,0 +1,299 @@ +// +// Copyright (c) 2026 ALUX, Inc. All rights reserved. +// Based on scratch-link by Scratch Foundation, licensed under AGPL-3.0-only. +// + +namespace ScratchLink.Win.Serial; + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Ports; +using System.Threading; +using System.Threading.Tasks; +using Fleck; +using ScratchLink.JsonRpc; +using ScratchLink.Serial; + +/// +/// Implements a USB Serial session on Windows using +/// for I/O and WMI for VID/PID-aware port discovery. +/// +internal class WinSerialSession : SerialSession +{ + private SerialPort port; + private CancellationTokenSource rxCts; + private Task rxLoop; + + /// + /// Initializes a new instance of the class. + /// + /// The WebSocket connection for this session. + public WinSerialSession(IWebSocketConnection webSocket) + : base(webSocket) + { + } + + /// + protected override bool IsConnected => this.port != null && this.port.IsOpen; + + /// + protected override async Task DoDiscover(IReadOnlyList filters) + { + var ports = await Task.Run(() => WinSerialPortEnumerator.Query(filters)); + + foreach (var portInfo in ports) + { + var vendorHex = portInfo.VendorId.HasValue + ? $"0x{portInfo.VendorId.Value:X4}" + : null; + var productHex = portInfo.ProductId.HasValue + ? $"0x{portInfo.ProductId.Value:X4}" + : null; + + await this.OnPortDiscovered(portInfo, portInfo.Path, portInfo.DisplayName, vendorHex, productHex); + } + + return new Dictionary(); + } + + /// + protected override Task DoConnect(WinSerialPortInfo info, SerialOpenParams openParams) + { + if (this.port != null) + { + throw JsonRpc2Error.InvalidRequest("already connected").ToException(); + } + + try + { + this.port = new SerialPort(info.Path) + { + BaudRate = openParams.BaudRate, + DataBits = openParams.DataBits, + Parity = MapParity(openParams.Parity), + StopBits = MapStopBits(openParams.StopBits), + Handshake = MapFlowControl(openParams.FlowControl), + ReadTimeout = 500, + WriteTimeout = SerialPort.InfiniteTimeout, + // CH340 + codetinker firmware treats DTR/RTS transitions as a reset signal; + // pin them low explicitly so SerialPort.Open does not toggle them. + DtrEnable = false, + RtsEnable = false, + }; + this.port.Open(); + } + catch (Exception e) + { + Trace.WriteLine($"Failed to open serial port {info.Path}: {e}"); + this.CloseConnectionSilently(); + throw JsonRpc2Error.ApplicationError($"could not open serial port {info.Path}: {e.Message}").ToException(); + } + + this.rxCts = new CancellationTokenSource(); + var token = this.rxCts.Token; + this.rxLoop = Task.Run(() => this.ReadLoop(token)); + + return Task.FromResult(new Dictionary()); + } + + /// + protected override async Task DoWrite(byte[] data) + { + var currentPort = this.port; + if (currentPort == null || !currentPort.IsOpen) + { + throw JsonRpc2Error.InvalidRequest("cannot write when not connected").ToException(); + } + + try + { + await currentPort.BaseStream.WriteAsync(data, 0, data.Length); + } + catch (ObjectDisposedException) + { + throw JsonRpc2Error.InternalError("write failed: port was disposed").ToException(); + } + catch (IOException e) + { + throw JsonRpc2Error.InternalError($"write failed: {e.Message}").ToException(); + } + + return data.Length; + } + + /// + protected override async Task DoDisconnect() + { + var loop = this.rxLoop; + this.CloseConnectionSilently(); + + if (loop != null) + { + try + { + await loop; + } + catch + { + // swallow: the loop's own error path already reported anything client-visible + } + } + } + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + this.CloseConnectionSilently(); + } + + private static Parity MapParity(string parity) => + (parity ?? "none").ToLowerInvariant() switch + { + "none" => Parity.None, + "even" => Parity.Even, + "odd" => Parity.Odd, + "mark" => Parity.Mark, + "space" => Parity.Space, + _ => throw JsonRpc2Error.InvalidParams($"unsupported parity: {parity}").ToException(), + }; + + private static StopBits MapStopBits(string stopBits) => + (stopBits ?? "one").ToLowerInvariant() switch + { + "one" => StopBits.One, + "onepointfive" => StopBits.OnePointFive, + "two" => StopBits.Two, + _ => throw JsonRpc2Error.InvalidParams($"unsupported stopBits: {stopBits}").ToException(), + }; + + private static Handshake MapFlowControl(string flow) => + (flow ?? "none").ToLowerInvariant() switch + { + "none" => Handshake.None, + "rtscts" => Handshake.RequestToSend, + "xonxoff" => Handshake.XOnXOff, + _ => throw JsonRpc2Error.InvalidParams($"unsupported flowControl: {flow}").ToException(), + }; + + private void ReadLoop(CancellationToken ct) + { + var buf = new byte[4096]; + + while (!ct.IsCancellationRequested) + { + var currentPort = this.port; + if (currentPort == null || !currentPort.IsOpen) + { + break; + } + + int n; + try + { + // SerialPort.BaseStream.ReadAsync on .NET 6 reliably throws + // ERROR_OPERATION_ABORTED on the first call against CH340 ports + // (even without a CancellationToken, even with DTR/RTS pinned low). + // Use the synchronous Read with a finite ReadTimeout so the loop + // wakes periodically and shutdown can be observed via ct. + n = currentPort.Read(buf, 0, buf.Length); + } + catch (TimeoutException) + { + continue; + } + catch (OperationCanceledException) + { + break; + } + catch (IOException) when (ct.IsCancellationRequested) + { + break; + } + catch (IOException e) + { + Trace.WriteLine($"Serial read IOException on {currentPort.PortName}: {e.Message}"); + _ = this.DidDisconnect("device", e.Message); + this.CloseConnectionSilently(); + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (InvalidOperationException) + { + break; + } + catch (Exception e) + { + Trace.WriteLine($"Unexpected serial read error: {e}"); + _ = this.DidDisconnect("error", e.Message); + this.CloseConnectionSilently(); + break; + } + + if (n <= 0) + { + continue; + } + + var data = new byte[n]; + Buffer.BlockCopy(buf, 0, data, 0, n); + _ = this.DidReceiveData(data); + } + } + + private void CloseConnectionSilently() + { + var localCts = this.rxCts; + this.rxCts = null; + + try + { + localCts?.Cancel(); + } + catch + { + // ignored + } + + var localPort = this.port; + this.port = null; + + if (localPort != null) + { + try + { + if (localPort.IsOpen) + { + localPort.Close(); + } + } + catch (Exception e) + { + Trace.WriteLine($"Error closing serial port: {e}"); + } + + try + { + localPort.Dispose(); + } + catch + { + // ignored + } + } + + try + { + localCts?.Dispose(); + } + catch + { + // ignored + } + } +} diff --git a/scratch-link-win/TrayIcon.xaml b/scratch-link-win/TrayIcon.xaml index ecce2afa..40fdf892 100644 --- a/scratch-link-win/TrayIcon.xaml +++ b/scratch-link-win/TrayIcon.xaml @@ -9,7 +9,7 @@ diff --git a/scratch-link-win/WinSessionManager.cs b/scratch-link-win/WinSessionManager.cs index 9d8e1313..36603a24 100644 --- a/scratch-link-win/WinSessionManager.cs +++ b/scratch-link-win/WinSessionManager.cs @@ -7,6 +7,7 @@ namespace ScratchLink.Win; using Fleck; using ScratchLink.Win.BLE; using ScratchLink.Win.BT; +using ScratchLink.Win.Serial; /// /// Implements the Windows-specific functionality of the SessionManager. @@ -21,6 +22,7 @@ protected override Session MakeNewSession(IWebSocketConnection webSocket) { "/scratch/ble" => new WinBLESession(webSocket), "/scratch/bt" => new WinBTSession(webSocket), + "/scratch/serial" => new WinSerialSession(webSocket), // for unrecognized paths, return a base Session for debugging _ => new Session(webSocket), diff --git a/scratch-link-win/app.manifest b/scratch-link-win/app.manifest index e0412302..285545f7 100644 --- a/scratch-link-win/app.manifest +++ b/scratch-link-win/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/scratch-link-win/scratch-link-tray.ico b/scratch-link-win/scratch-link-tray.ico index b53e3878..41fe21de 100644 Binary files a/scratch-link-win/scratch-link-tray.ico and b/scratch-link-win/scratch-link-tray.ico differ diff --git a/scratch-link-win/scratch-link-win.csproj b/scratch-link-win/scratch-link-win.csproj index df4ac45a..5eab41be 100644 --- a/scratch-link-win/scratch-link-win.csproj +++ b/scratch-link-win/scratch-link-win.csproj @@ -5,8 +5,8 @@ win10-x86;win10-x64;win10-arm64 10.0.17763.0 ScratchLink.Win - Scratch Link - Scratch Foundation + Alux Scratch Link + Alux (based on Scratch Foundation) $(Company) disable true @@ -41,5 +41,7 @@ + + diff --git a/scratch-link-win/scratch-link.ico b/scratch-link-win/scratch-link.ico index 42da3ace..126b93e9 100644 Binary files a/scratch-link-win/scratch-link.ico and b/scratch-link-win/scratch-link.ico differ