diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2d22ae7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Report a problem +description: Something in Roku Control doesn't work the way it should. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time — every report makes the app better for everyone. + + **Only volume works?** That's your Roku's *Limited* mode. On the Roku open + **Settings › System › Advanced system settings › Control by mobile apps** and choose **Enabled**. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you try, what did you expect, and what happened instead? + placeholder: I pressed Home and the app said "Can't reach the Roku"… + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open Roku Control + 2. Pick my living room TV + 3. … + - type: textarea + id: environment + attributes: + label: Your setup + description: >- + Filled in for you when you use "Report a problem" in the app. It lists your Roku's model and + software version — never its IP address, name or serial number. + render: text + - type: input + id: version + attributes: + label: App version + description: Shown at the bottom of the app. + placeholder: "2.1.0" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c294c6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: MoveWeight.com + url: https://moveweight.com + about: The people who make Roku Control. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..2c92786 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Suggest an idea +description: A feature, a button, a shortcut — anything that would make Roku Control better for you. +title: "[Idea]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Roku Control is free, open source and ad-free. Ideas from people who use it decide what gets built next — thank you! + - type: textarea + id: idea + attributes: + label: Your idea + description: What would you like the app to do? + validations: + required: true + - type: textarea + id: why + attributes: + label: Why it would help + description: What are you trying to do today, and what gets in the way? + - type: input + id: version + attributes: + label: App version + placeholder: "2.1.0" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b6fd97e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,191 @@ +name: Build installers + +# Runs by hand from the Actions tab (optionally publishing a release and +# submitting to the Microsoft Store), on a version tag (v2.1.0), or on a pull +# request labelled "build-installers" (a downloadable preview for testing). +on: + workflow_dispatch: + inputs: + release: + description: Publish a GitHub release with this tag (e.g. v2.1.0). Leave empty to only build. + required: false + default: '' + store: + description: Also submit the MSIX to the Microsoft Store (needs the PARTNER_CENTER_* secrets) + type: boolean + default: false + push: + tags: ['v*'] + pull_request: + types: [labeled, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: ${{ matrix.os }} + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'build-installers') + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-22.04] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install Tauri system libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev patchelf + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - run: npm ci + + # tauri:build builds the frontend first (beforeBuildCommand), so the + # installer never ships an empty window. + - run: npm run tauri:build + + # The Microsoft Store listing is an MSIX product: package the same exe + # with msix/AppxManifest.xml using the Windows SDK's makeappx. Partner + # Center signs it on upload. + - name: Package for the Microsoft Store (MSIX) + if: runner.os == 'Windows' + shell: pwsh + run: | + $makeappx = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\makeappx.exe" | + Sort-Object { [version]$_.Directory.Parent.Name } -Descending | Select-Object -First 1 + if (-not $makeappx) { throw 'makeappx.exe not found in the Windows SDK' } + $version = (Select-Xml -Path msix/AppxManifest.xml -Namespace @{ m = 'http://schemas.microsoft.com/appx/manifest/foundation/windows10' } -XPath '/m:Package/m:Identity').Node.Version + $stage = Join-Path $env:RUNNER_TEMP 'msix' + New-Item -ItemType Directory -Force (Join-Path $stage 'Assets') | Out-Null + Copy-Item src-tauri/target/release/roku-control.exe $stage + Copy-Item msix/AppxManifest.xml $stage + Copy-Item msix/Assets/* (Join-Path $stage 'Assets') + New-Item -ItemType Directory -Force src-tauri/target/release/bundle/msix | Out-Null + & $makeappx.FullName pack /d $stage /p "src-tauri/target/release/bundle/msix/RokuControl_${version}_x64.msix" /o + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - uses: actions/upload-artifact@v4 + with: + name: roku-control-${{ runner.os }} + if-no-files-found: error + path: | + src-tauri/target/release/bundle/msix/*.msix + src-tauri/target/release/bundle/msi/*.msi + src-tauri/target/release/bundle/nsis/*.exe + src-tauri/target/release/bundle/dmg/*.dmg + src-tauri/target/release/bundle/deb/*.deb + src-tauri/target/release/bundle/rpm/*.rpm + src-tauri/target/release/bundle/appimage/*.AppImage + + # Publish the installers as a GitHub release, so there is a direct download + # link. A release asked for from the Actions tab is published right away; + # pushed tags with a suffix (v2.1.0-beta.1) publish a pre-release and plain + # version tags make a draft to review first; a labelled PR gets a + # "preview-pr" pre-release that is replaced on every push. + release: + name: Publish release + needs: build + if: ${{ !cancelled() && needs.build.result != 'skipped' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || inputs.release != '') }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: installers + merge-multiple: true + + - name: Create the release + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_SHA: ${{ github.event.pull_request.head.sha }} + INPUT_TAG: ${{ inputs.release }} + run: | + mapfile -t files < <(find installers -type f) + printf 'Attaching: %s\n' "${files[@]}" + if [ "$EVENT" = pull_request ]; then + tag="preview-pr$PR_NUMBER" + gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --cleanup-tag --yes || true + gh release create "$tag" "${files[@]}" --repo "$GITHUB_REPOSITORY" --target "$PR_SHA" --prerelease \ + --title "Preview build — PR #$PR_NUMBER" \ + --notes "Test build of pull request #$PR_NUMBER at $PR_SHA. Not a release. Installers are unsigned, so Windows may show a SmartScreen warning: choose More info → Run anyway." + elif [ "$EVENT" = workflow_dispatch ]; then + if ! [[ "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Release tag must look like v2.1.0 or v2.1.0-beta.1, got '$INPUT_TAG'" + exit 1 + fi + kind=--latest + case "$INPUT_TAG" in *-*) kind=--prerelease ;; esac + gh release create "$INPUT_TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" \ + --title "Roku Control $INPUT_TAG" --generate-notes "$kind" + else + kind=--draft + case "$REF_NAME" in *-*) kind=--prerelease ;; esac + gh release create "$REF_NAME" "${files[@]}" --repo "$GITHUB_REPOSITORY" \ + --title "Roku Control $REF_NAME" --generate-notes "$kind" + fi + + # Submit the MSIX to the Microsoft Store listing (product 9MTNNGZKJ1WK) with + # Microsoft's Store CLI. Needs an Azure AD app linked in Partner Center; its + # IDs go in the PARTNER_CENTER_* repository secrets. Certification then + # takes Microsoft a few hours to a few days. + store: + name: Submit to Microsoft Store + needs: build + if: ${{ github.event_name == 'workflow_dispatch' && inputs.store && needs.build.result == 'success' }} + runs-on: windows-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: roku-control-Windows + path: windows + + - name: Check the Partner Center secrets + shell: pwsh + env: + TENANT_ID: ${{ secrets.PARTNER_CENTER_TENANT_ID }} + SELLER_ID: ${{ secrets.PARTNER_CENTER_SELLER_ID }} + CLIENT_ID: ${{ secrets.PARTNER_CENTER_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.PARTNER_CENTER_CLIENT_SECRET }} + run: | + $missing = 'TENANT_ID', 'SELLER_ID', 'CLIENT_ID', 'CLIENT_SECRET' | Where-Object { -not [Environment]::GetEnvironmentVariable($_) } + if ($missing) { + Write-Output "::error::Missing repository secrets: $($missing | ForEach-Object { "PARTNER_CENTER_$_" }). See STORE.md." + exit 1 + } + + - uses: microsoft/setup-msstore-cli@v1 + + - name: Submit the MSIX + shell: pwsh + env: + TENANT_ID: ${{ secrets.PARTNER_CENTER_TENANT_ID }} + SELLER_ID: ${{ secrets.PARTNER_CENTER_SELLER_ID }} + CLIENT_ID: ${{ secrets.PARTNER_CENTER_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.PARTNER_CENTER_CLIENT_SECRET }} + run: | + $msix = Get-ChildItem windows -Recurse -Filter *.msix | Select-Object -First 1 + if (-not $msix) { throw 'No .msix in the Windows build' } + msstore reconfigure --tenantId $env:TENANT_ID --sellerId $env:SELLER_ID --clientId $env:CLIENT_ID --clientSecret $env:CLIENT_SECRET + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + msstore publish -v -i $msix.FullName -id 9MTNNGZKJ1WK + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06357dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install Tauri system libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + key: cargo-${{ runner.os }}-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}- + + # The Rust build embeds ../dist, so the frontend has to exist first. + - run: npm ci + - run: npm run build + + - name: Clippy + working-directory: src-tauri + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Tests + working-directory: src-tauri + run: cargo test --locked diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f71799a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +## 2.1.0 + +Fixes for the problems people reported in Microsoft Store reviews ("didn't +work at all", "only volume works", "still doesn't work on the living room TV"), +plus keyboard control and power-on. + +### Fixed +- The app opened pointed at the developer's own TVs (hard-coded IP addresses), + so for everyone else no button did anything until they found and changed the + device menu. Those entries are gone; the app now searches your network on + launch and picks what it finds. +- Buttons failed silently. Every command now checks the Roku's answer and says + what went wrong and how to fix it. +- Roku OS 14.1+ "Limited" mode (the reason only volume worked) is detected, and + the app shows the exact setting to change. +- The fallback network scan tried 762 addresses one at a time with a 3-second + timeout — up to ~38 minutes of spinner. It now probes your actual subnet in + parallel in a couple of seconds. +- Discovery now asks on every network adapter, so VPNs and Hyper-V/WSL virtual + adapters no longer swallow the search. +- A system or corporate proxy could intercept requests meant for the Roku; the + app now always connects directly. +- The green "connected" dot was always green. Status is now a real check every + few seconds. +- When the router gives a Roku a new IP, the app finds it again by serial number. +- The close button and window dragging were blocked by missing permissions. +- Rapid presses could arrive out of order; commands are now sent in sequence. +- Typed text now encodes spaces, symbols and accented letters correctly. +- Channel names with `&` showed as `&`. + +### Added +- **Turn on from off**: `PowerOn`, then Wake-on-LAN with retries. +- **Keyboard shortcuts** for every remote button, press-and-hold repeat. +- **Live typing** straight into the TV's search box. +- Real channel icons, a channel filter, "on now", and TV inputs by name. +- Add a Roku by IP, scan the whole network, forget old devices. +- Mini remote that stays on top. +- Help: troubleshooting steps, shortcut list, copy diagnostics. +- Report a problem / Suggest an idea — opens a pre-filled GitHub issue. +- MoveWeight.com and source code links in the footer. +- A one-time invitation to rate the app after ten minutes of use. +- CI (Linux + Windows) and a workflow that builds installers for all three OSes, + packs the Microsoft Store MSIX, publishes GitHub releases and can submit to + the Store. + +### Removed +- The volume slider. Rokus can't report their volume, so the slider only ever + guessed; press-and-hold − / + is accurate. diff --git a/README.md b/README.md index c7e6005..201f9fd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,19 @@ # Roku Control -A desktop remote for Roku TVs and streaming devices. Every button on the -physical remote, plus channel launching and instant device switching, in a -native window on Linux, macOS or Windows — one Tauri (Rust + React) codebase, -a binary around 5 MB. +A free desktop remote for Roku TVs and streaming players. Every button on the +physical remote, your keyboard as a remote, live typing into search boxes, +channel launching with real channel art, and a Turn on button that can wake a +TV from standby — in a native window on Windows, macOS or Linux. One Tauri +(Rust + React) codebase, a binary around 5 MB. -Your Rokus are found automatically: the app sends an SSDP discovery broadcast -on launch and lists every Roku that answers, and you can add one by IP if your -network blocks multicast. Everything happens on your LAN — the app never -touches the internet. +No ads, no accounts, no tracking. The app only ever talks to the Rokus on your +own network. ## Get it - **Microsoft Store** — search for *Roku Control* (Windows). +- **Installers** — Windows, macOS and Linux downloads are on the + [latest release](https://github.com/BlizzHacker/RokuControl/releases/latest). - **From source** — any OS: ```bash @@ -37,6 +38,64 @@ Installers land in `src-tauri/target/release/bundle/`. > script builds the web frontend first. A bare cargo build packages an empty > frontend and you get a blank window that looks like a broken app. +## What it does + +- **Finds your Rokus by itself** on launch — SSDP on every network adapter, and + a direct scan of your subnet when multicast is blocked. Add one by IP if you + need to. Devices are remembered by serial number, so when your router hands a + Roku a new address the app follows it. +- **Honest status** — the dot next to your Roku reflects a real check every few + seconds: connected, screen off, not responding, or Limited mode. +- **Turn on from off** — sends `PowerOn`, and if the TV has dropped off the + network, Wake-on-LAN to every MAC address it has seen for it, then keeps + knocking until the TV answers. +- **Your keyboard is a remote** (see below), with press-and-hold repeat on the + arrows and volume. +- **Live typing** — type into the app and the letters appear in the TV's + search box as you type, including accents and symbols. Fix a typo anywhere + and the TV follows. +- **Channels** with their real icons, a filter, and "on now"; **TV inputs** by + the names you gave them on the TV. +- **Mini remote** (📌) — a compact remote that stays on top of other windows. +- **Straight-to-GitHub feedback** — *Report a problem* and *Suggest an idea* + open a pre-filled GitHub issue (Roku model and software version only — never + IPs, names or serials). Nothing is sent until you press Submit. + +## Keyboard shortcuts + +| Key | Does | +|-----|------| +| ↑ ↓ ← → | Move | +| Enter | OK | +| Backspace / Esc | Back | +| H | Home | +| Space or P | Play / Pause | +| `,` / `.` (or `[` / `]`) | Rewind / Fast forward | +| R | Instant replay | +| I or `*` | Options | +| S | Search | +| `+` / `−` / M | Volume up / down / mute | +| Page Up / Page Down | Channel up / down | +| `/` or T | Start typing on the TV (Esc to stop) | +| `?` | Show all shortcuts | + +## If something doesn't work + +**Only volume works, or the app says your Roku is blocking it.** Roku OS 14.1 +and later ship in *Limited* mode. On the Roku open +**Settings › System › Advanced system settings › Control by mobile apps** and +choose **Enabled** (or set **Network access** to **Permissive**). + +**Turn on doesn't wake the TV.** A Roku TV that is fully off switches its +network off too. Turn on **Settings › System › Power › Fast TV start**. + +**No Roku found.** Put the computer on the same network as the Roku (not a +guest network). On Windows, set that network to *Private*. You can always add a +Roku by IP — it's under **Settings › Network › About** on the Roku. + +Still stuck? Use **Report a problem** in the app, or +[open an issue](https://github.com/BlizzHacker/RokuControl/issues/new/choose). + ## How it works Roku devices expose the @@ -49,14 +108,23 @@ then talks straight to each Roku. |-------|------| | Desktop shell | Tauri 2 (Rust) | | UI | React 19 + Vite | -| Networking | reqwest (async HTTP) | -| Discovery | SSDP multicast (UDP) | +| Networking | reqwest (plain HTTP, no proxy) | +| Discovery | SSDP multicast + subnet probe | +| Wake | Wake-on-LAN magic packets | + +Run the Rust tests (they include a fake Roku to talk to) with +`cd src-tauri && cargo test`. ## Privacy -Roku Control does not collect, store, or transmit any personal data. All +Roku Control does not collect or transmit any personal data. All communication is strictly between the app and Roku devices on your local -network. No analytics, no telemetry, no tracking, no internet dependency. +network. No analytics, no telemetry, no tracking. See [privacy.txt](privacy.txt). + +## Credits + +Made ad-free by [MoveWeight.com](https://moveweight.com). If the app helps +you, a rating in the Microsoft Store or a star here is how other people find it. ## License diff --git a/STORE-LISTING.txt b/STORE-LISTING.txt index c4de506..260e2ef 100644 --- a/STORE-LISTING.txt +++ b/STORE-LISTING.txt @@ -2,53 +2,61 @@ --- DESCRIPTION --- -Control any Roku TV or device on your local network with this beautiful, free desktop remote. No ads, no tracking, no internet required. +Control any Roku TV or streaming player on your network with this free desktop remote. No ads, no accounts, no tracking — it only ever talks to the Rokus in your home. -FULL REMOTE CONTROL -• D-pad with directional arrows and OK/Select button -• Play/Pause, Rewind, Fast Forward, and Instant Replay -• Volume slider, Mute, Channel Up/Down -• HDMI input switcher — HDMI 1-4, Antenna, AV -• Home, Back, Info, and Search buttons -• Wake and Sleep power buttons -• Find My Remote — triggers your physical remote to beep +FINDS YOUR ROKU BY ITSELF +Open the app and it searches your network — on every adapter, with a direct scan if your router blocks discovery. It remembers each Roku by serial number, so it keeps working when your router gives the TV a new address. Or add one by IP in seconds. -APP LAUNCHER -Browse and launch all your installed channels — Netflix, YouTube, Plex, Disney+, Hulu, Prime Video, HBO Max, Spotify, and more. One click to switch apps. +TURN YOUR TV ON FROM STANDBY +One click sends Power On. If the TV has dropped off the network, the app sends a Wake-on-LAN signal and keeps trying. (For the most reliable wake-up, turn on Fast TV start on your Roku TV — the app shows you how.) -KEYBOARD INPUT -Type directly into search fields on your TV. Perfect for searching movies on Plex, YouTube, Netflix, or any Roku app. +YOUR KEYBOARD IS A REMOTE +Arrow keys move, Enter is OK, Backspace is Back, Space plays and pauses, + and − change the volume. Hold a key to keep scrolling. Press ? to see every shortcut. -MULTI-DEVICE SUPPORT -Control every Roku in your home. Switch between TVs with a single click. Auto-discovers Roku devices on your network via SSDP — no manual setup needed. +TYPE STRAIGHT INTO YOUR TV +Search Netflix, YouTube, Plex or any app by typing on your real keyboard — letters show up on the TV as you type, accents and symbols included. -WIDGET MODE -Always-on-top floating mini remote. Keep it hovering over your other windows for quick access. +EVERY BUTTON ON THE REMOTE +D-pad and OK, Back, Home, Options, Search, Play/Pause, Rewind, Fast Forward, Instant Replay, Volume and Mute, Channel Up/Down, TV inputs by the names you gave them, and Find My Remote. -DARK THEME -Beautiful purple-accented dark UI designed for desktop. Frameless window with drag-to-move. +YOUR CHANNELS, ONE CLICK AWAY +All your channels with their real icons, a quick filter, and what's on now. -PRIVATE & OFFLINE -No accounts, no analytics, no tracking. Communicates directly with your Roku devices over your local network. Zero internet dependency. +MINI REMOTE +A compact remote that stays on top of your other windows. + +TELLS YOU WHAT'S WRONG — AND HOW TO FIX IT +A live status light shows whether your Roku is connected, asleep or unreachable. If your Roku is blocking remote control (Roku's "Limited" mode), the app shows you exactly which setting to change. + +FREE, OPEN SOURCE, PRIVATE +Made ad-free by MoveWeight.com. Report a problem or suggest an idea from inside the app. The source code is on GitHub. + +--- WHAT'S NEW (2.1.0) --- + +• Finds your Roku automatically when you open the app +• Fixes "only volume works": the app now spots Roku's Limited mode and shows the setting to change +• Turn on your TV from standby (Power On + Wake-on-LAN) +• Full keyboard control and live typing into search boxes +• Real channel icons, TV inputs by name, mini always-on-top remote +• Clear messages when something's wrong, and a Help screen +• Report problems and suggest ideas straight from the app --- FEATURES (short list) --- -• Full D-pad remote control -• Play/Pause, Rewind, Fast Forward -• Volume slider with mute -• HDMI input switcher -• App launcher with channel icons -• Keyboard text input for search -• Instant Replay and Find My Remote -• Wake and sleep power controls -• Multi-device support with auto-discovery -• Widget mode — always-on-top mini remote -• Dark theme with custom window frame -• 100% offline — no internet needed +• Automatic Roku discovery — follows your TV when its IP changes +• Turn on from standby (Power On + Wake-on-LAN) +• Keyboard shortcuts for every button +• Live typing into search boxes +• Full D-pad, playback, volume and channel controls +• TV inputs and channel launcher with real icons +• Live connection status with fix-it help +• Mini remote that stays on top +• Report problems and ideas from the app +• No ads, no accounts, no tracking — free and open source --- KEYWORDS --- -roku remote, roku control, tv remote, roku tv, hisense remote, onn remote, roku app, desktop remote, streaming remote, roku controller, plex remote, smart tv remote, roku widget +roku remote, roku control, tv remote, roku tv, hisense remote, tcl remote, onn remote, roku keyboard, desktop remote, roku controller, plex remote, smart tv remote --- CATEGORY --- @@ -56,7 +64,7 @@ Utilities & tools > Remote & control --- PRIVACY POLICY URL --- -https://gist.github.com/BlizzHacker/f71942af06897dabd2ba582863977f8e +https://github.com/BlizzHacker/RokuControl/blob/master/privacy.txt --- PRICE --- @@ -66,5 +74,6 @@ Free Take these with Win+Shift+S while the app is open: 1. Main remote with D-pad and all buttons visible -2. App launcher grid open showing channel icons -3. Widget mode floating over the desktop +2. Channels grid with channel icons and "On now" +3. Mini remote floating over the desktop +4. Help screen showing keyboard shortcuts diff --git a/STORE.md b/STORE.md index 1f30711..2c16d30 100644 --- a/STORE.md +++ b/STORE.md @@ -1,74 +1,65 @@ -# Windows Store Submission Checklist +# Publishing to the Microsoft Store + +Roku Control is an MSIX product in Partner Center (Store ID `9MTNNGZKJ1WK`, +package identity `MOVEWEIGHT.RokuControl`). The *Build installers* workflow +builds the app and packs the Store `.msix` with the Windows SDK's `makeappx` +from `msix/AppxManifest.xml`. Partner Center signs the package on upload. + +## Before every release + +1. Bump the version in `package.json`, `src-tauri/Cargo.toml`, + `src-tauri/tauri.conf.json` and `msix/AppxManifest.xml` (`2.1.0` → `2.1.0.0`). + The Store only accepts a version higher than the one it has. +2. Update `CHANGELOG.md` and the "What's new" block in `STORE-LISTING.txt`. + +## Build and publish the release + +Actions → **Build installers** → **Run workflow** on `master`: + +- **release:** the tag to publish, e.g. `v2.1.0`. The installers and the + `.msix` are attached to a GitHub release with that tag. +- **store:** tick it to also submit the `.msix` to the Store (see below). + +## Submit to the Store + +### Option A — automatically from GitHub (one-time setup) + +1. Partner Center → **Account settings** → **User management** → + **Azure AD applications** → **Add Azure AD application** (create a new one), + role **Manager**. Create a **key** for it. +2. Note the **Tenant ID**, **Client ID** and the key (client secret), and your + **Seller ID** (Account settings → Legal info → Developer tab). +3. GitHub → repository **Settings** → **Secrets and variables** → **Actions**, + add: `PARTNER_CENTER_TENANT_ID`, `PARTNER_CENTER_CLIENT_ID`, + `PARTNER_CENTER_CLIENT_SECRET`, `PARTNER_CENTER_SELLER_ID`. + +From then on, running the workflow with **store** ticked uploads the package +and submits it for certification using Microsoft's `msstore` CLI. + +### Option B — by hand + +1. Download `RokuControl__x64.msix` from the GitHub release. +2. Partner Center → Apps and games → **Roku Control** → **Start update**. +3. **Packages:** upload the `.msix`; remove the previous version's package. +4. **Store listings:** paste the description and "What's new" from + `STORE-LISTING.txt`; refresh screenshots if the UI changed. +5. **Properties:** privacy policy URL + `https://github.com/BlizzHacker/RokuControl/blob/master/privacy.txt`. +6. **Submit for certification.** + +## Certification notes + +- Capabilities: `runFullTrust` (restricted — it's a desktop app; the + justification is "Tauri desktop app, runs as a full-trust Win32 process"), + `privateNetworkClientServer` (talking to Rokus on the LAN) and + `internetClient`. +- Common rejections: the app must launch on a clean machine, the privacy + policy URL must load, and the listing must only describe features the app + has. +- Screenshots (1366×768 PNG, at least 1): main remote, channels grid, mini + remote, help/shortcuts. ## Cost -- **$19** — one-time Microsoft Partner Center individual registration -- **$0** — publishing free apps costs nothing -- **$0** — no ongoing fees, ever - -## Step-by-step - -### 1. Register Developer Account -- Go to https://partner.microsoft.com -- Sign in with Microsoft account -- Pay $19 (one-time) -- Wait for approval (usually instant) - -### 2. Reserve App Name -- Partner Center → Apps & games → New product → MSIX or PWA app -- Reserve "Roku Control" - -### 3. Build the App -```bash -npm run tauri:build:win -``` -Output: `src-tauri/target/release/bundle/msi/` - -### 4. Convert MSI to MSIX -- Install MSIX Packaging Tool from Microsoft Store (free) -- Open it → "Application package" → pick the Roku Control .msi -- The tool creates a signed .msix - -### 5. Submit in Partner Center - -**App identity:** (from your reserved name) -``` -Publisher: CN= -Package identity: -``` - -**Store listing:** -``` -Name: Roku Control -Description: Control any Roku TV or device on your local network. -Beautiful dark theme, widget mode, app launcher. No ads. No tracking. Free. -Category: Utilities & tools -Subcategory: Remote & control -Price: Free -``` - -**Privacy policy URL:** -Host `privacy.txt` at any URL (GitHub Pages, your domain, etc.) -Example: `https://moveweight.com/roku-control-privacy` - -**Age rating:** -- No violence, no gambling, no user-generated content -- Answer the questionnaire honestly → will get "E for Everyone" or equivalent - -**Screenshots needed (1366×768 PNG):** -1. Main remote with D-pad -2. App launcher grid -3. Widget mode floating on desktop - -**Package:** Upload the .msix from step 4. - -### 6. Certification -Microsoft runs automated tests (~3 hours). Common rejection reasons: -- App crashes on launch → test on clean machine -- Missing privacy policy → host it publicly -- Age rating missing → complete questionnaire -- Wrong capabilities declared → we only need `internetClientServer` + `privateNetworkClientServer` - -## Free forever -- No Microsoft cut for free apps -- No renewal fee for the dev account (one-time $19) -- The app has no backend costs — it's LAN-only + +$19 one-time Partner Center registration for individuals. Publishing free apps +costs nothing, and the app has no backend. diff --git a/docs/index.html b/docs/index.html index a74d487..2b86835 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,19 +1 @@ -Privacy Policy for Roku Control - -Last updated: July 2026 - -Roku Control does not collect, store, share, or transmit any personal information, usage data, or telemetry. - -The app communicates exclusively with Roku devices on your local network (LAN) using the Roku External Control Protocol (ECP) on port 8060. No data is sent to any external server. No internet connection is required or used. - -The app discovers Roku devices via SSDP multicast on your local network. Device IP addresses and names are stored locally on your device for convenience and are never transmitted elsewhere. - -This app: -- Does NOT collect personal information -- Does NOT use analytics or crash reporting -- Does NOT contain advertisements -- Does NOT require an account or login -- Does NOT access the internet except to communicate with Roku devices on your LAN -- Only stores device IP addresses locally for quick reconnection - -If you have questions, contact: me@moveweight.com +Roku Control Privacy Policy

Privacy Policy for Roku Control

Last updated: September 2026

Roku Control does not collect, store on any server, share, or transmit any personal information, usage data, or telemetry.

The app communicates exclusively with Roku devices on your local network (LAN) using the Roku External Control Protocol (ECP) on port 8060. It finds them with SSDP multicast and, if that gets no answer, by checking your local subnet for that port. To turn on a TV that is in standby it may send Wake-on-LAN packets, which also stay on your local network. No data is sent to any external server.

Stored only on your computer, and never transmitted: the names, IP addresses, models and network hardware (MAC) addresses of your Rokus (so the app can reconnect and wake them), your window preference, and two counters — how long the app has been used and how many commands it has sent — used only to show a one-time invitation to rate the app.

Links in the app (MoveWeight.com, Source code, Report a problem, Suggest an idea, Rate) open your web browser or the Microsoft Store, and only when you click them. "Report a problem" pre-fills a GitHub issue with the app version, your operating system, and your Roku's model, software version and settings mode — never its IP address, name, serial number or MAC address. Nothing is sent unless you choose to submit it on GitHub.

This app:

  • Does NOT collect personal information
  • Does NOT use analytics or crash reporting
  • Does NOT contain advertisements
  • Does NOT require an account or login
  • Does NOT access the internet on its own — only Roku devices on your LAN, plus pages you choose to open
  • Only stores device details and usage counters locally, on your computer

Contact: me@moveweight.com

diff --git a/docs/privacy.html b/docs/privacy.html index 499c3ee..2b86835 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -1 +1 @@ -Roku Control Privacy Policy

Privacy Policy for Roku Control

Last updated: July 2026

Roku Control does not collect, store, share, or transmit any personal information, usage data, or telemetry.

The app communicates exclusively with Roku devices on your local network (LAN) using the Roku External Control Protocol (ECP) on port 8060. No data is sent to any external server. No internet connection is required or used.

This app: does not collect personal information, does not use analytics or crash reporting, does not contain advertisements, does not require an account or login, does not access the internet except to communicate with Roku devices on your LAN.

Contact: me@moveweight.com

+Roku Control Privacy Policy

Privacy Policy for Roku Control

Last updated: September 2026

Roku Control does not collect, store on any server, share, or transmit any personal information, usage data, or telemetry.

The app communicates exclusively with Roku devices on your local network (LAN) using the Roku External Control Protocol (ECP) on port 8060. It finds them with SSDP multicast and, if that gets no answer, by checking your local subnet for that port. To turn on a TV that is in standby it may send Wake-on-LAN packets, which also stay on your local network. No data is sent to any external server.

Stored only on your computer, and never transmitted: the names, IP addresses, models and network hardware (MAC) addresses of your Rokus (so the app can reconnect and wake them), your window preference, and two counters — how long the app has been used and how many commands it has sent — used only to show a one-time invitation to rate the app.

Links in the app (MoveWeight.com, Source code, Report a problem, Suggest an idea, Rate) open your web browser or the Microsoft Store, and only when you click them. "Report a problem" pre-fills a GitHub issue with the app version, your operating system, and your Roku's model, software version and settings mode — never its IP address, name, serial number or MAC address. Nothing is sent unless you choose to submit it on GitHub.

This app:

  • Does NOT collect personal information
  • Does NOT use analytics or crash reporting
  • Does NOT contain advertisements
  • Does NOT require an account or login
  • Does NOT access the internet on its own — only Roku devices on your LAN, plus pages you choose to open
  • Only stores device details and usage counters locally, on your computer

Contact: me@moveweight.com

diff --git a/index.html b/index.html index 6544def..0e7427b 100644 --- a/index.html +++ b/index.html @@ -4,169 +4,6 @@ Roku Control -
diff --git a/msix/AppxManifest.xml b/msix/AppxManifest.xml index 1f2d644..53bc84b 100644 --- a/msix/AppxManifest.xml +++ b/msix/AppxManifest.xml @@ -2,7 +2,7 @@ - + Roku Control MOVE WEIGHT diff --git a/package-lock.json b/package-lock.json index 4e5d69e..1b2de48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roku-control", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roku-control", - "version": "2.0.0", + "version": "2.1.0", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/cli": "^2.11.4", diff --git a/package.json b/package.json index ebe495e..54dd686 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "roku-control", "private": true, - "version": "2.0.0", + "version": "2.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/privacy.txt b/privacy.txt index a74d487..c9b9d96 100644 --- a/privacy.txt +++ b/privacy.txt @@ -1,19 +1,21 @@ Privacy Policy for Roku Control -Last updated: July 2026 +Last updated: September 2026 -Roku Control does not collect, store, share, or transmit any personal information, usage data, or telemetry. +Roku Control does not collect, store on any server, share, or transmit any personal information, usage data, or telemetry. -The app communicates exclusively with Roku devices on your local network (LAN) using the Roku External Control Protocol (ECP) on port 8060. No data is sent to any external server. No internet connection is required or used. +The app communicates exclusively with Roku devices on your local network (LAN) using the Roku External Control Protocol (ECP) on port 8060. It finds them with SSDP multicast and, if that gets no answer, by checking your local subnet for that port. To turn on a TV that is in standby it may send Wake-on-LAN packets, which also stay on your local network. No data is sent to any external server. -The app discovers Roku devices via SSDP multicast on your local network. Device IP addresses and names are stored locally on your device for convenience and are never transmitted elsewhere. +Stored only on your computer, and never transmitted: the names, IP addresses, models and network hardware (MAC) addresses of your Rokus (so the app can reconnect and wake them), your window preference, and two counters — how long the app has been used and how many commands it has sent — used only to show a one-time invitation to rate the app. + +Links in the app (MoveWeight.com, Source code, Report a problem, Suggest an idea, Rate) open your web browser or the Microsoft Store, and only when you click them. "Report a problem" pre-fills a GitHub issue with the app version, your operating system, and your Roku's model, software version and settings mode — never its IP address, name, serial number or MAC address. Nothing is sent unless you choose to submit it on GitHub. This app: - Does NOT collect personal information - Does NOT use analytics or crash reporting - Does NOT contain advertisements - Does NOT require an account or login -- Does NOT access the internet except to communicate with Roku devices on your LAN -- Only stores device IP addresses locally for quick reconnection +- Does NOT access the internet on its own — only Roku devices on your LAN, plus pages you choose to open +- Only stores device details and usage counters locally, on your computer If you have questions, contact: me@moveweight.com diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 73911d3..275e88c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -42,60 +42,141 @@ dependencies = [ ] [[package]] -name = "anstream" -version = "1.0.0" +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-broadcast" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", ] [[package]] -name = "anstyle" -version = "1.0.14" +name = "async-channel" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] [[package]] -name = "anstyle-parse" -version = "1.0.0" +name = "async-executor" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ - "utf8parse", + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", ] [[package]] -name = "anstyle-query" -version = "1.1.5" +name = "async-io" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", "windows-sys 0.61.2", ] [[package]] -name = "anstyle-wincon" -version = "3.0.11" +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "anstyle", - "once_cell_polyfill", + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", "windows-sys 0.61.2", ] [[package]] -name = "anyhow" -version = "1.0.103" +name = "async-task" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] [[package]] name = "atk" @@ -192,6 +273,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -371,12 +465,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -388,23 +476,22 @@ dependencies = [ ] [[package]] -name = "cookie" -version = "0.18.1" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ - "time", - "version_check", + "crossbeam-utils", ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "core-foundation-sys", - "libc", + "time", + "version_check", ] [[package]] @@ -430,9 +517,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.0", - "core-foundation 0.10.1", + "core-foundation", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "libc", ] @@ -443,7 +530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.0", - "core-foundation 0.10.1", + "core-foundation", "libc", ] @@ -574,37 +661,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "deranged" version = "0.5.8" @@ -799,35 +855,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] -name = "env_filter" -version = "2.0.0" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ - "log", - "regex", + "enumflags2_derive", + "serde", ] [[package]] -name = "env_logger" -version = "0.11.11" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -857,6 +908,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -910,15 +981,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -926,7 +988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -940,12 +1002,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -993,6 +1049,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1323,25 +1392,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -1366,6 +1416,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1431,7 +1487,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1442,37 +1497,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1491,11 +1515,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1641,6 +1663,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1698,12 +1730,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itoa" version = "1.0.18" @@ -1733,31 +1759,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "jiff" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" -dependencies = [ - "defmt", - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] - -[[package]] -name = "jiff-static" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "jni" version = "0.21.1" @@ -2005,23 +2006,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2212,7 +2196,6 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", - "libc", "objc2", "objc2-core-foundation", ] @@ -2291,12 +2274,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "open" version = "5.3.6" @@ -2309,67 +2286,24 @@ dependencies = [ ] [[package]] -name = "openssl" -version = "0.10.81" +name = "option-ext" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "openssl-macros", - "openssl-sys", -] +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "futures-core", + "pin-project-lite", ] [[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "pango" -version = "0.18.3" +name = "pango" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" dependencies = [ @@ -2392,6 +2326,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2480,6 +2420,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2494,7 +2445,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.41.0", + "quick-xml", "serde", "time", ] @@ -2526,18 +2477,17 @@ dependencies = [ ] [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "polling" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "portable-atomic", + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -2623,16 +2573,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -2746,29 +2686,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", - "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tower", "tower-http", "tower-service", @@ -2812,59 +2744,20 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "roku-control" -version = "2.0.0" +version = "2.1.0" dependencies = [ - "env_logger", - "log", - "quick-xml 0.37.5", + "base64 0.22.1", + "if-addrs", "reqwest 0.12.28", "serde", "serde_json", + "socket2", "tauri", "tauri-build", - "tauri-plugin-dialog", + "tauri-plugin-opener", "tauri-plugin-process", - "tauri-plugin-shell", "tokio", ] @@ -2896,39 +2789,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -2950,15 +2810,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "schemars" version = "0.8.22" @@ -3016,29 +2867,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "selectors" version = "0.36.1" @@ -3249,44 +3077,12 @@ dependencies = [ "digest", ] -[[package]] -name = "shared_child" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" -dependencies = [ - "libc", - "sigchld", - "windows-sys 0.60.2", -] - [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3415,12 +3211,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.7" @@ -3453,6 +3243,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3473,27 +3274,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "system-deps" version = "6.2.2" @@ -3515,7 +3295,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.0", "block2", - "core-foundation 0.10.1", + "core-foundation", "core-graphics", "crossbeam-channel", "dbus", @@ -3694,45 +3474,25 @@ dependencies = [ ] [[package]] -name = "tauri-plugin-dialog" -version = "2.7.1" +name = "tauri-plugin-opener" +version = "2.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.18", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" -dependencies = [ - "anyhow", "dunce", "glob", - "log", + "objc2-app-kit", "objc2-foundation", - "percent-encoding", + "open", "schemars 0.8.22", "serde", "serde_json", - "serde_repr", "tauri", "tauri-plugin", - "tauri-utils", "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", "url", + "windows", + "zbus", ] [[package]] @@ -3745,27 +3505,6 @@ dependencies = [ "tauri-plugin", ] -[[package]] -name = "tauri-plugin-shell" -version = "2.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" -dependencies = [ - "encoding_rs", - "log", - "open", - "os_pipe", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "shared_child", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "tokio", -] - [[package]] name = "tauri-runtime" version = "2.11.3" @@ -4011,26 +3750,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -4216,9 +3935,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4268,6 +3999,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4321,12 +4063,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -4358,12 +4094,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.23.4" @@ -4376,12 +4106,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" @@ -4767,17 +4491,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -4823,15 +4536,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -4841,15 +4545,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4883,30 +4578,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -4937,12 +4615,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4955,12 +4627,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4973,24 +4639,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5003,12 +4657,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5021,12 +4669,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5039,12 +4681,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5057,12 +4693,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -5197,6 +4827,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5218,12 +4918,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" @@ -5262,3 +4956,44 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.3", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ffb1822..6df8227 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "roku-control" -version = "2.0.0" +version = "2.1.0" description = "Roku Remote Control — cross-platform desktop app" authors = ["Wade"] license = "MIT" @@ -16,14 +16,14 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon"] } -tauri-plugin-shell = "2" -tauri-plugin-dialog = "2" +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", features = ["json"] } -quick-xml = { version = "0.37", features = ["serialize"] } -log = "0.4" -env_logger = "0.11" +# Plain HTTP to devices on the LAN only: no TLS stack, no proxy detection. +reqwest = { version = "0.12", default-features = false } +socket2 = "0.6" +if-addrs = "0.13" +base64 = "0.22" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..206c3b3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,11 +1,18 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "enables the default permissions", + "description": "Window controls for the frameless main window, and quitting the app", "windows": [ "main" ], "permissions": [ - "core:default" + "core:default", + "core:window:allow-start-dragging", + "core:window:allow-minimize", + "core:window:allow-close", + "core:window:allow-set-always-on-top", + "core:window:allow-set-size", + "core:window:allow-set-min-size", + "process:allow-exit" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 66f3401..2881f1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,57 +1,114 @@ mod roku; -use roku::{RokuDevice, RokuApp}; -use tauri::{Manager, State}; -use std::sync::Mutex; +use roku::{EcpError, ErrorKind, PowerOnReport, RokuApp, RokuDevice}; +use tauri::Manager; +use tauri_plugin_opener::OpenerExt; -struct AppState { - devices: Mutex>, +type CmdResult = Result; + +#[tauri::command] +async fn discover(deep: Option) -> Vec { + roku::discover(deep.unwrap_or(false)).await } +/// Also serves as "add by IP" and the periodic health check. #[tauri::command] -async fn discover(state: State<'_, AppState>) -> Result, String> { - let devices = roku::discover_devices().await?; - if let Ok(mut stored) = state.devices.lock() { - *stored = devices.clone(); - } - Ok(devices) +async fn device_info(ip: String) -> CmdResult { + let host = roku::normalize_host(&ip)?; + roku::device_info(&host).await +} + +#[tauri::command] +async fn get_apps(ip: String) -> CmdResult> { + roku::apps(&roku::normalize_host(&ip)?).await +} + +#[tauri::command] +async fn active_app(ip: String) -> CmdResult> { + roku::active_app(&roku::normalize_host(&ip)?).await +} + +#[tauri::command] +async fn app_icon(ip: String, app_id: String) -> CmdResult { + roku::app_icon(&roku::normalize_host(&ip)?, &app_id).await +} + +#[tauri::command] +async fn keypress(ip: String, key: String) -> CmdResult<()> { + roku::keypress(&roku::normalize_host(&ip)?, &key).await } #[tauri::command] -async fn get_apps(ip: String) -> Result, String> { - roku::get_apps(&ip).await +async fn launch(ip: String, app_id: String) -> CmdResult<()> { + roku::launch(&roku::normalize_host(&ip)?, &app_id).await } #[tauri::command] -async fn keypress(ip: String, key: String) -> Result<(), String> { - roku::send_keypress(&ip, &key).await +async fn send_text(ip: String, text: String) -> CmdResult<()> { + roku::send_text(&roku::normalize_host(&ip)?, &text).await } #[tauri::command] -async fn launch(ip: String, app_id: String) -> Result<(), String> { - roku::launch_app(&ip, &app_id).await +async fn power_on(ip: String, macs: Vec) -> CmdResult { + roku::power_on(&roku::normalize_host(&ip)?, &macs).await } #[tauri::command] -async fn send_text(ip: String, text: String) -> Result<(), String> { - roku::send_text(&ip, &text).await +async fn lookup_mac(ip: String) -> Option { + let host = roku::normalize_host(&ip).ok()?; + roku::arp_lookup(&host).await +} + +/// Links the UI may open in the user's browser or the Microsoft Store. +const LINK_PREFIXES: &[&str] = &[ + "https://moveweight.com", + "https://www.moveweight.com", + "https://github.com/BlizzHacker/RokuControl", + "https://apps.microsoft.com/detail/9mtnngzkj1wk", + "ms-windows-store://review/?ProductId=9MTNNGZKJ1WK", + "ms-windows-store://pdp/?ProductId=9MTNNGZKJ1WK", + "https://support.roku.com/", +]; + +fn is_allowed_link(url: &str) -> bool { + if url.len() > 8000 || url.chars().any(|c| c.is_whitespace() || c.is_control()) { + return false; + } + LINK_PREFIXES.iter().any(|prefix| { + url.strip_prefix(prefix).is_some_and(|rest| { + // Stop "https://moveweight.com.evil.example" slipping through. + prefix.ends_with('/') || rest.is_empty() || rest.starts_with(['/', '?', '#', '&']) + }) + }) +} + +#[tauri::command] +fn open_link(app: tauri::AppHandle, url: String) -> CmdResult<()> { + if !is_allowed_link(&url) { + return Err(EcpError::new(ErrorKind::Invalid, "That link isn't allowed.")); + } + app.opener() + .open_url(url, None::<&str>) + .map_err(|e| EcpError::new(ErrorKind::Other, format!("Couldn't open the link: {e}"))) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) - .manage(AppState { - devices: Mutex::new(Vec::new()), - }) .invoke_handler(tauri::generate_handler![ discover, + device_info, get_apps, + active_app, + app_icon, keypress, launch, send_text, + power_on, + lookup_mac, + open_link, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { .. } = event { @@ -63,3 +120,33 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running Roku Control"); } + +#[cfg(test)] +mod tests { + use super::is_allowed_link; + + #[test] + fn only_our_links_open() { + for ok in [ + "https://moveweight.com", + "https://moveweight.com/", + "https://github.com/BlizzHacker/RokuControl", + "https://github.com/BlizzHacker/RokuControl/issues/new?template=bug_report.yml&title=x%20y", + "ms-windows-store://review/?ProductId=9MTNNGZKJ1WK", + "https://support.roku.com/article/123", + ] { + assert!(is_allowed_link(ok), "{ok}"); + } + for bad in [ + "https://moveweight.com.evil.example", + "https://github.com/BlizzHacker/RokuControlEvil", + "https://github.com/someone/else", + "file:///C:/Windows/System32/calc.exe", + "https://moveweight.com/ x", + "javascript:alert(1)", + "", + ] { + assert!(!is_allowed_link(bad), "{bad}"); + } + } +} diff --git a/src-tauri/src/roku.rs b/src-tauri/src/roku.rs index f98d615..3e2b491 100644 --- a/src-tauri/src/roku.rs +++ b/src-tauri/src/roku.rs @@ -1,240 +1,1211 @@ -use std::net::{SocketAddr, UdpSocket}; +//! Roku External Control Protocol (ECP) client: discovery, device queries, +//! key presses, text entry and waking a TV that is switched off. +//! +//! Every request goes straight to the Roku on the LAN (port 8060). Nothing here +//! talks to the internet. + +use serde::Serialize; +use std::collections::{BTreeSet, HashSet}; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::{Arc, OnceLock}; use std::time::Duration; -use serde::{Deserialize, Serialize}; +use tokio::net::{TcpStream, UdpSocket}; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; +use tokio::time::Instant; + +pub const ECP_PORT: u16 = 8060; +const SSDP_ADDR: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250); +const SSDP_PORT: u16 = 1900; +const M_SEARCH: &str = "M-SEARCH * HTTP/1.1\r\n\ + HOST: 239.255.255.250:1900\r\n\ + MAN: \"ssdp:discover\"\r\n\ + ST: roku:ecp\r\n\ + MX: 2\r\n\r\n"; + +/// Longest text we will type in one go; each character is its own request. +const MAX_TEXT_CHARS: usize = 500; + +pub const LIMITED_HELP: &str = "Your Roku is blocking remote control from this computer \ +(it is in Limited mode, which only allows volume). On the Roku, open Settings › System › \ +Advanced system settings › Control by mobile apps and set it to Enabled — or set Network \ +access to Permissive."; + +const FAST_START_HELP: &str = "To turn the TV on from this app while it is off, enable \ +Fast TV start on the TV: Settings › System › Power › Fast TV start."; + +// ─── Errors ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ErrorKind { + /// Nothing answered: the device is off, asleep, on another network or has a new IP. + Unreachable, + /// The Roku answered 403 — "Control by mobile apps" is Limited or Disabled. + Limited, + /// The Roku answered 404 — this model has no such key, app or input. + Unsupported, + /// Malformed input from the UI (address, key or app id). + Invalid, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct EcpError { + pub kind: ErrorKind, + pub message: String, +} + +impl EcpError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { kind, message: message.into() } + } + fn invalid(message: impl Into) -> Self { + Self::new(ErrorKind::Invalid, message) + } +} + +impl std::fmt::Display for EcpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// Map a non-success HTTP status from the Roku to an error the user can act on. +pub fn status_error(status: u16, body: &str) -> EcpError { + match status { + 403 => EcpError::new(ErrorKind::Limited, LIMITED_HELP), + 404 => EcpError::new( + ErrorKind::Unsupported, + "This Roku doesn't support that button or app.", + ), + _ => { + let detail: String = body.trim().chars().take(120).collect(); + let message = if detail.is_empty() { + format!("The Roku answered with HTTP {status}.") + } else { + format!("The Roku answered with HTTP {status}: {detail}") + }; + EcpError::new(ErrorKind::Other, message) + } + } +} + +fn transport_error(host: &str, e: &reqwest::Error) -> EcpError { + if e.is_timeout() || e.is_connect() { + EcpError::new( + ErrorKind::Unreachable, + format!( + "Can't reach the Roku at {host}. Check that it's on and connected to the \ + same network as this computer." + ), + ) + } else { + EcpError::new(ErrorKind::Other, format!("Network error talking to {host}: {e}")) + } +} + +// ─── Input validation ────────────────────────────────────────────────────── + +/// Accept what people paste — `192.168.1.20`, `http://192.168.1.20:8060/`, +/// `roku-den.local` — and reduce it to a bare IPv4 address or hostname. +pub fn normalize_host(input: &str) -> Result { + let mut s = input.trim(); + for scheme in ["http://", "https://"] { + if s.get(..scheme.len()).is_some_and(|p| p.eq_ignore_ascii_case(scheme)) { + s = &s[scheme.len()..]; + } + } + let s = s.split(['/', '?', '#']).next().unwrap_or(""); + let s = s.split(':').next().unwrap_or("").trim(); + + if s.is_empty() { + return Err(EcpError::invalid( + "Enter your Roku's IP address, like 192.168.1.20. On the Roku it's under \ + Settings › Network › About.", + )); + } + if let Ok(ip) = s.parse::() { + if ip.is_unspecified() || ip.is_broadcast() || ip.is_multicast() { + return Err(EcpError::invalid(format!("{s} isn't a device address."))); + } + return Ok(ip.to_string()); + } + if s.chars().all(|c| c.is_ascii_digit() || c == '.') { + return Err(EcpError::invalid(format!("{s} isn't a valid IP address."))); + } + let valid_hostname = s.len() <= 253 + && s.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + }); + if valid_hostname { + Ok(s.to_ascii_lowercase()) + } else { + Err(EcpError::invalid(format!("\"{s}\" isn't an IP address or hostname."))) + } +} + +/// ECP key names: `Home`, `VolumeUp`, `Lit_a`, `Lit_%C3%A9`, … +pub fn validate_key(key: &str) -> Result<(), EcpError> { + let ok = !key.is_empty() + && key.len() <= 64 + && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '%'); + if ok { + Ok(()) + } else { + Err(EcpError::invalid(format!("\"{key}\" isn't a Roku key name."))) + } +} + +/// App ids are numeric (`12`), `dev`, or TV inputs like `tvinput.hdmi1`. +pub fn validate_app_id(id: &str) -> Result<(), EcpError> { + let ok = !id.is_empty() + && id.len() <= 64 + && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')); + if ok { + Ok(()) + } else { + Err(EcpError::invalid(format!("\"{id}\" isn't a Roku app id."))) + } +} + +// ─── HTTP ────────────────────────────────────────────────────────────────── + +fn http() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + // A Roku is always on the LAN. A system or corporate proxy can only + // break the connection, so never use one. + .no_proxy() + .connect_timeout(Duration::from_millis(1500)) + .timeout(Duration::from_secs(6)) + .pool_idle_timeout(Duration::from_secs(20)) + .build() + .expect("building the HTTP client") + }) +} + +fn ecp_url(host: &str, path: &str) -> String { + format!("http://{host}:{ECP_PORT}/{path}") +} + +async fn check_status(resp: reqwest::Response) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + let body = resp.text().await.unwrap_or_default(); + Err(status_error(status.as_u16(), &body)) +} + +async fn ecp_get(host: &str, path: &str) -> Result { + let resp = http() + .get(ecp_url(host, path)) + .send() + .await + .map_err(|e| transport_error(host, &e))?; + check_status(resp).await +} + +async fn ecp_get_text(host: &str, path: &str) -> Result { + ecp_get(host, path) + .await? + .text() + .await + .map_err(|e| transport_error(host, &e)) +} + +async fn ecp_post(host: &str, path: &str) -> Result<(), EcpError> { + let resp = http() + .post(ecp_url(host, path)) + // Some firmware wants an explicit Content-Length: 0 on POST. + .body("") + .send() + .await + .map_err(|e| transport_error(host, &e))?; + check_status(resp).await.map(|_| ()) +} + +// ─── XML helpers (ECP responses are small and flat) ──────────────────────── + +fn unescape_xml(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(amp) = rest.find('&') { + out.push_str(&rest[..amp]); + rest = &rest[amp..]; + let Some(semi) = rest.find(';').filter(|&i| i <= 10) else { + out.push('&'); + rest = &rest[1..]; + continue; + }; + let entity = &rest[1..semi]; + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + match decoded { + Some(c) => { + out.push(c); + rest = &rest[semi + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +/// Text of the first `…`; empty when missing or self-closing. +fn xml_text(xml: &str, tag: &str) -> String { + let open = format!("<{tag}>"); + let close = format!(""); + xml.find(&open) + .and_then(|start| { + let inner = &xml[start + open.len()..]; + inner.find(&close).map(|end| unescape_xml(inner[..end].trim())) + }) + .unwrap_or_default() +} + +fn xml_attr(tag: &str, attr: &str) -> String { + let pattern = format!(" {attr}=\""); + tag.find(&pattern) + .and_then(|start| { + let rest = &tag[start + pattern.len()..]; + rest.find('"').map(|end| unescape_xml(&rest[..end])) + }) + .unwrap_or_default() +} -const SSDP_MULTICAST: &str = "239.255.255.250:1900"; -const ROKU_PORT: u16 = 8060; +// ─── Device info ─────────────────────────────────────────────────────────── -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] pub struct RokuDevice { pub ip: String, pub name: String, pub model: String, + pub model_number: String, pub vendor: String, pub serial: String, + pub device_id: String, + pub location: String, + pub software_version: String, + /// `PowerOn`, `DisplayOff`, `Ready`, `Headless`, … — empty when unknown. pub power_mode: String, + /// `ecp-setting-mode` on newer firmware, e.g. `default`, `limited`. pub ecp_mode: String, + pub network_type: String, + pub wifi_mac: String, + pub ethernet_mac: String, + pub is_tv: bool, + pub is_stick: bool, + pub supports_find_remote: bool, + pub supports_wake_on_wlan: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +pub fn parse_device_info(ip: &str, xml: &str) -> Option { + let text = |tag: &str| xml_text(xml, tag); + let flag = |tag: &str| text(tag).eq_ignore_ascii_case("true"); + + let vendor = text("vendor-name"); + let model_name = text("model-name"); + let serial = text("serial-number"); + if vendor.is_empty() && model_name.is_empty() && serial.is_empty() { + return None; + } + let model = first_non_empty([text("friendly-model-name"), model_name]); + let name = first_non_empty([ + text("user-device-name"), + text("friendly-device-name"), + text("default-device-name"), + model.clone(), + format!("Roku at {ip}"), + ]); + + Some(RokuDevice { + ip: ip.to_string(), + name, + model, + model_number: text("model-number"), + vendor, + serial, + device_id: text("device-id"), + location: text("user-device-location"), + software_version: text("software-version"), + power_mode: text("power-mode"), + ecp_mode: text("ecp-setting-mode").to_ascii_lowercase(), + network_type: text("network-type"), + wifi_mac: text("wifi-mac").to_ascii_lowercase(), + ethernet_mac: text("ethernet-mac").to_ascii_lowercase(), + is_tv: flag("is-tv"), + is_stick: flag("is-stick"), + supports_find_remote: flag("supports-find-remote"), + supports_wake_on_wlan: flag("supports-wake-on-wlan"), + }) +} + +fn first_non_empty(candidates: [String; N]) -> String { + candidates.into_iter().find(|s| !s.is_empty()).unwrap_or_default() +} + +pub async fn device_info(host: &str) -> Result { + let xml = ecp_get_text(host, "query/device-info").await?; + parse_device_info(host, &xml).ok_or_else(|| { + EcpError::new( + ErrorKind::Other, + format!("The device at {host} answered, but it doesn't look like a Roku."), + ) + }) +} + +// ─── Apps ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct RokuApp { pub id: String, pub name: String, - pub app_type: String, + /// `appl` for channels, `tvin` for TV inputs, `menu`, `ndka`, … + #[serde(rename = "type")] + pub kind: String, pub version: String, } -/// Discover Roku devices via SSDP multicast -pub async fn discover_devices() -> Result, String> { - let mut devices = Vec::new(); - let mut seen = std::collections::HashSet::new(); +pub fn parse_apps(xml: &str) -> Vec { + let mut apps = Vec::new(); + let mut rest = xml; + while let Some(start) = rest.find("` and anything else that merely starts with "app". + if !rest.starts_with([' ', '>', '/']) { + continue; + } + let Some(tag_end) = rest.find('>') else { break }; + let attrs = &rest[..tag_end]; + let self_closing = attrs.ends_with('/'); + rest = &rest[tag_end + 1..]; + let name = if self_closing { + String::new() + } else { + let end = rest.find("").unwrap_or(rest.len()); + let name = unescape_xml(rest[..end].trim()); + rest = &rest[end..]; + name + }; + apps.push(RokuApp { + id: xml_attr(attrs, "id"), + name, + kind: xml_attr(attrs, "type"), + version: xml_attr(attrs, "version"), + }); + } + apps +} + +pub async fn apps(host: &str) -> Result, EcpError> { + let xml = ecp_get_text(host, "query/apps").await?; + Ok(parse_apps(&xml).into_iter().filter(|a| !a.id.is_empty()).collect()) +} - // Try SSDP first - if let Ok(socket) = UdpSocket::bind("0.0.0.0:0") { - socket.set_read_timeout(Some(Duration::from_secs(3))).ok(); - socket.set_broadcast(true).ok(); +/// What's on screen now. `None` when the Roku is on its home screen. +pub async fn active_app(host: &str) -> Result, EcpError> { + let xml = ecp_get_text(host, "query/active-app").await?; + Ok(parse_apps(&xml).into_iter().find(|a| !a.id.is_empty())) +} - let ssdp_msg = "M-SEARCH * HTTP/1.1\r\n\ - HOST: 239.255.255.250:1900\r\n\ - MAN: \"ssdp:discover\"\r\n\ - ST: roku:ecp\r\n\ - MX: 3\r\n\r\n"; - - if socket.send_to(ssdp_msg.as_bytes(), SSDP_MULTICAST).is_ok() { - let mut buf = [0u8; 4096]; - let start = std::time::Instant::now(); - while start.elapsed() < Duration::from_secs(4) { - if let Ok((size, src)) = socket.recv_from(&mut buf) { - let response = String::from_utf8_lossy(&buf[..size]); - if response.contains("roku:ecp") { - let ip = extract_ip(&response, &src); - if !seen.contains(&ip) { - seen.insert(ip.clone()); - if let Ok(device) = get_device_info(&ip).await { - devices.push(device); - } - } +/// The channel's poster art as a `data:` URL, so the UI can show real icons. +pub async fn app_icon(host: &str, app_id: &str) -> Result { + use base64::Engine as _; + validate_app_id(app_id)?; + let resp = ecp_get(host, &format!("query/icon/{app_id}")).await?; + let mime = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .filter(|v| v.starts_with("image/")) + .unwrap_or("image/png") + .to_string(); + let bytes = resp.bytes().await.map_err(|e| transport_error(host, &e))?; + if bytes.is_empty() || bytes.len() > 1_000_000 { + return Err(EcpError::new(ErrorKind::Unsupported, "No icon for this app.")); + } + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(format!("data:{mime};base64,{b64}")) +} + +// ─── Keys, apps and text ─────────────────────────────────────────────────── + +pub async fn keypress(host: &str, key: &str) -> Result<(), EcpError> { + validate_key(key)?; + ecp_post(host, &format!("keypress/{key}")).await +} + +pub async fn launch(host: &str, app_id: &str) -> Result<(), EcpError> { + validate_app_id(app_id)?; + ecp_post(host, &format!("launch/{app_id}")).await +} + +fn percent_encode_char(c: char) -> String { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf) + .bytes() + .map(|b| format!("%{b:02X}")) + .collect() +} + +/// Turn text into ECP key names. Letters and digits go as-is; everything else +/// (space, punctuation, accents, emoji) is UTF-8 percent-encoded, which is +/// what `Lit_` expects. A newline presses Enter. +pub fn text_to_keys(text: &str) -> Vec { + text.chars() + .filter_map(|c| match c { + '\n' => Some("Enter".to_string()), + c if c.is_control() => None, + c if c.is_ascii_alphanumeric() => Some(format!("Lit_{c}")), + c => Some(format!("Lit_{}", percent_encode_char(c))), + }) + .collect() +} + +pub async fn send_text(host: &str, text: &str) -> Result<(), EcpError> { + if text.chars().count() > MAX_TEXT_CHARS { + return Err(EcpError::invalid(format!( + "That's too long to type — keep it under {MAX_TEXT_CHARS} characters." + ))); + } + let keys = text_to_keys(text); + for (i, key) in keys.iter().enumerate() { + if i > 0 { + // Some on-screen keyboards drop characters that arrive back-to-back. + tokio::time::sleep(Duration::from_millis(30)).await; + } + ecp_post(host, &format!("keypress/{key}")).await?; + } + Ok(()) +} + +// ─── Local networks ──────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct LocalNet { + pub ip: Ipv4Addr, + pub netmask: Ipv4Addr, +} + +impl LocalNet { + pub fn broadcast(&self) -> Ipv4Addr { + Ipv4Addr::from(u32::from(self.ip) | !u32::from(self.netmask)) + } +} + +/// Every IPv4 network this computer is on (Wi-Fi, Ethernet, VPN, virtual switches). +pub fn local_networks() -> Vec { + let mut nets: Vec = if_addrs::get_if_addrs() + .unwrap_or_default() + .into_iter() + .filter(|iface| !iface.is_loopback()) + .filter_map(|iface| match iface.addr { + if_addrs::IfAddr::V4(v4) => Some(LocalNet { ip: v4.ip, netmask: v4.netmask }), + _ => None, + }) + .filter(|n| !n.ip.is_loopback() && !n.ip.is_link_local() && !n.ip.is_unspecified()) + .collect(); + nets.sort(); + nets.dedup(); + nets +} + +/// Hosts to probe when multicast discovery finds nothing. Only private +/// networks are scanned; anything wider than /22 is narrowed to our own /24. +pub fn scan_targets(nets: &[LocalNet]) -> Vec { + let mut out = BTreeSet::new(); + for net in nets.iter().filter(|n| n.ip.is_private()) { + let ip = u32::from(net.ip); + let mut mask = u32::from(net.netmask); + if mask.leading_ones() < 22 { + mask = 0xFFFF_FF00; + } + let network = ip & mask; + let broadcast = network | !mask; + if broadcast.saturating_sub(network) < 2 { + continue; + } + out.extend( + ((network + 1)..broadcast) + .filter(|&h| h != ip) + .map(Ipv4Addr::from), + ); + } + out.into_iter().collect() +} + +// ─── Discovery ───────────────────────────────────────────────────────────── + +/// Pull the device address out of an SSDP reply, if it's from a Roku. +pub fn parse_ssdp_response(data: &[u8], src: SocketAddr) -> Option { + let text = String::from_utf8_lossy(data); + if !text.to_ascii_lowercase().contains("roku:ecp") { + return None; + } + text.lines() + .filter_map(|line| line.split_once(':')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case("location")) + .and_then(|(_, v)| normalize_host(v).ok()) + .or_else(|| Some(src.ip().to_string())) +} + +fn ssdp_socket(bind_ip: Option) -> Option { + use socket2::{Domain, Protocol, Socket, Type}; + let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP)).ok()?; + if let Some(ip) = bind_ip { + // Send the search out of this specific adapter, not just the default route. + sock.set_multicast_if_v4(&ip).ok()?; + } + sock.set_multicast_ttl_v4(2).ok(); + let addr = SocketAddr::from((bind_ip.unwrap_or(Ipv4Addr::UNSPECIFIED), 0)); + sock.bind(&addr.into()).ok()?; + sock.set_nonblocking(true).ok()?; + UdpSocket::from_std(sock.into()).ok() +} + +async fn ssdp_search(nets: &[LocalNet], window: Duration) -> HashSet { + // One socket per adapter so a VPN or Hyper-V switch can't swallow the + // search, plus one on the default route in case enumeration missed something. + let sockets = nets + .iter() + .map(|n| Some(n.ip)) + .chain(std::iter::once(None)) + .filter_map(ssdp_socket); + + let mut tasks = JoinSet::new(); + for sock in sockets { + tasks.spawn(async move { + let dest = SocketAddr::from((SSDP_ADDR, SSDP_PORT)); + let start = Instant::now(); + let deadline = start + window; + let mut found = HashSet::new(); + let mut buf = [0u8; 2048]; + let mut sent = 0; + while Instant::now() < deadline { + // UDP is lossy on Wi-Fi: ask three times, spaced out. + if sent < 3 && start.elapsed() >= Duration::from_millis(350 * sent) { + let _ = sock.send_to(M_SEARCH.as_bytes(), dest).await; + sent += 1; + } + let slice = deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_millis(150)); + if let Ok(Ok((n, src))) = tokio::time::timeout(slice, sock.recv_from(&mut buf)).await + { + if let Some(host) = parse_ssdp_response(&buf[..n], src) { + found.insert(host); } - } else { - break; } } + found + }); + } + + let mut all = HashSet::new(); + while let Some(res) = tasks.join_next().await { + if let Ok(found) = res { + all.extend(found); } } + all +} - // Fallback: scan common subnets - if devices.is_empty() { - for subnet in &["192.168.0", "192.168.1", "10.0.0"] { - for i in 1..=254 { - let ip = format!("{}.{}", subnet, i); - if seen.contains(&ip) { continue; } - if let Ok(device) = get_device_info(&ip).await { - seen.insert(ip); - devices.push(device); - } +/// Find anything listening on the ECP port. Used when multicast is blocked +/// (Windows "Public" networks, some mesh routers). +async fn scan_for_ecp(targets: Vec) -> Vec { + let limit = Arc::new(Semaphore::new(96)); + let mut tasks = JoinSet::new(); + for ip in targets { + let limit = limit.clone(); + tasks.spawn(async move { + let _permit = limit.acquire_owned().await.ok()?; + let addr = SocketAddr::from((ip, ECP_PORT)); + match tokio::time::timeout(Duration::from_millis(500), TcpStream::connect(addr)).await { + Ok(Ok(_)) => Some(ip.to_string()), + _ => None, } + }); + } + let mut found = Vec::new(); + while let Some(res) = tasks.join_next().await { + if let Ok(Some(ip)) = res { + found.push(ip); } } - - Ok(devices) + found } -fn extract_ip(response: &str, src: &SocketAddr) -> String { - for line in response.lines() { - if line.to_lowercase().starts_with("location:") { - if let Some(start) = line.find("://") { - let rest = &line[start + 3..]; - if let Some(end) = rest.find(|c: char| c == '/' || c == ':') { - return rest[..end].to_string(); - } - return rest.trim().to_string(); +/// Find every Roku on the local network. SSDP first; if nothing answers (or +/// `deep` is set) probe the local subnets directly. +pub async fn discover(deep: bool) -> Vec { + let nets = local_networks(); + let mut hosts = ssdp_search(&nets, Duration::from_millis(2000)).await; + if deep || hosts.is_empty() { + hosts.extend(scan_for_ecp(scan_targets(&nets)).await); + } + + let mut tasks = JoinSet::new(); + for host in hosts { + tasks.spawn(async move { + match device_info(&host).await { + Ok(dev) => Some(dev), + // It answered on the ECP port but refuses queries: still a Roku, + // and the user needs to see it to learn how to fix the setting. + Err(e) if e.kind == ErrorKind::Limited => Some(RokuDevice { + name: format!("Roku at {host}"), + ip: host, + ecp_mode: "limited".into(), + ..Default::default() + }), + Err(_) => None, + } + }); + } + + let mut devices: Vec = Vec::new(); + while let Some(res) = tasks.join_next().await { + if let Ok(Some(dev)) = res { + // A Roku on both Wi-Fi and Ethernet answers twice; keep one. + let dup = !dev.serial.is_empty() && devices.iter().any(|d| d.serial == dev.serial); + if !dup { + devices.push(dev); } } } - src.ip().to_string() + devices.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then(a.ip.cmp(&b.ip))); + devices } -/// Get device info from a Roku -pub async fn get_device_info(ip: &str) -> Result { - let url = format!("http://{}:{}/query/device-info", ip, ROKU_PORT); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(3)) - .build() - .map_err(|e| e.to_string())?; - - let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; - let xml = resp.text().await.map_err(|e| e.to_string())?; - - let name = extract_xml(&xml, "user-device-name"); - let model = extract_xml(&xml, "model-name"); - let vendor = extract_xml(&xml, "vendor-name"); - let serial = extract_xml(&xml, "serial-number"); - let power = extract_xml(&xml, "power-mode"); - let ecp = extract_xml(&xml, "ecp-setting-mode"); +// ─── Power on: ECP, then Wake-on-LAN ─────────────────────────────────────── - if vendor.is_empty() && model.is_empty() { - return Err("Not a Roku device".into()); +pub fn parse_mac(s: &str) -> Option<[u8; 6]> { + let s = s.trim(); + let parts: Vec<&str> = if s.contains([':', '-']) { + s.split([':', '-']).collect() + } else if s.len() == 12 && s.is_ascii() { + (0..6).map(|i| &s[i * 2..i * 2 + 2]).collect() + } else { + return None; + }; + if parts.len() != 6 { + return None; } + let mut mac = [0u8; 6]; + for (slot, part) in mac.iter_mut().zip(&parts) { + // macOS prints "a:b:c:1:2:3" without leading zeros. + if part.is_empty() || part.len() > 2 || !part.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + *slot = u8::from_str_radix(part, 16).ok()?; + } + if mac == [0; 6] || mac == [0xFF; 6] { + return None; + } + Some(mac) +} - Ok(RokuDevice { - ip: ip.to_string(), - name, - model, - vendor, - serial, - power_mode: power, - ecp_mode: ecp, - }) +pub fn format_mac(mac: [u8; 6]) -> String { + mac.iter().map(|b| format!("{b:02x}")).collect::>().join(":") +} + +pub fn magic_packet(mac: &[u8; 6]) -> [u8; 102] { + let mut pkt = [0xFF; 102]; + for chunk in pkt[6..].chunks_mut(6) { + chunk.copy_from_slice(mac); + } + pkt } -/// Get installed apps -pub async fn get_apps(ip: &str) -> Result, String> { - let url = format!("http://{}:{}/query/apps", ip, ROKU_PORT); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .map_err(|e| e.to_string())?; - - let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; - let xml = resp.text().await.map_err(|e| e.to_string())?; +async fn send_magic_packets(macs: &[[u8; 6]], host: &str, nets: &[LocalNet]) -> usize { + let Ok(sock) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await else { + return 0; + }; + let _ = sock.set_broadcast(true); - let mut apps = Vec::new(); - let mut rest = xml.as_str(); - - while let Some(start) = rest.find("").unwrap_or(rest.len()); - let app_xml = &rest[..end + 6]; - - let id = extract_attr(app_xml, "id"); - let app_type = extract_attr(app_xml, "type"); - let version = extract_attr(app_xml, "version"); - let name_end = app_xml.find("").unwrap_or(app_xml.len()); - let inner = &app_xml[app_xml.find('>').unwrap_or(0) + 1..name_end]; - let name = inner.trim().to_string(); - - apps.push(RokuApp { id, name, app_type, version }); - rest = &rest[end + 6..]; - } - - Ok(apps) -} - -/// Send a keypress -pub async fn send_keypress(ip: &str, key: &str) -> Result<(), String> { - let url = format!("http://{}:{}/keypress/{}", ip, ROKU_PORT, key); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(3)) - .build() - .map_err(|e| e.to_string())?; - - client.post(&url).send().await.map_err(|e| e.to_string())?; - Ok(()) + let mut targets: Vec = vec![Ipv4Addr::BROADCAST]; + targets.extend(nets.iter().filter(|n| n.ip.is_private()).map(LocalNet::broadcast)); + if let Ok(ip) = host.parse::() { + targets.push(ip); + } + targets.sort(); + targets.dedup(); + + let mut sent = 0; + for mac in macs { + let pkt = magic_packet(mac); + for &ip in &targets { + for port in [9, 7] { + if sock.send_to(&pkt, (ip, port)).await.is_ok() { + sent += 1; + } + } + } + } + sent } -/// Launch an app -pub async fn launch_app(ip: &str, app_id: &str) -> Result<(), String> { - let url = format!("http://{}:{}/launch/{}", ip, ROKU_PORT, app_id); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(3)) - .build() - .map_err(|e| e.to_string())?; - - client.post(&url).send().await.map_err(|e| e.to_string())?; - Ok(()) +/// Find the MAC for `ip` in `arp -a` / `arp -n` / `/proc/net/arp` output. +pub fn parse_arp_output(text: &str, ip: Ipv4Addr) -> Option { + let ip = ip.to_string(); + text.lines() + .map(|line| line.split_whitespace().collect::>()) + .filter(|tokens| tokens.iter().any(|t| t.trim_matches(['(', ')']) == ip)) + .find_map(|tokens| tokens.iter().find_map(|t| parse_mac(t))) + .map(format_mac) } -/// Send text input — one character at a time via Lit_ keypresses -pub async fn send_text(ip: &str, text: &str) -> Result<(), String> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(3)) - .build() - .map_err(|e| e.to_string())?; +#[cfg(target_os = "linux")] +fn arp_lookup_blocking(ip: Ipv4Addr) -> Option { + let table = std::fs::read_to_string("/proc/net/arp").ok()?; + parse_arp_output(&table, ip) +} - for ch in text.chars() { - let key = match ch { - ' ' => "Lit_%20".to_string(), - '\n' | '\r' => "Enter".to_string(), - c if c.is_alphanumeric() => format!("Lit_{}", c), - c => format!("Lit_{}", urlencoding(c)), - }; - let url = format!("http://{}:{}/keypress/{}", ip, ROKU_PORT, key); - client.post(&url).send().await.map_err(|e| e.to_string())?; - // Small delay between keystrokes - tokio::time::sleep(Duration::from_millis(40)).await; +#[cfg(not(target_os = "linux"))] +fn arp_lookup_blocking(ip: Ipv4Addr) -> Option { + let mut cmd = std::process::Command::new("arp"); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW).args(["-a", &ip.to_string()]); } - Ok(()) + #[cfg(not(windows))] + cmd.args(["-n", &ip.to_string()]); + let out = cmd.output().ok()?; + parse_arp_output(&String::from_utf8_lossy(&out.stdout), ip) } -fn urlencoding(c: char) -> String { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - let mut result = String::new(); - for byte in s.bytes() { - result.push_str(&format!("%{:02X}", byte)); +/// The MAC address of a device we've talked to recently, from the OS ARP cache. +/// Newer Roku firmware hides its MAC from device-info in Limited mode. +pub async fn arp_lookup(host: &str) -> Option { + let ip: Ipv4Addr = host.parse().ok()?; + tokio::task::spawn_blocking(move || arp_lookup_blocking(ip)) + .await + .ok() + .flatten() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PowerOnReport { + /// `ecp` when the TV was listening, `wakeOnLan` when it had to be woken. + pub method: &'static str, + pub packets_sent: usize, + pub waited_ms: u64, +} + +/// `PowerOn` for TVs; streaming players have no such key, so press Home, +/// which wakes the player and (over HDMI-CEC) usually the TV too. +async fn power_key(host: &str) -> Result<(), EcpError> { + match keypress(host, "PowerOn").await { + Err(e) if e.kind == ErrorKind::Unsupported => keypress(host, "Home").await, + other => other, } - result } -fn extract_xml(xml: &str, tag: &str) -> String { - let open = format!("<{}>", tag); - let close = format!("", tag); - if let Some(start) = xml.find(&open) { - let inner = &xml[start + open.len()..]; - if let Some(end) = inner.find(&close) { - return inner[..end].trim().to_string(); +/// Turn a device on, even from deep standby. +/// +/// A Roku TV with Fast TV start keeps its network up while "off" and accepts +/// `PowerOn` straight away. Without it the network sleeps too, so send +/// Wake-on-LAN magic packets to every MAC we know and keep knocking until the +/// TV answers or we give up. +pub async fn power_on(host: &str, macs: &[String]) -> Result { + let start = Instant::now(); + let report = |method, packets_sent| PowerOnReport { + method, + packets_sent, + waited_ms: start.elapsed().as_millis() as u64, + }; + + match power_key(host).await { + Ok(()) => return Ok(report("ecp", 0)), + Err(e) if e.kind != ErrorKind::Unreachable => return Err(e), + Err(_) => {} + } + + let mut known: Vec<[u8; 6]> = macs.iter().filter_map(|m| parse_mac(m)).collect(); + if let Some(mac) = arp_lookup(host).await.and_then(|m| parse_mac(&m)) { + known.push(mac); + } + known.sort(); + known.dedup(); + if known.is_empty() { + return Err(EcpError::new( + ErrorKind::Unreachable, + format!( + "The TV at {host} isn't answering, so it's fully powered off. {FAST_START_HELP} \ + (Wake-on-LAN also needs this app to have seen the TV once while it was on.)" + ), + )); + } + + let nets = local_networks(); + let deadline = start + Duration::from_secs(25); + let mut packets = 0; + let mut last_wol: Option = None; + while Instant::now() < deadline { + if last_wol.map_or(true, |t| t.elapsed() >= Duration::from_secs(3)) { + packets += send_magic_packets(&known, host, &nets).await; + last_wol = Some(Instant::now()); + } + tokio::time::sleep(Duration::from_millis(1200)).await; + match power_key(host).await { + Ok(()) => return Ok(report("wakeOnLan", packets)), + Err(e) if e.kind == ErrorKind::Unreachable => continue, + Err(e) => return Err(e), } } - String::new() + Err(EcpError::new( + ErrorKind::Unreachable, + format!("Sent wake-up signals, but the TV didn't respond. {FAST_START_HELP}"), + )) } -fn extract_attr(xml: &str, attr: &str) -> String { - let pattern = format!("{}=\"", attr); - if let Some(start) = xml.find(&pattern) { - let rest = &xml[start + pattern.len()..]; - if let Some(end) = rest.find('"') { - return rest[..end].to_string(); +// ─── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const DEVICE_INFO: &str = r#" + + 29380011-0800-1026-806b-a0629f9a1cbe + X01900ABCDEF + S0A1234567890 + Hisense + 58R6E3 + G218X + true + false + A0:62:FB:9A:1C:BE + a0:62:fb:9a:1c:bd + wifi + Hisense•58R6E3 + Hisense•58R6E3 + Hisense•58R6E3 - X01900ABCDEF + Living Room & Den + Living room + 14.1.4 + DisplayOff + true + false + Limited + +"#; + + #[test] + fn parses_device_info() { + let d = parse_device_info("192.168.1.20", DEVICE_INFO).unwrap(); + assert_eq!(d.ip, "192.168.1.20"); + assert_eq!(d.name, "Living Room & Den"); + assert_eq!(d.model, "Hisense•58R6E3"); + assert_eq!(d.model_number, "G218X"); + assert_eq!(d.vendor, "Hisense"); + assert_eq!(d.serial, "X01900ABCDEF"); + assert_eq!(d.software_version, "14.1.4"); + assert_eq!(d.power_mode, "DisplayOff"); + assert_eq!(d.ecp_mode, "limited"); + assert_eq!(d.wifi_mac, "a0:62:fb:9a:1c:be"); + assert!(d.is_tv && !d.is_stick && d.supports_find_remote && !d.supports_wake_on_wlan); + } + + #[test] + fn device_name_falls_back_to_model_then_ip() { + let xml = "RokuRoku Express"; + assert_eq!(parse_device_info("10.0.0.5", xml).unwrap().name, "Roku Express"); + let xml = "X1"; + assert_eq!(parse_device_info("10.0.0.5", xml).unwrap().name, "Roku at 10.0.0.5"); + } + + #[test] + fn rejects_non_roku_xml() { + assert!(parse_device_info("10.0.0.5", "router").is_none()); + } + + #[test] + fn parses_apps_and_skips_the_container() { + let xml = r#" + + Xbox + Netflix + Roku Media Player + Max & HBO +"#; + let apps = parse_apps(xml); + assert_eq!(apps.len(), 4); + assert_eq!(apps[0], RokuApp { id: "tvinput.hdmi1".into(), name: "Xbox".into(), kind: "tvin".into(), version: "1.0.0".into() }); + assert_eq!(apps[2].kind, "appl"); + assert_eq!(apps[3].name, "Max & HBO"); + } + + #[test] + fn active_app_home_screen_has_no_id() { + let home = "Roku"; + assert!(parse_apps(home).into_iter().all(|a| a.id.is_empty())); + let playing = r#"NetflixAquarium"#; + let first = parse_apps(playing).into_iter().find(|a| !a.id.is_empty()).unwrap(); + assert_eq!(first.name, "Netflix"); + } + + #[test] + fn unescapes_entities() { + assert_eq!(unescape_xml("A & B <3 é😀 & done"), "A & B <3 é😀 & done"); + assert_eq!(unescape_xml("no entities"), "no entities"); + assert_eq!(unescape_xml("&bogus;"), "&bogus;"); + } + + #[test] + fn text_becomes_lit_keys() { + assert_eq!(text_to_keys("Hi 5"), ["Lit_H", "Lit_i", "Lit_%20", "Lit_5"]); + assert_eq!(text_to_keys("a+b&c/?#"), ["Lit_a", "Lit_%2B", "Lit_b", "Lit_%26", "Lit_c", "Lit_%2F", "Lit_%3F", "Lit_%23"]); + assert_eq!(text_to_keys("é"), ["Lit_%C3%A9"]); + assert_eq!(text_to_keys("ok\r\n\t"), ["Lit_o", "Lit_k", "Enter"]); + for key in text_to_keys("Crème brûlée — 100% 🎬") { + validate_key(&key).unwrap(); + } + } + + #[test] + fn normalizes_hosts() { + assert_eq!(normalize_host(" 192.168.1.20 ").unwrap(), "192.168.1.20"); + assert_eq!(normalize_host("http://192.168.1.20:8060/query/device-info").unwrap(), "192.168.1.20"); + assert_eq!(normalize_host("HTTP://Roku-Den.local").unwrap(), "roku-den.local"); + for bad in ["", " ", "192.168.1.300", "0.0.0.0", "255.255.255.255", "evil com", "a b", "roku_den.local", "-bad.local", "http://", "é.local"] { + let err = normalize_host(bad).unwrap_err(); + assert_eq!(err.kind, ErrorKind::Invalid, "{bad:?} should be rejected"); + } + } + + #[test] + fn validates_keys_and_app_ids() { + for ok in ["Home", "VolumeUp", "Lit_a", "Lit_%C3%A9", "InputHDMI1"] { + validate_key(ok).unwrap(); + } + for bad in ["", "../launch/12", "Home?x=1", "Lit_ ", "a/b"] { + assert!(validate_key(bad).is_err(), "{bad:?}"); + } + for ok in ["12", "tvinput.hdmi1", "dev", "tvinput.dtv"] { + validate_app_id(ok).unwrap(); + } + for bad in ["", "12/../../keypress/Home", "12?x", "a b"] { + assert!(validate_app_id(bad).is_err(), "{bad:?}"); + } + } + + #[test] + fn maps_http_status_to_actionable_errors() { + let e = status_error(403, "ECP command not allowed in limited mode."); + assert_eq!(e.kind, ErrorKind::Limited); + assert!(e.message.contains("Control by mobile apps")); + assert_eq!(status_error(404, "").kind, ErrorKind::Unsupported); + let other = status_error(503, "busy"); + assert_eq!(other.kind, ErrorKind::Other); + assert!(other.message.contains("503") && other.message.contains("busy")); + } + + #[test] + fn parses_macs_in_every_os_format() { + let want = [0xa0, 0x62, 0xfb, 0x02, 0x1c, 0x0e]; + assert_eq!(parse_mac("a0:62:fb:02:1c:0e"), Some(want)); + assert_eq!(parse_mac("A0-62-FB-02-1C-0E"), Some(want)); + assert_eq!(parse_mac("a0:62:fb:2:1c:e"), Some(want)); + assert_eq!(parse_mac("a062fb021c0e"), Some(want)); + for bad in ["", "00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff", "a0:62:fb:02:1c", "zz:62:fb:02:1c:0e", "0x1", "192.168.1.1", "a0:62:fb:02:1c:0e:11"] { + assert_eq!(parse_mac(bad), None, "{bad:?}"); + } + assert_eq!(format_mac(want), "a0:62:fb:02:1c:0e"); + } + + #[test] + fn builds_magic_packets() { + let mac = [1, 2, 3, 4, 5, 6]; + let pkt = magic_packet(&mac); + assert_eq!(&pkt[..6], &[0xFF; 6]); + assert!(pkt[6..].chunks(6).all(|c| c == mac)); + assert_eq!(pkt[6..].chunks(6).count(), 16); + } + + #[test] + fn finds_macs_in_arp_tables() { + let ip: Ipv4Addr = "192.168.0.126".parse().unwrap(); + let windows = "\r\nInterface: 192.168.0.10 --- 0x5\r\n Internet Address Physical Address Type\r\n 192.168.0.1 10-20-30-40-50-60 dynamic\r\n 192.168.0.126 a0-62-fb-9a-1c-be dynamic\r\n"; + let macos = "? (192.168.0.1) at 10:20:30:40:50:60 on en0 ifscope [ethernet]\n? (192.168.0.126) at a0:62:fb:9a:1c:be on en0 ifscope [ethernet]\n"; + let linux = "IP address HW type Flags HW address Mask Device\n192.168.0.126 0x1 0x2 a0:62:fb:9a:1c:be * wlan0\n192.168.0.12 0x1 0x0 00:00:00:00:00:00 * wlan0\n"; + for table in [windows, macos, linux] { + assert_eq!(parse_arp_output(table, ip).as_deref(), Some("a0:62:fb:9a:1c:be")); } + let incomplete: Ipv4Addr = "192.168.0.12".parse().unwrap(); + assert_eq!(parse_arp_output(linux, incomplete), None); + let other: Ipv4Addr = "192.168.0.12".parse().unwrap(); + assert_eq!(parse_arp_output(windows, other), None); + } + + #[test] + fn scan_targets_cover_the_local_subnet_only() { + let net = |ip: &str, mask: &str| LocalNet { ip: ip.parse().unwrap(), netmask: mask.parse().unwrap() }; + let t = scan_targets(&[net("192.168.1.50", "255.255.255.0")]); + assert_eq!(t.len(), 253); + assert!(!t.contains(&"192.168.1.50".parse().unwrap())); + assert!(!t.contains(&"192.168.1.0".parse().unwrap()) && !t.contains(&"192.168.1.255".parse().unwrap())); + // A /16 is narrowed to our own /24. + assert_eq!(scan_targets(&[net("10.0.4.9", "255.255.0.0")]).len(), 253); + // A /22 is scanned whole. + assert_eq!(scan_targets(&[net("172.16.5.9", "255.255.252.0")]).len(), 1021); + // Public addresses and point-to-point links are never scanned. + assert!(scan_targets(&[net("8.8.8.8", "255.255.255.0"), net("192.168.9.1", "255.255.255.255")]).is_empty()); + assert_eq!(net("192.168.1.50", "255.255.255.0").broadcast(), "192.168.1.255".parse::().unwrap()); + } + + #[test] + fn parses_ssdp_replies() { + let src: SocketAddr = "192.168.1.77:1900".parse().unwrap(); + let reply = b"HTTP/1.1 200 OK\r\nCache-Control: max-age=3600\r\nST: roku:ecp\r\nLOCATION: http://192.168.1.134:8060/\r\nUSN: uuid:roku:ecp:X01900ABCDEF\r\n\r\n"; + assert_eq!(parse_ssdp_response(reply, src).as_deref(), Some("192.168.1.134")); + let no_location = b"HTTP/1.1 200 OK\r\nST: roku:ecp\r\n\r\n"; + assert_eq!(parse_ssdp_response(no_location, src).as_deref(), Some("192.168.1.77")); + let not_roku = b"HTTP/1.1 200 OK\r\nST: upnp:rootdevice\r\nLOCATION: http://192.168.1.1:5000/\r\n\r\n"; + assert_eq!(parse_ssdp_response(not_roku, src), None); + } + + /// Try discovery on whatever network this machine is on: + /// `cargo test discovers_on_this_network -- --ignored --nocapture` + #[tokio::test] + #[ignore = "needs a real network"] + async fn discovers_on_this_network() { + let start = std::time::Instant::now(); + let nets = local_networks(); + println!("networks: {nets:?} ({} scan targets)", scan_targets(&nets).len()); + let found = discover(false).await; + println!("{} Roku(s) in {:?}: {found:#?}", found.len(), start.elapsed()); + assert!(start.elapsed() < Duration::from_secs(20), "discovery must stay quick"); + } + + /// A tiny fake Roku on a loopback address, answering every request with + /// the scripted status and body, and recording the request lines it saw. + #[cfg(target_os = "linux")] + async fn fake_roku(ip: &str, routes: Vec<(&'static str, u16, &'static str)>) -> Arc>> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind((ip, ECP_PORT)).await.expect("bind fake roku"); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let log = seen.clone(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { return }; + let routes = routes.clone(); + let log = log.clone(); + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + loop { + let Ok(n) = stream.read(&mut buf).await else { return }; + if n == 0 { return; } + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let line = req.lines().next().unwrap_or_default().to_string(); + log.lock().unwrap().push(line.clone()); + let (status, body) = routes + .iter() + .find(|(prefix, _, _)| line.contains(prefix)) + .map(|(_, s, b)| (*s, *b)) + .unwrap_or((404, "")); + let resp = format!("HTTP/1.1 {status} X\r\nContent-Length: {}\r\n\r\n{body}", body.len()); + if stream.write_all(resp.as_bytes()).await.is_err() { return; } + } + }); + } + }); + seen + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn talks_to_a_roku_and_reports_limited_mode() { + let ip = "127.0.0.61"; + let seen = fake_roku(ip, vec![ + ("/query/device-info", 200, DEVICE_INFO), + ("/keypress/VolumeUp", 200, ""), + ("/keypress/Home", 403, "ECP command not allowed in limited mode."), + ("/keypress/Lit_", 200, ""), + ("/launch/12", 200, ""), + ]).await; + + let dev = device_info(ip).await.unwrap(); + assert_eq!(dev.name, "Living Room & Den"); + keypress(ip, "VolumeUp").await.unwrap(); + let err = keypress(ip, "Home").await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Limited); + assert_eq!(keypress(ip, "InputHDMI9").await.unwrap_err().kind, ErrorKind::Unsupported); + send_text(ip, "a b").await.unwrap(); + launch(ip, "12").await.unwrap(); + + let seen = seen.lock().unwrap().clone(); + assert!(seen.contains(&"POST /keypress/Lit_a HTTP/1.1".to_string()), "{seen:?}"); + assert!(seen.contains(&"POST /keypress/Lit_%20 HTTP/1.1".to_string()), "{seen:?}"); + assert!(seen.contains(&"POST /launch/12 HTTP/1.1".to_string()), "{seen:?}"); + let order: Vec<_> = seen.iter().filter(|l| l.contains("Lit_")).cloned().collect(); + assert_eq!(order, ["POST /keypress/Lit_a HTTP/1.1", "POST /keypress/Lit_%20 HTTP/1.1", "POST /keypress/Lit_b HTTP/1.1"]); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn subnet_scan_finds_the_ecp_port() { + fake_roku("127.0.0.70", vec![]).await; + let targets: Vec = (65..=80).map(|h| Ipv4Addr::new(127, 0, 0, h)).collect(); + let start = std::time::Instant::now(); + assert_eq!(scan_for_ecp(targets).await, ["127.0.0.70"]); + assert!(start.elapsed() < Duration::from_secs(2)); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn power_on_uses_ecp_when_the_tv_is_listening() { + let ip = "127.0.0.62"; + let seen = fake_roku(ip, vec![("/keypress/PowerOn", 200, "")]).await; + let report = power_on(ip, &[]).await.unwrap(); + assert_eq!(report.method, "ecp"); + assert_eq!(report.packets_sent, 0); + assert_eq!(seen.lock().unwrap().as_slice(), ["POST /keypress/PowerOn HTTP/1.1"]); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn power_on_presses_home_on_players_without_a_power_key() { + let ip = "127.0.0.63"; + let seen = fake_roku(ip, vec![("/keypress/Home", 200, "")]).await; + assert_eq!(power_on(ip, &[]).await.unwrap().method, "ecp"); + assert_eq!(seen.lock().unwrap().as_slice(), ["POST /keypress/PowerOn HTTP/1.1", "POST /keypress/Home HTTP/1.1"]); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn unreachable_without_a_mac_explains_fast_tv_start() { + // Nothing listens on this loopback address, so the connection is refused. + let err = power_on("127.0.0.64", &[]).await.unwrap_err(); + assert_eq!(err.kind, ErrorKind::Unreachable); + assert!(err.message.contains("Fast TV start"), "{}", err.message); } - String::new() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 65c0256..0146e6d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Roku Control", - "version": "2.0.0", + "version": "2.1.0", "identifier": "com.moveweight.roku-control", "build": { "frontendDist": "../dist", @@ -20,11 +20,12 @@ "resizable": true, "decorations": false, "transparent": true, - "center": true + "center": true, + "label": "main" } ], "security": { - "csp": "default-src 'self'; connect-src ipc: http://192.168.*:* http://10.*:* http://172.16.*:*; style-src 'self' 'unsafe-inline'" + "csp": "default-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost; style-src 'self' 'unsafe-inline'" } }, "bundle": { diff --git a/src/App.jsx b/src/App.jsx index 47e61fa..10770de 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,244 +1,266 @@ -import React, { useState, useCallback, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { exit } from '@tauri-apps/plugin-process'; - -const PRELOADED = [ - { ip: '192.168.0.126', label: "Wade's Room — Hisense 58\"" }, - { ip: '192.168.0.124', label: "Alyra's Room — onn. 32\"" }, -]; - -const ICONS = { - netflix:'🔴',prime:'📦',hulu:'💚',youtube:'▶️',spotify:'🎵',plex:'🎬', - disney:'🐭',apple:'🍎',hbo:'👁️',peacock:'🦚',starz:'⭐',paramount:'🏔️', - espn:'⚽',crunchyroll:'🍥',tubi:'📺',pluto:'📡',pandora:'🔷',jellyfin:'🎞️', - emby:'📀',cnn:'📰',directv:'📡',cbs:'👁️',fubo:'⚾',backdrops:'🖼️', - hdmi:'🔌',av:'🔗',xcast:'📲',camdiggity:'📹',romm:'🕹️', -}; -const icon = (n) => { const l=(n||'').toLowerCase(); for(const[k,v]of Object.entries(ICONS)) if(l.includes(k)) return v; return '📱'; }; - -const INPUTS = [ - {k:'InputTuner',l:'📡 Antenna'},{k:'InputHDMI1',l:'HDMI 1'}, - {k:'InputHDMI2',l:'HDMI 2'},{k:'InputHDMI3',l:'HDMI 3'}, - {k:'InputHDMI4',l:'HDMI 4'},{k:'InputAV1',l:'AV'}, -]; - -export default function App({ widget = false }) { - const [devices, setDevices] = useState(PRELOADED); - const [sel, setSel] = useState(PRELOADED[0]); - const [apps, setApps] = useState([]); - const [showApps, setShowApps] = useState(false); - const [showInputs, setShowInputs] = useState(false); - const [scanning, setScanning] = useState(false); - const [vol, setVol] = useState(30); - const [err, setErr] = useState(''); - const [text, setText] = useState(''); - const volRef = useRef(30); - const volTimer = useRef(null); - - const refresh = async () => { - setScanning(true); setErr(''); - try { - const found = await invoke('discover'); - if (found?.length) { - const merged = [...PRELOADED]; - for (const d of found) { - if (!merged.find(m => m.ip === d.ip)) - merged.push({ ip: d.ip, label: `${d.name || d.model} — ${d.vendor}` }); - } - setDevices(merged); - if (!sel) setSel(merged[0]); - } - } catch(e) { setErr(e+''); } - setScanning(false); - }; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { LogicalSize } from '@tauri-apps/api/dpi'; +import { useRoku } from './hooks/useRoku'; +import { api, toError } from './lib/api'; +import { load, save } from './lib/storage'; +import { usage } from './lib/usage'; +import { issueUrl, openLink, rateUrl } from './lib/links'; +import { REPEATABLE, SHORTCUTS, normalizeKey } from './lib/shortcuts'; +import TitleBar from './components/TitleBar'; +import DeviceBar from './components/DeviceBar'; +import Banner from './components/Banner'; +import Remote from './components/Remote'; +import Keyboard from './components/Keyboard'; +import Apps from './components/Apps'; +import Footer from './components/Footer'; +import Toast from './components/Toast'; +import HelpDialog from './components/HelpDialog'; +import DevicesDialog from './components/DevicesDialog'; +import RatePrompt from './components/RatePrompt'; + +const FULL = { w: 480, h: 820, minW: 400, minH: 650 }; +const MINI = { w: 300, h: 540, minW: 280, minH: 460 }; - const loadApps = useCallback(async (ip) => { - try { const a = await invoke('get_apps', { ip }); if (a?.length) setApps(a); } catch(e) {} +async function applyWindowMode(compact) { + const m = compact ? MINI : FULL; + try { + const win = getCurrentWindow(); + await win.setAlwaysOnTop(compact); + await win.setMinSize(new LogicalSize(m.minW, m.minH)); + await win.setSize(new LogicalSize(m.w, m.h)); + } catch { + // Not inside Tauri (plain browser dev server). + } +} + +export default function App() { + const [toast, setToast] = useState(null); + const toastTimer = useRef(); + const notify = useCallback((message, tone = 'error', ms = 6000) => { + setToast({ message, tone }); + clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToast(null), ms); }, []); - const selectDevice = (d) => { setSel(d); setApps([]); loadApps(d.ip); }; + const roku = useRoku({ notify }); + const { selected: sel, status, press } = roku; - const send = async (key) => { - if (!sel) return; - setErr(''); - try { await invoke('keypress', { ip: sel.ip, key }); } catch(e) { setErr(e+''); } - }; + const [dialog, setDialog] = useState(null); + const dialogRef = useRef(dialog); + dialogRef.current = dialog; + const closeDialog = useCallback(() => setDialog(null), []); - const sendVol = (dir) => { - if (!sel) return; - const newV = dir === 'up' ? Math.min(100, volRef.current + 3) : Math.max(0, volRef.current - 3); - volRef.current = newV; - setVol(newV); - if (volTimer.current) clearTimeout(volTimer.current); - volTimer.current = setTimeout(() => send(dir === 'up' ? 'VolumeUp' : 'VolumeDown'), 20); + // ── Mini remote: small, always on top ── + const [compact, setCompact] = useState(() => load('compact', false)); + useEffect(() => { + if (compact) applyWindowMode(true); + // Only restore the saved mode on launch; toggling applies it directly. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const toggleCompact = () => { + const next = !compact; + setCompact(next); + save('compact', next); + applyWindowMode(next); }; - const handleVolChange = (v) => { - const target = Number(v); - const prev = volRef.current; - volRef.current = target; - setVol(target); - if (volTimer.current) clearTimeout(volTimer.current); - const diff = target - prev; - const dir = diff > 0 ? 'VolumeUp' : 'VolumeDown'; - const steps = Math.abs(Math.round(diff / 3)); - let i = 0; - const step = () => { - if (i++ >= steps) return; - send(dir); - volTimer.current = setTimeout(step, 50); + // ── Keyboard shortcuts ── + const typingRef = useRef(null); + const [flash, setFlash] = useState(null); + const flashTimer = useRef(); + const lastRepeat = useRef(0); + + useEffect(() => { + const onKey = (e) => { + if (dialogRef.current || e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return; + const target = e.target instanceof Element ? e.target : null; + if (target?.closest('input, textarea, select, [contenteditable="true"]')) return; + const item = SHORTCUTS.get(normalizeKey(e.key)); + if (!item) return; + // A button reached with Tab keeps Enter/Space for itself. + if ((e.key === 'Enter' || e.key === ' ') && target?.closest('button')) return; + e.preventDefault(); + if (item.action === 'help') return setDialog({ type: 'help', tab: 'keys' }); + if (item.action === 'type') return typingRef.current?.focus(); + if (e.repeat) { + if (!REPEATABLE.has(item.roku) || performance.now() - lastRepeat.current < 110) return; + lastRepeat.current = performance.now(); + } + press(item.roku, { droppable: e.repeat }); + setFlash(item.roku); + clearTimeout(flashTimer.current); + flashTimer.current = setTimeout(() => setFlash(null), 160); }; - step(); - }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [press]); + + // ── Channels and what's playing ── + const [apps, setApps] = useState([]); + const [appsState, setAppsState] = useState({ loading: false, error: null }); + const [activeAppId, setActiveAppId] = useState(null); + + const selIdRef = useRef(sel?.id); + selIdRef.current = sel?.id; - const launch = async (appId) => { + const loadApps = useCallback(async () => { if (!sel) return; - try { await invoke('launch', { ip: sel.ip, appId }); setShowApps(false); } catch(e) { setErr(e+''); } - }; + const forId = sel.id; + setAppsState({ loading: true, error: null }); + try { + const list = await api.apps(sel.ip); + // The user may have switched Rokus while this was loading. + if (selIdRef.current !== forId) return; + setApps(list); + setAppsState({ loading: false, error: null }); + } catch (e) { + if (selIdRef.current !== forId) return; + const err = toError(e); + setAppsState({ + loading: false, + error: err.kind === 'limited' ? 'Channels are hidden while the Roku is in Limited mode.' : err.message, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sel?.id, sel?.ip]); + + useEffect(() => { + setApps([]); + setActiveAppId(null); + setAppsState({ loading: false, error: null }); + }, [sel?.id]); + + const reachable = status.state === 'online' || status.state === 'standby'; + useEffect(() => { + if (reachable && !apps.length) loadApps(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reachable, sel?.id]); - const sendText = async () => { - if (!sel || !text.trim()) return; - setErr(''); - try { await invoke('send_text', { ip: sel.ip, text: text.trim() }); setText(''); } catch(e) { setErr(e+''); } + const selIp = sel?.ip; + useEffect(() => { + if (!selIp || compact || status.state !== 'online') return undefined; + let live = true; + const poll = () => { + if (document.visibilityState !== 'visible') return; + api.activeApp(selIp).then( + (a) => live && setActiveAppId(a?.id ?? null), + () => {}, + ); + }; + poll(); + const id = setInterval(poll, 10_000); + return () => { + live = false; + clearInterval(id); + }; + }, [selIp, compact, status.state]); + + const launchApp = async (appId) => { + if (await roku.launch(appId)) setActiveAppId(appId); }; - const dpad = [ - {r:0,c:1,k:'Up',l:'▲'},{r:1,c:0,k:'Left',l:'◀'}, - {r:1,c:1,k:'Select',l:'OK',cl:'c'},{r:1,c:2,k:'Right',l:'▶'}, - {r:2,c:1,k:'Down',l:'▼'}, - ]; + // ── One-time rating prompt after ten minutes of use ── + useEffect(() => { + let last = Date.now(); + const id = setInterval(() => { + const now = Date.now(); + if (document.visibilityState === 'visible') usage.addActiveTime(Math.min(now - last, 30_000)); + last = now; + if (!dialogRef.current && usage.shouldAskForRating()) { + usage.markRatingAsked(); + setDialog({ type: 'rate' }); + } + }, 15_000); + return () => clearInterval(id); + }, []); + + // ── Feedback straight to GitHub ── + const reportProblem = () => openLink(issueUrl('bug', { device: sel, lastError: roku.lastError ?? status.error })); + const suggestIdea = () => openLink(issueUrl('idea')); + const rate = () => openLink(rateUrl()); + const openHelp = (tab = 'fix') => setDialog({ type: 'help', tab }); return ( -
-
- 📺 Roku Control {sel ? `· ${sel.label.split('—')[0].trim()}` : ''} -
- -
-
- - {!widget && ( -
-
- - -
- )} +
+ openHelp()} /> + roku.discover()} + onManage={() => setDialog({ type: 'devices' })} + /> -
- {err &&
{err}
} - - {/* Power row */} -
- {sel?.label || 'No device'} -
- - -
-
- - {/* D-Pad */} -
- {dpad.map((k,i) => ( - - ))} -
- - {/* Transport: Rewind — PLAY/PAUSE — Fwd — Instant Replay */} -
- - - - -
- - {/* Navigation */} -
- - - - -
- - {/* Volume slider — actually sends commands */} -
- - handleVolChange(e.target.value)} /> - - -
- - {/* Channel + Inputs */} -
- - - - -
- {showInputs && ( -
- {INPUTS.map(inp => ( - - ))} -
- )} +
+ roku.discover()} + onManage={() => setDialog({ type: 'devices' })} + onHelp={() => openHelp()} + /> - {/* Keyboard input */} - {!widget && ( -
- setText(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') sendText(); }} - placeholder="Type to search on TV..." - style={{flex:1,background:'var(--bg3)',border:'1px solid var(--border)', - borderRadius:'var(--radius-sm)',color:'var(--text)',padding:'8px 10px',fontSize:12,outline:'none'}} /> - - -
- )} + a.type === 'tvin')} + compact={compact} + /> - {/* App Launcher */} - {!widget && ( + {!compact && ( <> - - {showApps && apps.length > 0 && ( -
- {apps.map((a,i) => ( -
launch(a.id)} title={a.name}> - {icon(a.name)} - {a.name} -
- ))} -
+ + {sel && ( + )} +

+ Tip: your keyboard is a remote too — arrows, Enter, Backspace, Space.{' '} + +

+
)} +
+ + setToast(null)} /> - {/* Branding */} -
-
- Brought to you ad-free by -
-
- MOVEWEIGHT.NET -
-
- WE MAKE DOPE SHIT! -
-
-
+ {dialog?.type === 'help' && ( + + )} + {dialog?.type === 'devices' && ( + roku.discover({ deep: true })} + onForget={roku.forget} + onSelect={roku.select} + onClose={closeDialog} + /> + )} + {dialog?.type === 'rate' && }
); } diff --git a/src/assets/hero.png b/src/assets/hero.png deleted file mode 100644 index 02251f4..0000000 Binary files a/src/assets/hero.png and /dev/null differ diff --git a/src/assets/vite.svg b/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/src/components/Apps.jsx b/src/components/Apps.jsx new file mode 100644 index 0000000..e32bfde --- /dev/null +++ b/src/components/Apps.jsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { api } from '../lib/api'; + +const EMOJI = { + netflix: '🔴', prime: '📦', hulu: '💚', youtube: '▶️', spotify: '🎵', plex: '🎬', disney: '🏰', + apple: '🍎', max: '🟣', hbo: '🟣', peacock: '🦚', starz: '⭐', paramount: '⛰️', espn: '🏈', + crunchyroll: '🍥', tubi: '📺', pluto: '🪐', pandora: '🔷', jellyfin: '🎞️', emby: '📀', news: '📰', + sling: '📡', fubo: '⚽', twitch: '🟪', music: '🎵', weather: '⛅', photo: '🖼️', game: '🕹️', +}; +const emojiFor = (name) => { + const n = (name || '').toLowerCase(); + return Object.entries(EMOJI).find(([k]) => n.includes(k))?.[1] ?? '📺'; +}; + +// Real channel art, fetched from the Roku a few at a time and cached. +const icons = new Map(); +const waiting = []; +let running = 0; + +function loadIcon(ip, appId, cacheKey) { + if (icons.has(cacheKey)) return Promise.resolve(icons.get(cacheKey)); + return new Promise((resolve) => { + waiting.push({ ip, appId, cacheKey, resolve }); + pump(); + }); +} + +function pump() { + while (running < 4 && waiting.length) { + const job = waiting.shift(); + running++; + api + .appIcon(job.ip, job.appId) + .catch(() => null) + .then((url) => { + icons.set(job.cacheKey, url); + job.resolve(url); + running--; + pump(); + }); + } +} + +function AppTile({ device, app, active, onLaunch }) { + const cacheKey = `${device.id}/${app.id}`; + const [src, setSrc] = useState(() => icons.get(cacheKey)); + + useEffect(() => { + if (src !== undefined) return undefined; + let live = true; + loadIcon(device.ip, app.id, cacheKey).then((url) => live && setSrc(url)); + return () => { + live = false; + }; + // Tiles are keyed by device + app, so this runs once per tile. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +} + +export default function Apps({ device, apps, activeAppId, loading, error, onLaunch, onReload }) { + const [filter, setFilter] = useState(''); + const channels = apps.filter((a) => a.type !== 'tvin'); + const q = filter.trim().toLowerCase(); + const shown = q ? channels.filter((a) => a.name.toLowerCase().includes(q)) : channels; + + return ( +
+
+

+ 📺 Channels {channels.length > 0 && {channels.length}} +

+ {channels.length > 6 && ( + setFilter(e.target.value)} aria-label="Find a channel" /> + )} +
+ {loading && !channels.length ? ( +

Loading channels…

+ ) : error ? ( +

+ {error}{' '} + +

+ ) : shown.length ? ( +
+ {shown.map((a) => ( + + ))} +
+ ) : ( +

{q ? 'No channel matches that.' : 'No channels yet.'}

+ )} +
+ ); +} diff --git a/src/components/Banner.jsx b/src/components/Banner.jsx new file mode 100644 index 0000000..2e358c8 --- /dev/null +++ b/src/components/Banner.jsx @@ -0,0 +1,80 @@ +// What's wrong and how to fix it, in plain words, right where the user looks. +export default function Banner({ status, device, scanning, waking, onRecheck, onPowerOn, onRescan, onManage, onHelp }) { + if (status.state === 'limited') { + return ( +
+ Your Roku is blocking this app +

+ It's set to Limited mode, so only volume works. On the TV, open{' '} + Settings › System › Advanced system settings › Control by mobile apps and choose Enabled (or set{' '} + Network access to Permissive). +

+
+ + +
+
+ ); + } + if (status.state === 'offline' && device) { + return ( +
+ Can't reach {device.name} +

+ {device.isTv === false + ? 'Make sure it has power and is on the same network as this computer.' + : 'If the TV is off, try Turn on. Otherwise make sure it’s on the same network as this computer.'} +

+
+ + + +
+
+ ); + } + if (status.state === 'scanning') { + return ( +
+ + ⟳ Looking for Rokus on your network… + +

This takes a few seconds.

+
+ ); + } + if (status.state === 'none') { + return ( +
+ No Roku found +
    +
  1. Connect this computer to the same Wi-Fi as your Roku (not a guest network).
  2. +
  3. On Windows, set that network to Private so the Roku can answer.
  4. +
  5. Or add it by IP — on the Roku it's under Settings › Network › About.
  6. +
+
+ + + +
+
+ ); + } + return null; +} diff --git a/src/components/DeviceBar.jsx b/src/components/DeviceBar.jsx new file mode 100644 index 0000000..fd990be --- /dev/null +++ b/src/components/DeviceBar.jsx @@ -0,0 +1,59 @@ +const STATUS_TEXT = { + online: 'Connected', + standby: 'Screen off', + offline: 'Not responding', + limited: 'Limited mode', + checking: 'Checking…', + scanning: 'Searching…', + none: 'No Roku found', +}; + +function ago(ts) { + if (!ts) return ''; + const s = Math.round((Date.now() - ts) / 1000); + return s < 5 ? 'just now' : s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`; +} + +export default function DeviceBar({ devices, selected, select, status, scanning, onRescan, onManage }) { + const detail = selected + ? [selected.model, selected.softwareVersion && `Roku OS ${selected.softwareVersion}`].filter(Boolean).join(' · ') + : ''; + return ( +
+
+ + {devices.length > 0 ? ( + + ) : ( +
{scanning ? 'Searching your network…' : 'No Roku found yet'}
+ )} + + +
+
+ {STATUS_TEXT[status.state] ?? ''} + {detail && {detail}} +
+
+ ); +} diff --git a/src/components/DevicesDialog.jsx b/src/components/DevicesDialog.jsx new file mode 100644 index 0000000..d7e8258 --- /dev/null +++ b/src/components/DevicesDialog.jsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import Modal from './Modal'; +import { toError } from '../lib/api'; + +export default function DevicesDialog({ devices, selected, onAdd, onScan, onForget, onSelect, scanning, onClose }) { + const [ip, setIp] = useState(''); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const add = async (e) => { + e.preventDefault(); + if (!ip.trim()) return; + setBusy(true); + setMessage(null); + try { + const info = await onAdd(ip.trim()); + setMessage({ tone: 'ok', text: `Added ${info.name}.` }); + setIp(''); + } catch (err) { + setMessage({ tone: 'error', text: toError(err).message }); + } finally { + setBusy(false); + } + }; + + const scan = async () => { + setMessage(null); + const found = await onScan(); + setMessage({ tone: found.length ? 'ok' : 'error', text: found.length ? `Found ${found.length} Roku${found.length > 1 ? 's' : ''}.` : 'No Rokus answered. Try adding one by IP.' }); + }; + + return ( + +
+ +
+ setIp(e.target.value)} autoFocus /> + +
+

On the Roku: Settings › Network › About.

+
+ {message &&

{message.text}

} + + {devices.length > 0 && ( +
    + {devices.map((d) => ( +
  • + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx new file mode 100644 index 0000000..e64ae4b --- /dev/null +++ b/src/components/Footer.jsx @@ -0,0 +1,21 @@ +import { APP_VERSION, REPO, WEBSITE, isWindows, openLink } from '../lib/links'; + +export default function Footer({ onReport, onIdea, onRate }) { + return ( +
+
Brought to you ad-free by
+ +
WE MAKE DOPE SHIT!
+ +
v{APP_VERSION} · Free & open source (MIT) · No ads · No tracking
+
+ ); +} diff --git a/src/components/HelpDialog.jsx b/src/components/HelpDialog.jsx new file mode 100644 index 0000000..4baaced --- /dev/null +++ b/src/components/HelpDialog.jsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import Modal from './Modal'; +import { SHORTCUT_GROUPS } from '../lib/shortcuts'; +import { APP_VERSION, REPO, WEBSITE, diagnostics, openLink } from '../lib/links'; + +const TABS = [ + ['fix', 'Fix a problem'], + ['keys', 'Keyboard'], + ['about', 'About'], +]; + +function Shortcuts() { + return ( +
+ {SHORTCUT_GROUPS.map((g) => ( +
+

{g.title}

+
+ {g.items + .filter((item) => item.desc) + .map((item) => ( +
+
+ {item.label} +
+
{item.desc}
+
+ ))} +
+
+ ))} +

Hold an arrow or volume key to keep going. Shortcuts pause while you're typing in a text box.

+
+ ); +} + +function Troubleshooting({ device, lastError, onReport }) { + const [copied, setCopied] = useState(false); + const copy = () => { + navigator.clipboard?.writeText(diagnostics(device, lastError)).then(() => setCopied(true), () => {}); + }; + return ( +
+
+ Only volume works, or buttons show “blocking this app” +

+ Newer Roku software ships in Limited mode. On the TV go to Settings › System › Advanced system settings › Control by + mobile apps and choose Enabled. If you see Network access, pick Default or Permissive — not Limited. +

+
+
+ The app can't find my Roku +
    +
  1. Your computer and Roku must be on the same network — not a guest network.
  2. +
  3. On Windows, open Settings › Network & internet, pick your Wi-Fi and set it to Private.
  4. +
  5. Press ⟳ to search again; the app also scans your network directly if the quick search gets no answer.
  6. +
  7. + Still nothing? Press + and add it by IP. You'll find it on the Roku under Settings › Network › About. +
  8. +
+
+
+ “Turn on” doesn't wake my TV +

+ A Roku TV that's fully off also switches its network off. Turn on Settings › System › Power › Fast TV start and the app can + switch it on any time. Without it, the app sends Wake-on-LAN, which works on some TVs (best over Ethernet) — it needs to have seen + the TV on at least once. +

+
+
+ It worked before and now it doesn't +

+ Your router probably gave the Roku a new address. The app notices and finds it again on its own; you can also press ⟳. +

+
+
+ + +
+

Reports open on GitHub in your browser, pre-filled with your Roku's model and software version (never its IP, name or serial). Nothing is sent until you press Submit there.

+
+ ); +} + +function About() { + return ( +
+

+ Roku Control {APP_VERSION} — a free, open-source remote for Roku TVs and players. No ads, no accounts, no tracking: the app + only ever talks to the Rokus on your own network. +

+
+ + +
+

MIT licensed · Roku is a trademark of Roku, Inc. This app is not affiliated with Roku.

+
+ ); +} + +export default function HelpDialog({ initialTab = 'fix', device, lastError, onReport, onClose }) { + const [tab, setTab] = useState(initialTab); + return ( + +
+ {TABS.map(([id, label]) => ( + + ))} +
+ {tab === 'fix' && } + {tab === 'keys' && } + {tab === 'about' && } +
+ ); +} diff --git a/src/components/Keyboard.jsx b/src/components/Keyboard.jsx new file mode 100644 index 0000000..cd6e7f4 --- /dev/null +++ b/src/components/Keyboard.jsx @@ -0,0 +1,102 @@ +import { forwardRef, useRef, useState } from 'react'; + +const chars = (s) => Array.from(s); + +/** + * Live typing: every character you type shows up in the TV's search box as + * you type it. We track what we've sent, and turn any edit (typing, paste, + * deleting, fixing a typo in the middle) into Backspaces plus new letters. + * Keyed by device in App, so switching Rokus starts from an empty box. + */ +const Keyboard = forwardRef(function Keyboard({ device, press, typeText }, inputRef) { + const [value, setValue] = useState(''); + const [focused, setFocused] = useState(false); + const sent = useRef(''); + const composing = useRef(false); + + const sync = (next) => { + const a = chars(sent.current); + const b = chars(next); + let same = 0; + while (same < a.length && same < b.length && a[same] === b[same]) same++; + for (let i = same; i < a.length; i++) press('Backspace'); + const added = b.slice(same).join(''); + if (added) typeText(added); + sent.current = next; + }; + + const reset = () => { + sent.current = ''; + setValue(''); + }; + + const onKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + press('Enter'); + reset(); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.currentTarget.blur(); + } + }; + + const backspace = () => { + press('Backspace'); + const next = chars(sent.current).slice(0, -1).join(''); + sent.current = next; + setValue(next); + }; + + const clear = () => { + for (let i = 0; i < chars(sent.current).length; i++) press('Backspace'); + reset(); + }; + + return ( +
+
+

⌨ Type on your TV

+ {focused ? 'Typing mode · Esc for remote keys' : 'Press / to start typing'} +
+
+ setFocused(true)} + onBlur={() => setFocused(false)} + onChange={(e) => { + setValue(e.target.value); + if (!composing.current) sync(e.target.value); + }} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={(e) => { + composing.current = false; + sync(e.currentTarget.value); + }} + onKeyDown={onKeyDown} + /> + + +
+ {value && ( + + )} +
+ ); +}); + +export default Keyboard; diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx new file mode 100644 index 0000000..ae975f9 --- /dev/null +++ b/src/components/Modal.jsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; + +export default function Modal({ title, onClose, children }) { + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [onClose]); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/src/components/RatePrompt.jsx b/src/components/RatePrompt.jsx new file mode 100644 index 0000000..b1cc96f --- /dev/null +++ b/src/components/RatePrompt.jsx @@ -0,0 +1,32 @@ +import Modal from './Modal'; +import { isWindows, openLink, rateUrl } from '../lib/links'; + +// Shown once, after ten minutes of use — see lib/usage.js. +export default function RatePrompt({ onIdea, onClose }) { + return ( + +
+
+ ★★★★★ +
+

+ Roku Control is free and open source, with no ads, no tracking and no upsells — we don't make a cent from you. But your + input is what helps this grow. +

+

A quick rating helps other people find the app, and your ideas decide what we build next.

+
+ + + +
+

We'll only ask this once.

+
+
+ ); +} diff --git a/src/components/Remote.jsx b/src/components/Remote.jsx new file mode 100644 index 0000000..316ffb6 --- /dev/null +++ b/src/components/Remote.jsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import RemoteButton from './RemoteButton'; + +const FALLBACK_INPUTS = [ + { key: 'InputTuner', label: 'Antenna TV' }, + { key: 'InputHDMI1', label: 'HDMI 1' }, + { key: 'InputHDMI2', label: 'HDMI 2' }, + { key: 'InputHDMI3', label: 'HDMI 3' }, + { key: 'InputHDMI4', label: 'HDMI 4' }, + { key: 'InputAV1', label: 'AV' }, +]; + +export default function Remote({ device, press, launch, flash, powerOn, waking, inputs, compact }) { + const [showInputs, setShowInputs] = useState(false); + // Unknown (e.g. a Limited-mode Roku we couldn't query) counts as a TV. + const isTv = device?.isTv !== false; + const key = (rokuKey, children, props = {}) => ( + + {children} + + ); + + return ( +
+
+ + {key('PowerOff', '⏻ Turn off', { className: 'btn power-off', title: 'Turn off' })} + {!compact && device?.supportsFindRemote !== false && + key('FindRemote', '🔔', { className: 'btn ghost square', title: 'Find my remote (makes it beep)', 'aria-label': 'Find remote' })} +
+ +
+ {key('Back', '← Back', { className: 'btn', title: 'Back (Backspace / Esc)' })} + {key('Home', '⌂ Home', { className: 'btn', title: 'Home (H)' })} +
+ +
+ {key('Up', '▲', { className: 'dbtn up', repeat: true, title: 'Up (↑)', 'aria-label': 'Up' })} + {key('Left', '◀', { className: 'dbtn left', repeat: true, title: 'Left (←)', 'aria-label': 'Left' })} + {key('Select', 'OK', { className: 'dbtn ok', title: 'OK (Enter)' })} + {key('Right', '▶', { className: 'dbtn right', repeat: true, title: 'Right (→)', 'aria-label': 'Right' })} + {key('Down', '▼', { className: 'dbtn down', repeat: true, title: 'Down (↓)', 'aria-label': 'Down' })} +
+ +
+ {key('InstantReplay', '↺', { className: 'btn round', title: 'Instant replay (R)', 'aria-label': 'Instant replay' })} + {key('Info', '✱', { className: 'btn round', title: 'Options (I or *)', 'aria-label': 'Options' })} + {key('Search', '🔍', { className: 'btn round', title: 'Search (S)', 'aria-label': 'Search' })} +
+ +
+ {key('Rev', '⏪', { className: 'btn round', repeat: true, title: 'Rewind (,)', 'aria-label': 'Rewind' })} + {key('Play', '⏯', { className: 'btn round play', title: 'Play / Pause (Space)', 'aria-label': 'Play or pause' })} + {key('Fwd', '⏩', { className: 'btn round', repeat: true, title: 'Fast forward (.)', 'aria-label': 'Fast forward' })} +
+ +
+ {key('VolumeMute', '🔇', { className: 'vbtn', title: 'Mute (M)', 'aria-label': 'Mute' })} + {key('VolumeDown', '−', { className: 'vbtn wide', repeat: true, title: 'Volume down (−) — hold to keep going', 'aria-label': 'Volume down' })} + VOL + {key('VolumeUp', '+', { className: 'vbtn wide', repeat: true, title: 'Volume up (+) — hold to keep going', 'aria-label': 'Volume up' })} +
+ + {isTv && !compact && ( + <> +
+ {key('ChannelDown', 'Ch −', { className: 'btn', repeat: true, title: 'Channel down (Page Down)' })} + {key('ChannelUp', 'Ch +', { className: 'btn', repeat: true, title: 'Channel up (Page Up)' })} + +
+ {showInputs && ( +
+ {inputs.length > 0 + ? inputs.map((inp) => ( + + )) + : FALLBACK_INPUTS.map((inp) => ( + + ))} +
+ )} + + )} +
+ ); +} diff --git a/src/components/RemoteButton.jsx b/src/components/RemoteButton.jsx new file mode 100644 index 0000000..d69168d --- /dev/null +++ b/src/components/RemoteButton.jsx @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; + +const HOLD_DELAY_MS = 450; +const REPEAT_MS = 130; + +/** + * A remote key. Click to press once; with `repeat`, hold to keep pressing + * (scrolling a long list, stepping the volume). It never takes focus on + * click, so Enter on the keyboard still means OK instead of re-clicking it. + */ +export default function RemoteButton({ rokuKey, onPress, repeat = false, flash, className = '', children, ...rest }) { + const timers = useRef({}); + const held = useRef(false); + + const stop = () => { + clearTimeout(timers.current.delay); + clearInterval(timers.current.every); + timers.current = {}; + }; + useEffect(() => stop, []); + + const onPointerDown = (e) => { + if (!repeat || e.button !== 0) return; + held.current = false; + stop(); + timers.current.delay = setTimeout(() => { + held.current = true; + onPress(rokuKey, { droppable: true }); + timers.current.every = setInterval(() => onPress(rokuKey, { droppable: true }), REPEAT_MS); + }, HOLD_DELAY_MS); + }; + + const onClick = () => { + if (held.current) { + held.current = false; + return; + } + onPress(rokuKey); + }; + + return ( + + ); +} diff --git a/src/components/TitleBar.jsx b/src/components/TitleBar.jsx new file mode 100644 index 0000000..e07c407 --- /dev/null +++ b/src/components/TitleBar.jsx @@ -0,0 +1,45 @@ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { exit } from '@tauri-apps/plugin-process'; + +const win = () => getCurrentWindow(); + +// Frameless window: drag anywhere on the bar that isn't a button. +function startDrag(e) { + if (e.button !== 0 || e.target.closest('button')) return; + win().startDragging().catch(() => {}); +} + +export default function TitleBar({ deviceName, compact, onToggleCompact, onHelp }) { + return ( +
+
+ + ⏻ + + Roku Control + {deviceName && !compact && · {deviceName}} +
+
+ + + + +
+
+ ); +} diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..e6e5872 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,8 @@ +export default function Toast({ toast, onClose }) { + if (!toast) return null; + return ( +
+ {toast.message} +
+ ); +} diff --git a/src/hooks/useRoku.js b/src/hooks/useRoku.js new file mode 100644 index 0000000..31daf14 --- /dev/null +++ b/src/hooks/useRoku.js @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { api, createSender, toError } from '../lib/api'; +import { load, save } from '../lib/storage'; +import { usage } from '../lib/usage'; + +const POLL_MS = 8000; +const REDISCOVER_MS = 60_000; +const VOLUME_KEYS = new Set(['VolumeUp', 'VolumeDown', 'VolumeMute']); + +const uniq = (xs) => [...new Set(xs.filter(Boolean))]; + +/** + * Fold a device-info result into the saved list. Rokus are matched by serial, + * so when the router hands one a new IP the saved entry follows it instead of + * stranding the user on a dead address. + */ +export function mergeDevice(list, info, extra = {}) { + const i = list.findIndex((d) => + info.serial ? d.serial === info.serial || (!d.serial && d.ip === info.ip) : d.ip === info.ip, + ); + const prev = i >= 0 ? list[i] : null; + const { wifiMac, ethernetMac, ...rest } = info; + const fields = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== '' && v != null)); + // A Roku in Limited mode can come back with only an IP; keep what we knew. + if (prev && !info.serial) delete fields.name; + const record = { + ...prev, + ...fields, + ...extra, + id: prev?.id ?? (info.serial || info.deviceId || `ip:${info.ip}`), + macs: uniq([...(prev?.macs ?? []), wifiMac, ethernetMac]), + lastSeen: Date.now(), + }; + const next = [...list]; + if (i >= 0) next[i] = record; + else next.push(record); + return next; +} + +export function statusFromInfo(info) { + const mode = (info.ecpMode || '').toLowerCase(); + let state = 'online'; + if (mode === 'limited' || mode === 'disabled') state = 'limited'; + else if (info.powerMode && info.powerMode !== 'PowerOn') state = 'standby'; + return { state, checkedAt: Date.now() }; +} + +export function useRoku({ notify }) { + const [devices, setDevices] = useState(() => load('devices', [])); + const [selectedId, setSelectedId] = useState(() => load('selected', null)); + const [status, setStatus] = useState({ state: 'checking' }); + const [scanning, setScanning] = useState(false); + const [waking, setWaking] = useState(false); + const [lastError, setLastError] = useState(null); + + const selected = devices.find((d) => d.id === selectedId) ?? devices[0] ?? null; + const selectedRef = useRef(selected); + selectedRef.current = selected; + const notifyRef = useRef(notify); + notifyRef.current = notify; + + useEffect(() => save('devices', devices), [devices]); + useEffect(() => save('selected', selected?.id ?? null), [selected?.id]); + + const discover = useCallback(async ({ deep = false, quiet = false } = {}) => { + if (!quiet) setScanning(true); + try { + const found = await api.discover(deep); + setDevices((list) => found.reduce((acc, info) => mergeDevice(acc, info), list)); + return found; + } catch (e) { + if (!quiet) notifyRef.current(toError(e).message); + return []; + } finally { + if (!quiet) setScanning(false); + } + }, []); + + const lastRediscover = useRef(0); + const macLookups = useRef(new Set()); + + // A real check against the device every few seconds. The status dot shows + // only what the last check found, never a guess. + const check = useCallback(async () => { + const dev = selectedRef.current; + if (!dev) return; + try { + const info = await api.deviceInfo(dev.ip); + if (dev.serial && info.serial && info.serial !== dev.serial) { + throw { kind: 'unreachable', message: `${dev.name} isn't at ${dev.ip} any more.` }; + } + if (selectedRef.current?.id !== dev.id) return; + setDevices((list) => mergeDevice(list, info)); + setStatus(statusFromInfo(info)); + // Remember the MAC while the TV is on, so Wake-on-LAN can find it later. + if (!info.wifiMac && !info.ethernetMac && !dev.macs?.length && !macLookups.current.has(dev.id)) { + macLookups.current.add(dev.id); + const mac = await api.lookupMac(dev.ip).catch(() => null); + if (mac) setDevices((list) => list.map((d) => (d.id === dev.id ? { ...d, macs: uniq([...(d.macs ?? []), mac]) } : d))); + } + } catch (e) { + const err = toError(e); + if (selectedRef.current?.id !== dev.id) return; + setStatus({ state: err.kind === 'limited' ? 'limited' : 'offline', checkedAt: Date.now(), error: err }); + // Most "it stopped working" reports are a new DHCP address. Go find it. + if (err.kind === 'unreachable' && dev.serial && Date.now() - lastRediscover.current > REDISCOVER_MS) { + lastRediscover.current = Date.now(); + const found = await discover({ quiet: true }); + const moved = found.find((f) => f.serial === dev.serial); + if (moved && selectedRef.current?.id === dev.id) setStatus(statusFromInfo(moved)); + } + } + }, [discover]); + + // First launch: look for Rokus right away (the old build waited for a click). + const started = useRef(false); + useEffect(() => { + if (started.current) return; + started.current = true; + discover({ quiet: devices.length > 0 }); + }, [discover, devices.length]); + + useEffect(() => { + if (!selected) return undefined; + setStatus({ state: 'checking' }); + check(); + const id = setInterval(() => { + if (document.visibilityState === 'visible') check(); + }, POLL_MS); + return () => clearInterval(id); + // Re-arm when the device or its address changes, not on every field update. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected?.id, selected?.ip]); + + const send = useMemo( + () => + createSender((err, meta) => { + if (!err) { + usage.recordCommand(); + setLastError(null); + // Volume works even in Limited mode; anything else getting through means it's fixed. + if (meta && !VOLUME_KEYS.has(meta)) { + setStatus((s) => (s.state === 'limited' || s.state === 'offline' ? { state: 'online', checkedAt: Date.now() } : s)); + } + return; + } + setLastError(err); + if (err.kind === 'limited') setStatus((s) => ({ ...s, state: 'limited', error: err })); + else if (err.kind === 'unreachable') { + setStatus((s) => ({ ...s, state: 'offline', error: err })); + check(); + } else notifyRef.current(err.message); + }), + [check], + ); + + const press = useCallback( + (key, opts) => { + const dev = selectedRef.current; + if (!dev) return Promise.resolve(false); + return send(() => api.keypress(dev.ip, key), { ...opts, meta: key }); + }, + [send], + ); + + const typeText = useCallback( + (text) => { + const dev = selectedRef.current; + if (!dev || !text) return Promise.resolve(false); + return send(() => api.sendText(dev.ip, text), { meta: 'text' }); + }, + [send], + ); + + const launch = useCallback( + (appId) => { + const dev = selectedRef.current; + if (!dev) return Promise.resolve(false); + return send(() => api.launch(dev.ip, appId), { meta: 'launch' }); + }, + [send], + ); + + const powerOn = useCallback(async () => { + const dev = selectedRef.current; + if (!dev || waking) return; + setWaking(true); + try { + const report = await api.powerOn(dev.ip, dev.macs ?? []); + usage.recordCommand(); + notifyRef.current(report.method === 'wakeOnLan' ? `${dev.name} woke up.` : `Turning on ${dev.name}…`, 'info'); + setTimeout(check, 1500); + } catch (e) { + const err = toError(e); + setLastError(err); + if (err.kind === 'limited') setStatus((s) => ({ ...s, state: 'limited', error: err })); + else notifyRef.current(err.message, 'error', 12000); + } finally { + setWaking(false); + } + }, [check, waking]); + + const devicesRef = useRef(devices); + devicesRef.current = devices; + + const addByIp = useCallback(async (ip) => { + const info = await api.deviceInfo(ip); + const next = mergeDevice(devicesRef.current, info, { manual: true }); + setDevices(next); + const added = next.find((d) => (info.serial && d.serial === info.serial) || d.ip === info.ip); + if (added) setSelectedId(added.id); + return info; + }, []); + + const forget = useCallback((id) => { + setDevices((list) => list.filter((d) => d.id !== id)); + }, []); + + return { + devices, + selected, + select: setSelectedId, + status: selected ? status : { state: scanning ? 'scanning' : 'none' }, + scanning, + waking, + lastError, + discover, + check, + press, + typeText, + launch, + powerOn, + addByIp, + forget, + }; +} diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..37deafe --- /dev/null +++ b/src/lib/api.js @@ -0,0 +1,54 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** Anything `invoke` throws, as `{ kind, message }` (the Rust side sends that shape). */ +export function toError(e) { + if (e && typeof e === 'object' && typeof e.kind === 'string') return e; + return { kind: 'other', message: String(e?.message ?? e) }; +} + +export const api = { + discover: (deep = false) => invoke('discover', { deep }), + deviceInfo: (ip) => invoke('device_info', { ip }), + apps: (ip) => invoke('get_apps', { ip }), + activeApp: (ip) => invoke('active_app', { ip }), + appIcon: (ip, appId) => invoke('app_icon', { ip, appId }), + keypress: (ip, key) => invoke('keypress', { ip, key }), + launch: (ip, appId) => invoke('launch', { ip, appId }), + sendText: (ip, text) => invoke('send_text', { ip, text }), + powerOn: (ip, macs) => invoke('power_on', { ip, macs }), + lookupMac: (ip) => invoke('lookup_mac', { ip }), + openLink: (url) => invoke('open_link', { url }), +}; + +/** + * Commands to a Roku must land in order ("Down, Down, OK" is not "OK, Down, + * Down"), so they run one at a time. When one fails, everything still queued + * is dropped, so a TV that comes back doesn't replay a backlog of presses. + */ +export function createSender(onResult) { + let chain = Promise.resolve(); + let pending = 0; + let generation = 0; + + return function enqueue(task, { droppable = false, meta } = {}) { + // Held keys repeat fast; don't let them pile up behind a slow TV. + if (droppable && pending >= 4) return Promise.resolve(false); + pending++; + const gen = generation; + chain = chain.then(async () => { + try { + if (gen !== generation) return false; + await task(); + onResult(null, meta); + return true; + } catch (e) { + generation++; + onResult(toError(e), meta); + return false; + } finally { + pending--; + } + }); + return chain; + }; +} diff --git a/src/lib/links.js b/src/lib/links.js new file mode 100644 index 0000000..0e9c6b5 --- /dev/null +++ b/src/lib/links.js @@ -0,0 +1,63 @@ +/* global __APP_VERSION__ */ +import { api } from './api'; + +export const APP_VERSION = __APP_VERSION__; +export const WEBSITE = 'https://moveweight.com'; +export const REPO = 'https://github.com/BlizzHacker/RokuControl'; +export const STORE_REVIEW = 'ms-windows-store://review/?ProductId=9MTNNGZKJ1WK'; +export const isWindows = /Windows/i.test(navigator.userAgent); + +export function openLink(url) { + return api.openLink(url).catch(() => { + // Running in a plain browser (npm run dev) — fall back to a new tab. + window.open(url, '_blank', 'noopener'); + }); +} + +export function osName() { + const ua = navigator.userAgent; + if (/Windows NT 10/.test(ua)) return 'Windows 10/11'; + if (/Windows/.test(ua)) return 'Windows'; + if (/Mac OS X/.test(ua)) return 'macOS'; + if (/Linux/.test(ua)) return 'Linux'; + return 'Unknown'; +} + +const redactIps = (s) => String(s ?? '').replace(/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, ''); + +/** What a bug report needs, minus anything personal: no IPs, serials, MACs or names. */ +export function diagnostics(device, lastError) { + const lines = [`Roku Control ${APP_VERSION} on ${osName()}`]; + if (device) { + const model = [device.vendor, device.model, device.modelNumber].filter(Boolean).join(' '); + lines.push(`Roku: ${model || 'model unknown'}${device.isTv ? ' (TV)' : ''}`); + lines.push(`Roku OS: ${device.softwareVersion || 'unknown'}`); + lines.push(`Control by mobile apps: ${device.ecpMode || 'not reported'}`); + lines.push(`Power mode: ${device.powerMode || 'unknown'} · Network: ${device.networkType || 'unknown'}`); + } else { + lines.push('Roku: none found'); + } + if (lastError) lines.push(`Last error (${lastError.kind}): ${redactIps(lastError.message)}`); + return lines.join('\n'); +} + +const query = (params) => + Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join('&'); + +/** A GitHub "new issue" link that opens the matching form with the details filled in. */ +export function issueUrl(type, { device, lastError } = {}) { + if (type === 'bug') { + return `${REPO}/issues/new?${query({ + template: 'bug_report.yml', + title: '[Bug]: ', + version: APP_VERSION, + environment: diagnostics(device, lastError), + })}`; + } + return `${REPO}/issues/new?${query({ template: 'feature_request.yml', title: '[Idea]: ', version: APP_VERSION })}`; +} + +/** Windows users rate in the Store; everyone else can star the repo. */ +export const rateUrl = () => (isWindows ? STORE_REVIEW : REPO); diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js new file mode 100644 index 0000000..90dbf71 --- /dev/null +++ b/src/lib/shortcuts.js @@ -0,0 +1,53 @@ +// Keyboard shortcuts that work whenever you're not typing into a text box. +// Keys are KeyboardEvent.key values; single characters are matched lowercase. +export const SHORTCUT_GROUPS = [ + { + title: 'Navigate', + items: [ + { keys: ['ArrowUp'], label: '↑ ↓ ← →', roku: 'Up', desc: 'Move' }, + { keys: ['ArrowDown'], roku: 'Down' }, + { keys: ['ArrowLeft'], roku: 'Left' }, + { keys: ['ArrowRight'], roku: 'Right' }, + { keys: ['Enter'], label: 'Enter', roku: 'Select', desc: 'OK' }, + { keys: ['Backspace', 'Escape'], label: 'Backspace / Esc', roku: 'Back', desc: 'Back' }, + { keys: ['h', 'Home'], label: 'H', roku: 'Home', desc: 'Home' }, + { keys: ['i', '*'], label: 'I or *', roku: 'Info', desc: 'Options (✱)' }, + { keys: ['s'], label: 'S', roku: 'Search', desc: 'Search' }, + ], + }, + { + title: 'Playback', + items: [ + { keys: [' ', 'p'], label: 'Space or P', roku: 'Play', desc: 'Play / Pause' }, + { keys: [',', '<', '['], label: ', or [', roku: 'Rev', desc: 'Rewind' }, + { keys: ['.', '>', ']'], label: '. or ]', roku: 'Fwd', desc: 'Fast forward' }, + { keys: ['r'], label: 'R', roku: 'InstantReplay', desc: 'Instant replay' }, + ], + }, + { + title: 'Volume & TV', + items: [ + { keys: ['+', '='], label: '+', roku: 'VolumeUp', desc: 'Volume up' }, + { keys: ['-', '_'], label: '−', roku: 'VolumeDown', desc: 'Volume down' }, + { keys: ['m'], label: 'M', roku: 'VolumeMute', desc: 'Mute' }, + { keys: ['PageUp'], label: 'Page Up / Down', roku: 'ChannelUp', desc: 'Channel up / down' }, + { keys: ['PageDown'], roku: 'ChannelDown' }, + ], + }, + { + title: 'App', + items: [ + { keys: ['/', 't'], label: '/ or T', action: 'type', desc: 'Type on the TV' }, + { keys: ['?'], label: '?', action: 'help', desc: 'Show these shortcuts' }, + ], + }, +]; + +export const SHORTCUTS = new Map( + SHORTCUT_GROUPS.flatMap((g) => g.items).flatMap((item) => item.keys.map((k) => [k, item])), +); + +// Keys that make sense to hold down. +export const REPEATABLE = new Set(['Up', 'Down', 'Left', 'Right', 'VolumeUp', 'VolumeDown', 'Rev', 'Fwd', 'ChannelUp', 'ChannelDown']); + +export const normalizeKey = (key) => (key.length === 1 ? key.toLowerCase() : key); diff --git a/src/lib/storage.js b/src/lib/storage.js new file mode 100644 index 0000000..f97a509 --- /dev/null +++ b/src/lib/storage.js @@ -0,0 +1,20 @@ +// localStorage can throw (private mode, full disk, locked-down WebView), and +// nothing in this app is worth crashing over, so every access is guarded. +const PREFIX = 'rokucontrol.'; + +export function load(key, fallback) { + try { + const raw = localStorage.getItem(PREFIX + key); + return raw == null ? fallback : JSON.parse(raw); + } catch { + return fallback; + } +} + +export function save(key, value) { + try { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + } catch { + // Non-essential; the app works without persistence. + } +} diff --git a/src/lib/usage.js b/src/lib/usage.js new file mode 100644 index 0000000..b04d392 --- /dev/null +++ b/src/lib/usage.js @@ -0,0 +1,29 @@ +import { load, save } from './storage'; + +// One-time "rate us" prompt: after 10 minutes of actual use, and only once +// the app has worked for this person (asking someone it failed for to rate +// it helps nobody). +export const RATE_AFTER_MS = 10 * 60 * 1000; +const MIN_COMMANDS = 5; +const KEY = 'usage'; + +const state = { activeMs: 0, commands: 0, ratePromptShown: false, ...load(KEY, {}) }; +const persist = () => save(KEY, state); + +export const usage = { + recordCommand() { + state.commands += 1; + persist(); + }, + addActiveTime(ms) { + state.activeMs += ms; + persist(); + }, + shouldAskForRating() { + return !state.ratePromptShown && state.activeMs >= RATE_AFTER_MS && state.commands >= MIN_COMMANDS; + }, + markRatingAsked() { + state.ratePromptShown = true; + persist(); + }, +}; diff --git a/src/main.jsx b/src/main.jsx index 9d4268d..1e52c6e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,11 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; - -const isWidget = window.location.hash === '#/widget'; +import './styles.css'; ReactDOM.createRoot(document.getElementById('root')).render( - - + + , ); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..4a33a45 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,355 @@ +:root { + --bg: #08080f; + --bg2: #0f0f1a; + --bg3: #16162a; + --bg4: #1d1d38; + --purple: #8b5cf6; + --purple-glow: #a78bfa; + --purple-dark: #5b21b6; + --text: #e8ecf1; + --text-dim: #8e9ab0; + --border: #23233f; + --green: #22c55e; + --amber: #f59e0b; + --red: #ef4444; + --radius: 16px; + --radius-sm: 10px; + color-scheme: dark; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html { background: transparent; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + overflow: hidden; + height: 100vh; + user-select: none; + border-radius: 14px; + border: 1px solid rgba(139, 92, 246, 0.18); +} + +#root, .app { height: 100%; display: flex; flex-direction: column; } + +button { font: inherit; color: inherit; } +button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { + outline: 2px solid var(--purple-glow); + outline-offset: 2px; +} +input, select { user-select: text; } + +.spin { display: inline-block; animation: spin 0.9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Title bar ── */ +.titlebar { + display: flex; align-items: center; justify-content: space-between; + padding: 6px 8px 6px 14px; flex-shrink: 0; + background: linear-gradient(180deg, rgba(139, 92, 246, 0.1) 0%, transparent 100%); + border-bottom: 1px solid var(--border); + -webkit-app-region: drag; + cursor: grab; +} +.titlebar-left { display: flex; align-items: center; gap: 8px; min-width: 0; } +.logo { + width: 22px; height: 22px; border-radius: 7px; flex-shrink: 0; + display: grid; place-items: center; font-size: 12px; color: #fff; + background: linear-gradient(135deg, var(--purple-dark), var(--purple)); +} +.titlebar-text { font-weight: 700; font-size: 12px; color: var(--text-dim); letter-spacing: 0.5px; white-space: nowrap; } +.titlebar-device { font-size: 12px; color: var(--purple-glow); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.titlebar-buttons { display: flex; gap: 2px; -webkit-app-region: no-drag; } +.tb-btn { + width: 30px; height: 28px; border: none; border-radius: 8px; + background: transparent; color: var(--text-dim); font-size: 13px; font-weight: 700; + cursor: pointer; display: grid; place-items: center; transition: background 0.15s, color 0.15s; + -webkit-app-region: no-drag; +} +.tb-btn:hover { background: var(--bg3); color: var(--text); } +.tb-btn.on { background: var(--purple-dark); color: #fff; } +.tb-btn.close:hover { background: var(--red); color: #fff; } + +/* ── Device bar ── */ +.device-bar { + padding: 8px 12px 6px; background: var(--bg2); + border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.device-row { display: flex; align-items: center; gap: 8px; } +.device-select { + flex: 1; min-width: 0; background: var(--bg3); border: 1px solid var(--border); + border-radius: var(--radius-sm); color: var(--text); padding: 8px 10px; + font-size: 13px; cursor: pointer; outline: none; transition: border-color 0.2s; +} +.device-select:focus { border-color: var(--purple); } +.device-select.empty { color: var(--text-dim); cursor: default; } +.device-status { display: flex; gap: 8px; align-items: baseline; margin-top: 5px; font-size: 11px; padding-left: 17px; min-height: 14px; } +.status-text { font-weight: 600; white-space: nowrap; } +.status-text.online { color: var(--green); } +.status-text.standby { color: var(--amber); } +.status-text.offline, .status-text.none { color: #fca5a5; } +.status-text.limited { color: var(--amber); } +.status-text.checking, .status-text.scanning { color: var(--text-dim); } +.status-detail { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; background: #4b5563; } +.dot.online { background: var(--green); box-shadow: 0 0 8px rgba(34, 197, 94, 0.6); } +.dot.standby, .dot.limited { background: var(--amber); box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); } +.dot.offline, .dot.none { background: var(--red); } +.dot.checking, .dot.scanning { background: var(--text-dim); animation: pulse 1s ease-in-out infinite; } +@keyframes pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } } + +.icon-btn { + width: 36px; height: 36px; flex-shrink: 0; border-radius: var(--radius-sm); + background: var(--bg3); border: 1px solid var(--border); color: var(--text-dim); + font-size: 16px; cursor: pointer; display: grid; place-items: center; transition: all 0.15s; +} +.icon-btn:hover:not(:disabled) { color: var(--text); border-color: var(--purple); } +.icon-btn:disabled { opacity: 0.5; cursor: default; } + +/* ── Main column ── */ +.main { + flex: 1; overflow-y: auto; overflow-x: hidden; padding: 12px 14px 8px; + display: flex; flex-direction: column; gap: 12px; align-items: stretch; +} +.main::-webkit-scrollbar { width: 5px; } +.main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── Banners ── */ +.banner { + border-radius: var(--radius-sm); padding: 10px 12px; font-size: 12px; line-height: 1.45; + border: 1px solid; user-select: text; +} +.banner strong { display: block; font-size: 13px; margin-bottom: 3px; } +.banner p, .banner ol { color: var(--text); opacity: 0.9; } +.banner ol { padding-left: 18px; margin-top: 4px; } +.banner.warn { background: rgba(245, 158, 11, 0.09); border-color: rgba(245, 158, 11, 0.35); } +.banner.warn strong { color: #fcd34d; } +.banner.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.3); } +.banner.error strong { color: #fca5a5; } +.banner.info { background: rgba(139, 92, 246, 0.08); border-color: rgba(139, 92, 246, 0.3); } +.banner.info strong { color: var(--purple-glow); } +.banner-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } + +/* ── Buttons ── */ +.btn { + padding: 10px 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--bg3); color: var(--text); font-size: 14px; font-weight: 500; + cursor: pointer; transition: background 0.1s, border-color 0.1s, box-shadow 0.1s, transform 0.05s; + white-space: nowrap; display: inline-flex; align-items: center; justify-content: center; gap: 6px; +} +.btn:hover:not(:disabled) { background: var(--bg4); border-color: var(--purple); box-shadow: 0 2px 12px rgba(139, 92, 246, 0.2); } +.btn:active:not(:disabled), .btn.pressed { background: var(--purple); border-color: var(--purple-glow); transform: scale(0.96); } +.btn:disabled { opacity: 0.5; cursor: default; } +.btn.primary { background: linear-gradient(135deg, var(--purple-dark) 0%, #7c3aed 100%); border-color: var(--purple); font-weight: 600; } +.btn.primary:hover:not(:disabled) { box-shadow: 0 0 18px rgba(139, 92, 246, 0.45); } +.btn.ghost { background: transparent; } +.btn.small { padding: 7px 12px; font-size: 12px; } +.btn.on { background: var(--purple-dark); border-color: var(--purple); } +.btn.square { padding: 10px 12px; } +.btn.round { width: 54px; height: 44px; padding: 0; font-size: 17px; border-radius: 14px; } +.btn.round.play { width: 76px; background: linear-gradient(135deg, var(--purple-dark) 0%, #7c3aed 100%); border-color: var(--purple); } +.wide-btn { width: 100%; margin-top: 10px; } + +.link-btn { + background: none; border: none; color: var(--purple-glow); cursor: pointer; + font-size: inherit; padding: 0; text-decoration: underline; text-underline-offset: 2px; +} +.link-btn:hover { color: #c4b5fd; } + +/* ── Remote ── */ +.remote { display: flex; flex-direction: column; align-items: center; gap: 10px; transition: opacity 0.2s; } +.remote.disabled { opacity: 0.4; pointer-events: none; } +.row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } +.power-row { display: flex; gap: 8px; width: 100%; } +.power-row .power-on, .power-row .power-off { flex: 1; } +.power-on { border-color: rgba(34, 197, 94, 0.45); } +.power-on:hover:not(:disabled) { border-color: var(--green) !important; box-shadow: 0 0 14px rgba(34, 197, 94, 0.25) !important; } +.power-off { border-color: rgba(239, 68, 68, 0.4); } +.power-off:hover { border-color: var(--red) !important; box-shadow: 0 0 14px rgba(239, 68, 68, 0.25) !important; } +.nav-row { display: flex; gap: 8px; width: 100%; max-width: 262px; } +.nav-row .btn { flex: 1; } + +.dpad { + display: grid; gap: 7px; + grid-template: 'a up b' 72px 'left ok right' 72px 'c down d' 72px / 72px 72px 72px; +} +.dbtn { + border: 1px solid var(--border); border-radius: 16px; + background: linear-gradient(135deg, var(--bg3) 0%, #1a1a35 100%); + color: var(--text); font-size: 22px; cursor: pointer; touch-action: none; + display: grid; place-items: center; transition: transform 0.08s, box-shadow 0.1s, border-color 0.1s; +} +.dbtn:hover { border-color: var(--purple); box-shadow: 0 4px 16px rgba(139, 92, 246, 0.25); } +.dbtn:active, .dbtn.pressed { transform: scale(0.93); background: var(--purple-dark); border-color: var(--purple-glow); } +.dbtn.up { grid-area: up; } .dbtn.down { grid-area: down; } +.dbtn.left { grid-area: left; } .dbtn.right { grid-area: right; } +.dbtn.ok { + grid-area: ok; border-radius: 50%; font-weight: 800; font-size: 16px; letter-spacing: 1px; + background: linear-gradient(135deg, var(--purple-dark) 0%, #7c3aed 100%); border-color: var(--purple); +} +.dbtn.ok:active, .dbtn.ok.pressed { background: var(--purple); box-shadow: 0 0 22px rgba(139, 92, 246, 0.6); } + +.volume { + display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center; + padding: 6px; background: var(--bg2); border: 1px solid var(--border); border-radius: 999px; +} +.vbtn { + height: 40px; min-width: 40px; border: 1px solid transparent; border-radius: 999px; + background: var(--bg3); color: var(--text); font-size: 18px; font-weight: 600; cursor: pointer; + display: grid; place-items: center; touch-action: none; transition: background 0.1s, transform 0.05s; +} +.vbtn.wide { min-width: 76px; font-size: 22px; } +.vbtn:hover { background: var(--purple-dark); } +.vbtn:active, .vbtn.pressed { background: var(--purple); transform: scale(0.95); } +.vol-label { font-size: 10px; font-weight: 700; letter-spacing: 1.5px; color: var(--text-dim); } +.inputs { padding: 2px 0; } + +/* ── Cards ── */ +.card { + background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); + padding: 10px 12px 12px; +} +.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } +.card-head h2 { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 6px; white-space: nowrap; } +.hint { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.count { font-size: 10px; padding: 1px 7px; border-radius: 999px; background: var(--bg4); color: var(--text-dim); } +.muted { font-size: 12px; color: var(--text-dim); } + +.keyboard.active { border-color: var(--purple); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15); } +.keyboard .link-btn { font-size: 11px; margin-top: 6px; } +.kb-row { display: flex; gap: 6px; } +.kb-row input, .filter, .add-form input { + flex: 1; min-width: 0; background: var(--bg3); border: 1px solid var(--border); + border-radius: var(--radius-sm); color: var(--text); padding: 9px 11px; font-size: 13px; outline: none; +} +.kb-row input:focus, .filter:focus, .add-form input:focus { border-color: var(--purple); } +.filter { flex: 0 1 160px; padding: 6px 10px; font-size: 12px; } + +.app-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: 8px; max-height: 300px; overflow-y: auto; padding: 2px; +} +.app-grid::-webkit-scrollbar { width: 4px; } +.app-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +.app-tile { + position: relative; display: flex; flex-direction: column; gap: 4px; + padding: 5px; background: var(--bg3); border: 1px solid var(--border); + border-radius: var(--radius-sm); cursor: pointer; text-align: center; transition: all 0.12s; +} +.app-tile:hover { border-color: var(--purple); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(139, 92, 246, 0.2); } +.app-tile.active { border-color: var(--green); box-shadow: 0 0 0 1px var(--green); } +.app-tile img, .app-fallback { + width: 100%; aspect-ratio: 4 / 3; border-radius: 7px; object-fit: cover; background: var(--bg4); +} +.app-fallback { display: grid; place-items: center; font-size: 28px; } +.app-name { font-size: 11px; color: var(--text-dim); line-height: 1.2; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.badge { + position: absolute; top: 9px; left: 9px; font-size: 9px; font-weight: 700; letter-spacing: 0.4px; + padding: 2px 6px; border-radius: 999px; background: var(--green); color: #052e16; +} + +.tip { font-size: 11px; color: var(--text-dim); text-align: center; } + +/* ── Footer ── */ +.footer { margin-top: auto; padding: 12px 0 4px; text-align: center; display: flex; flex-direction: column; align-items: center; } +.credit { font-size: 10px; color: var(--text-dim); opacity: 0.6; letter-spacing: 0.5px; } +.brand { + background: none; border: none; cursor: pointer; font-size: 12px; font-weight: 800; + color: var(--purple-glow); letter-spacing: 1.2px; margin-top: 1px; +} +.brand:hover { color: #c4b5fd; text-shadow: 0 0 12px rgba(167, 139, 250, 0.6); } +.tagline { font-size: 9px; color: var(--text-dim); opacity: 0.45; margin-top: 1px; letter-spacing: 0.5px; } +.footer-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 4px 12px; margin-top: 10px; } +.footer-links button { + background: none; border: none; cursor: pointer; font-size: 11px; color: var(--text-dim); padding: 2px 0; +} +.footer-links button:hover { color: var(--purple-glow); text-decoration: underline; text-underline-offset: 2px; } +.fine { font-size: 10px; color: var(--text-dim); opacity: 0.6; margin-top: 8px; line-height: 1.4; } + +/* ── Toast ── */ +.toast { + position: fixed; left: 14px; right: 14px; bottom: 14px; z-index: 40; + padding: 10px 14px; border-radius: var(--radius-sm); font-size: 12px; line-height: 1.4; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); cursor: pointer; animation: rise 0.18s ease-out; user-select: text; +} +.toast.error { background: #3b1219; border: 1px solid rgba(239, 68, 68, 0.5); color: #fecaca; } +.toast.info { background: #1e1b4b; border: 1px solid rgba(139, 92, 246, 0.5); color: #ddd6fe; } +@keyframes rise { from { transform: translateY(8px); opacity: 0; } } + +/* ── Modals ── */ +.modal-backdrop { + position: fixed; inset: 0; z-index: 50; background: rgba(4, 4, 10, 0.72); + display: grid; place-items: center; padding: 14px; animation: fade 0.15s ease-out; border-radius: 14px; +} +@keyframes fade { from { opacity: 0; } } +.modal { + width: 100%; max-width: 440px; max-height: 100%; overflow-y: auto; + background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); user-select: text; +} +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 12px 0 16px; } +.modal-head h2 { font-size: 15px; } +.modal-head .icon-btn { width: 30px; height: 30px; font-size: 12px; } +.modal-body { padding: 10px 16px 16px; font-size: 13px; line-height: 1.5; } +.modal-body p + p { margin-top: 8px; } +.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.modal-actions.column { flex-direction: column; } +.modal-actions.column .btn { width: 100%; } + +.tabs { display: flex; gap: 4px; background: var(--bg3); padding: 3px; border-radius: var(--radius-sm); margin-bottom: 12px; } +.tabs button { flex: 1; padding: 6px; border: none; border-radius: 8px; background: none; color: var(--text-dim); font-size: 12px; font-weight: 600; cursor: pointer; } +.tabs button.on { background: var(--purple-dark); color: #fff; } + +.troubleshoot details { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 6px; background: var(--bg3); } +.troubleshoot summary { cursor: pointer; font-weight: 600; font-size: 12.5px; } +.troubleshoot details p, .troubleshoot details ol { margin-top: 6px; font-size: 12px; color: var(--text); opacity: 0.9; } +.troubleshoot ol { padding-left: 18px; } + +.shortcuts h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--purple-glow); margin: 10px 0 4px; } +.shortcuts h3:first-child { margin-top: 0; } +.shortcuts dl > div { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; border-bottom: 1px dashed var(--border); font-size: 12px; } +.shortcuts dd { color: var(--text-dim); text-align: right; } +.shortcuts .muted { margin-top: 10px; } +kbd { + font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; font-size: 11px; padding: 1px 6px; + border-radius: 5px; border: 1px solid var(--border); border-bottom-width: 2px; background: var(--bg3); +} + +.rate { text-align: center; } +.rate .stars { font-size: 30px; color: #fbbf24; letter-spacing: 4px; margin: 2px 0 10px; text-shadow: 0 0 18px rgba(251, 191, 36, 0.35); } + +.add-form label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 6px; } +.add-form .fine { margin-top: 4px; } +.form-msg { font-size: 12px; margin-top: 8px; } +.form-msg.ok { color: var(--green); } +.form-msg.error { color: #fca5a5; } +.device-list { list-style: none; margin-top: 12px; display: flex; flex-direction: column; gap: 6px; } +.device-list li { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg3); +} +.device-list li.current { border-color: var(--purple); } +.device-list .link-btn { font-size: 11px; color: var(--text-dim); } +.device-pick { background: none; border: none; cursor: pointer; text-align: left; display: flex; flex-direction: column; min-width: 0; } +.device-pick b { font-size: 13px; } +.device-pick span { font-size: 11px; color: var(--text-dim); } + +/* ── Mini remote ── */ +.compact .main { padding: 8px 10px; gap: 8px; } +.compact .device-bar { padding: 6px 8px 4px; } +.compact .device-status { display: none; } +.compact .dpad { grid-template: 'a up b' 56px 'left ok right' 56px 'c down d' 56px / 56px 56px 56px; gap: 6px; } +.compact .dbtn { font-size: 17px; border-radius: 13px; } +.compact .btn { padding: 8px 10px; font-size: 13px; } +.compact .btn.round { width: 46px; height: 38px; font-size: 15px; } +.compact .btn.round.play { width: 60px; } +.compact .vbtn { height: 34px; min-width: 34px; } +.compact .vbtn.wide { min-width: 58px; } +.compact .banner { font-size: 11px; padding: 8px 10px; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } +} diff --git a/vite.config.js b/vite.config.js index 8b0f57b..d202ff8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,13 @@ +import { readFileSync } from 'node:fs' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) + // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(version), + }, })