Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ on:
- 'docs/**'
- 'mooninstaller/**'
- 'mkdocs.yml'
# A pull request builds the desktop programs and uploads them as workflow artifacts, to install through a device's Firmware card before the
# merge; the release, container and Pages jobs run on main and tags only, so a pull request publishes nothing. The ESP32 matrix is built
# locally and the Teensy by test.yml, so they stay off pull requests.
pull_request:
paths:
- 'src/**'
- 'test/**'
- 'moondeck/ci/**'
- 'CMakeLists.txt'
- 'library.json'
- '.github/workflows/release.yml'
workflow_dispatch:
inputs:
tag:
Expand All @@ -58,7 +69,8 @@ on:
# their own concurrency group so a tag and a branch can build in parallel.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# A newer push to a pull request replaces its running build; a release always finishes.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# Minimal default permissions for every job (read-only). The release job
# overrides this locally with the write/id-token scopes it needs to publish
Expand Down Expand Up @@ -101,6 +113,7 @@ jobs:
# standard bridge. This is the ONLY firmware list in CI, and the chip is the IDF target.
firmwares:
needs: verify-version
if: github.event_name != 'pull_request' # and build-esp32 with it, which needs this list
runs-on: ubuntu-latest
outputs:
list: ${{ steps.gen.outputs.list }}
Expand Down Expand Up @@ -237,6 +250,10 @@ jobs:
env:
VERSION: ${{ steps.ver.outputs.version }}
run: uv run moondeck/ci/package_desktop.py --version "$VERSION"
# The program installs itself through its own Firmware route and restarts on it, on the system it is built for.
- name: Install its own program and restart on it
shell: bash
run: uv run moondeck/ci/release_stage.py smoke-update --program dist/firmware-desktop-*.bin
- uses: actions/upload-artifact@v4
with:
name: desktop-macos
Expand Down Expand Up @@ -301,9 +318,14 @@ jobs:
}
Write-Host "NSIS present: $found"
- name: Build + package Windows x64
shell: bash # PowerShell would read "$VERSION" as its own, empty variable and package library.json's bare version
env:
VERSION: ${{ steps.ver.outputs.version }}
run: uv run moondeck/ci/package_desktop.py --version "$VERSION"
# The program installs itself through its own Firmware route and restarts on it, on the system it is built for.
- name: Install its own program and restart on it
shell: bash
run: uv run moondeck/ci/release_stage.py smoke-update --program dist/firmware-desktop-*.bin
- uses: actions/upload-artifact@v4
with:
name: desktop-windows
Expand Down Expand Up @@ -363,6 +385,10 @@ jobs:
env:
VERSION: ${{ steps.ver.outputs.version }}
run: uv run moondeck/ci/package_desktop.py --version "$VERSION"
# The program installs itself through its own Firmware route and restarts on it, on the system it is built for.
- name: Install its own program and restart on it
shell: bash
run: uv run moondeck/ci/release_stage.py smoke-update --program dist/firmware-desktop-*.bin
- name: Check the glibc floor
run: uv run moondeck/ci/check_glibc_floor.py dist/moonlight_*_amd64.deb
- uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -422,6 +448,10 @@ jobs:
env:
VERSION: ${{ steps.ver.outputs.version }}
run: uv run moondeck/ci/package_desktop.py --version "$VERSION"
# The program installs itself through its own Firmware route and restarts on it, on the system it is built for.
- name: Install its own program and restart on it
shell: bash
run: uv run moondeck/ci/release_stage.py smoke-update --program dist/firmware-desktop-*.bin
- name: Check the glibc floor
run: uv run moondeck/ci/check_glibc_floor.py dist/moonlight_*_arm64.deb
- uses: actions/upload-artifact@v4
Expand All @@ -432,6 +462,7 @@ jobs:
# The Teensy 4.1 firmware, a HEX for teensy_loader_cli or PJRC's Teensy app; test.yml builds it on every PR as well.
build-teensy:
needs: verify-version
if: github.event_name != 'pull_request' # test.yml builds the Teensy firmwares on a pull request
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
27 changes: 27 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -172,13 +172,40 @@ endif()
if(MM_VERSION)
set_property(SOURCE src/core/util/build_info.cpp APPEND PROPERTY COMPILE_DEFINITIONS MM_VERSION="${MM_VERSION}")
endif()
# A desktop build names its firmware as the release names its program, firmware-desktop-<os>-<arch>-v<version>.bin (package_desktop.py),
# so the Firmware card, the map and Home Assistant install it as they install a board, which takes its name from esp32/main/CMakeLists.txt.
if(APPLE)
set(MM_DESKTOP_OS macos)
elseif(WIN32)
set(MM_DESKTOP_OS windows)
else()
set(MM_DESKTOP_OS linux)
endif()
# The processor the program is built for: on Apple, CMAKE_OSX_ARCHITECTURES when it names one, since CMAKE_SYSTEM_PROCESSOR may name the host's.
set(MM_DESKTOP_CPU "${CMAKE_SYSTEM_PROCESSOR}")
list(LENGTH CMAKE_OSX_ARCHITECTURES MM_OSX_ARCH_COUNT)
if(APPLE AND MM_OSX_ARCH_COUNT EQUAL 1)
set(MM_DESKTOP_CPU "${CMAKE_OSX_ARCHITECTURES}")
endif()
string(TOLOWER "${MM_DESKTOP_CPU}" MM_DESKTOP_CPU)
if(MM_DESKTOP_CPU MATCHES "^(arm64|aarch64)$")
set(MM_DESKTOP_CPU arm64)
elseif(MM_DESKTOP_CPU MATCHES "^(x86_64|amd64)$")
set(MM_DESKTOP_CPU x64)
else()
unset(MM_DESKTOP_CPU) # a processor the release packages no program for: the name stays unknown
endif()
if(MM_DESKTOP_CPU)
set_property(SOURCE src/core/util/build_info.cpp APPEND PROPERTY COMPILE_DEFINITIONS MM_FIRMWARE_NAME="desktop-${MM_DESKTOP_OS}-${MM_DESKTOP_CPU}")
endif()

# Platform library (desktop). moonlive_emit.cpp is the desktop MoonLive backend (host-ISA
# codegen) — it lives here because emitted machine code is platform/ISA-specific.
add_library(mm_platform
src/platform/desktop/platform_desktop.cpp
src/platform/desktop/platform_desktop_audio.cpp
src/platform/desktop/platform_desktop_hdmi.cpp
src/platform/desktop/platform_desktop_update.cpp
src/platform/desktop/moonlive_emit_noarch.cpp
src/platform/desktop/moonlive_asm_noarch.cpp
src/platform/desktop/moonlive_emit_arm64.cpp
Expand Down
8 changes: 6 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ RUN apt-get update \
&& test -n "$url" || { echo "no ${TARGETARCH} .deb in release ${RELEASE}" >&2; exit 1; } \
&& curl -fsSL -o /tmp/moonlight.deb "$url" \
&& dpkg-deb -x /tmp/moonlight.deb /rootfs \
# The program wherever the release's package keeps it, found rather than named, so every tag builds whatever its layout.
&& program=$(find /rootfs/usr -type f -name MoonLight | head -1) \
&& test -n "$program" \
&& mv "$program" /MoonLight \
# Every shared object the binary resolves to, gathered by asking the loader rather than by
# listing names: ldd walks the whole transitive chain, so this stays correct as that chain moves.
# The four the distroless base already carries (libc, libstdc++, libm, libgcc_s) are EXCLUDED
Expand All @@ -84,7 +88,7 @@ RUN apt-get update \
&& pulse=$(find /usr/lib -name libpulse.so.0 | head -1) \
&& test -n "$pulse" \
# Captured first, so a failing ldd, or one naming a library it cannot find, stops the build here.
&& ldd /rootfs/usr/bin/MoonLight > /tmp/deps \
&& ldd /MoonLight > /tmp/deps \
&& ldd "$pulse" >> /tmp/deps \
&& ! grep -q "not found" /tmp/deps \
&& echo "libpulse.so.0 => $pulse (0)" >> /tmp/deps \
Expand All @@ -108,7 +112,7 @@ RUN apt-get update \
# up base updates (docker buildx imagetools inspect gcr.io/distroless/cc-debian13:latest).
FROM gcr.io/distroless/cc-debian13@sha256:9b615fff20e1a4fad29c2b30562580b212c7dd5e2225236735cca0070ed11c78

COPY --from=fetch /rootfs/usr/bin/MoonLight /usr/bin/MoonLight
COPY --from=fetch /MoonLight /usr/bin/MoonLight
# The libraries the loader resolved in the fetch stage, collected there rather than named here.
# Without this the container starts and dies immediately on "libcurl.so.4: cannot open shared
# object file"; the release runs each image before it publishes one.
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/coding-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ All targets build warnings-as-errors: `-Wall -Wextra -Werror` on Clang/GCC (macO
**A clean local build is not proof the Windows build passes.** MSVC's `/W4` flags things Clang/GCC's `-Wall -Wextra` don't, most commonly **signed/unsigned mismatch in comparisons** (C4389), e.g. `(x & 1) == 1u` where the left side is signed: harmless logically, fatal under `/WX`. This has slipped past the macOS gate and broken Windows CI more than once. So:

- In a comparison, keep both sides the same signedness, don't mix a signed expression with an unsigned literal (`== 1`, not `== 1u`, when the other side is signed). Watch `& `, `%`, and subtraction results, which carry the signedness of their operands.
- A change that only built+passed on macOS/Linux is **not** verified for Windows. The Windows CI job (`release.yml`) is the real gate for MSVC-only warnings; let it run before considering a `src/`-touching change done, or build with MSVC locally if you have it.
- A change that only built+passed on macOS/Linux is **not** verified for Windows. The Windows CI job (`release.yml`, on every pull request touching `src/` as well as on main) is the real gate for MSVC-only warnings; let it run before considering a `src/`-touching change done, or build with MSVC locally if you have it.

Diagnostics as you type come from clangd, using the same `.clang-tidy` CI runs; once-per-machine setup is [Set up the editor](../moonwalk.md#setup-editor).

Expand Down
2 changes: 1 addition & 1 deletion docs/design/small-boards.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ MoonLight's UI ↔ device link is, at its heart, a **JSON control/state channel*
- **MoonLight's portable core + BLE-UI as a showcase: pursue as a time-boxed spike.** The core ports with essentially no architectural work, the small-LED story is a coherent niche rather than a crippling, and, the decisive finding, the **BLE-UI channel is clean and well-precedented**, bandwidth-comfortable, and reuses the transport-agnostic control/state core we already have. The showcase/advertising value is real and lands independent of LED scale. **Gate: acceptable only if the iOS-client gap (Web Bluetooth ≠ Safari) is fine for a demo**, which for an advertisement/proof-of-concept it is (Android + desktop Chrome/Edge reach the device; an iOS companion app is a later, separate call).

**Concrete first step if pursued** (sized as a spike, ~1–2 weeks):
1. A `src/platform/nrf/` skeleton under Zephyr on an **nRF52840-DK**: `platform_config.h` with all LED-lane constants 0 except one new PWM/I2S lane flag, `hasNetwork = false`, `hasWiFi/hasEthernet/hasOta = false`; implement the time / heap / fs (LittleFS) / GPIO seam groups. This alone proves the domain-neutral core boots and runs its Scheduler + persistence on Zephyr.
1. A `src/platform/nrf/` skeleton under Zephyr on an **nRF52840-DK**: `platform_config.h` with all LED-lane constants 0 except one new PWM/I2S lane flag, `hasNetwork = false`, `hasWiFi/hasEthernet = false`, `updateMode()` answering manual; implement the time / heap / fs (LittleFS) / GPIO seam groups. This alone proves the domain-neutral core boots and runs its Scheduler + persistence on Zephyr.
2. One **I2S-EasyDMA WS2812 driver** (the whole-frame-DMA shape, closest to `i80Ws2812*`) on a single lane driving a small strip/matrix, the visible output that makes the demo tangible.
3. The **BLE-UI transport**: a GATT custom service with a control-write and a state-notify characteristic, framing the *existing* JSON control/state onto BLE (chunked to MTU), plus a minimal **Web Bluetooth client page** (Android/desktop Chrome) that renders the generic module UI from the notified state and writes control changes back. This is the load-bearing deliverable, it's what turns "the core runs on nRF" into "and you can *use* it from your browser over Bluetooth."

Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/architecture/mooncore.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Only abstract what you need:
- **Memory**: `alloc(size)`, `free(ptr)`. Prefers PSRAM on ESP32, falls back to regular heap; `allocInternal(size)` where only internal RAM will do (see [Internal RAM](#internal-ram)). `freeHeap()`, `maxAllocBlock()` for diagnostics. (`heap_caps_malloc` / `std::malloc`)
- **Executable memory**: `allocExec(size)` / `freeExec(ptr, size)` allocate memory the CPU can *fetch and execute* from, and `writeExec(dst, src, len)` copies emitted machine code into it safely. Used by the MoonLive live-script engine (below) to place the native code it compiles. All the W^X and instruction-cache quirks live behind these three functions. On ESP32 that is IRAM via `MALLOC_CAP_EXEC`, with 32-bit-aligned stores and a cache sync so the core fetches fresh code. On POSIX desktops it is an `mmap` `PROT_EXEC` page, with `MAP_JIT` and a write-protect toggle on macOS-arm64. On Windows it is a `VirtualAlloc` `PAGE_EXECUTE_READWRITE` page plus `FlushInstructionCache`. (`heap_caps_malloc(MALLOC_CAP_EXEC)` / `mmap` / `VirtualAlloc`)
- **Networking**: `UdpSocket` for ArtNet send. `TcpConnection` / `TcpServer` for HTTP + WebSocket; `TcpConnection::writeSome` is a non-blocking partial write (returns bytes written, 0 = would-block) so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets)
- **Scheduling**: `yield()` (cooperative yield to OS/RTOS), `delayMs(ms)` (blocking sleep, off-path only), `delayUs(us)` (a microsecond busy-wait, only for sub-millisecond hardware timing a driver owns, such as the WS2812 300 µs inter-frame latch in `RmtLedDriver`), and `reboot()`. General pacing uses the non-blocking `millis()` gate instead. (`vTaskDelay` / `esp_rom_delay_us` / `esp_restart` on ESP32; `std::this_thread::sleep_for` / `std::exit` on desktop)
- **Scheduling**: `yield()` (cooperative yield to OS/RTOS), `delayMs(ms)` (blocking sleep, off-path only), `delayUs(us)` (a microsecond busy-wait, only for sub-millisecond hardware timing a driver owns, such as the WS2812 300 µs inter-frame latch in `RmtLedDriver`), and `reboot()`. General pacing uses the non-blocking `millis()` gate instead. (`vTaskDelay` / `esp_rom_delay_us` / `esp_restart` on ESP32; `std::this_thread::sleep_for` on desktop, where `reboot()` starts the program again: in place by `execv`, by a new process on Windows)
- **Platform config**: `platform_config.h` per platform: compile-time constants like `hasPsram` and `hasWiFi`. Each platform provides its own version; `types.h` includes it without `#ifdef`. Core code branches on these via `if constexpr` (such as NetworkModule drops its WiFi cascade when `hasWiFi` is false), so the dead branch is removed from the binary with no `#ifdef` outside `src/platform/`.

Abstractions are added when a concrete implementation needs them, not pre-designed.
Expand Down
2 changes: 1 addition & 1 deletion docs/explanation/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ MoonLight trusts its local network. Every client that reaches a device can read

## The trust boundary is the network

A device serves one plain HTTP port: 80 on a board, 8080 on a desktop. The REST API, the `/ws` state socket and the `/wsp` preview socket all live there, and every route answers every client on the network. A client can set any control, add or delete modules, read and write files, install firmware, reboot the device and reset it to factory settings. The routes: [the REST API](../reference/api.md).
A device serves one plain HTTP port: 80 on a board, 8080 on a desktop. The REST API, the `/ws` state socket and the `/wsp` preview socket all live there, and every route answers every client on the network. A client can set any control, add or delete modules, read and write files, install firmware (on a desktop, a program that then runs as the user running MoonLight), reboot the device and reset it to factory settings. The routes: [the REST API](../reference/api.md).

A web page from outside the network is refused. A browser names the page a request comes from in its `Origin` header, and a device answers 403, the WebSocket included, when that page's host is not on the network: a private, shared (RFC 6598) or loopback address, `localhost`, a `.local`, `.home.arpa` or `.internal` name, or a name without a dot; a page the device served, called under the name it was opened by, passes as well. So a site from the internet cannot use a browser on the network against a device.

Expand Down
3 changes: 3 additions & 0 deletions docs/help/help.json
Original file line number Diff line number Diff line change
Expand Up @@ -4965,6 +4965,9 @@
"appVersion": {
"line": "The installed version."
},
"update": {
"line": "How this device takes a new version: self, host or manual."
},
"moonbaseMismatch": {
"line": "The installed version."
},
Expand Down
27 changes: 27 additions & 0 deletions docs/reference/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,33 @@

## v7.1.0

### A desktop updates itself as a board does, and the Debian package installs a service

**Action: *nothing* on most desktops; *update a file* on a Linux install from the `.deb` with a systemd unit written from the install guide.**

Check warning on line 36 in docs/reference/MIGRATING.md

View workflow job for this annotation

GitHub Actions / vale

[vale] reported by reviewdog 🐶 [MoonLight.Negation] 'nothing' negates: say what the thing is, unless it states a real constraint. Raw Output: {"message":"[MoonLight.Negation] 'nothing' negates: say what the thing is, unless it states a real constraint.","location":{"path":"docs/reference/MIGRATING.md","range":{"start":{"line":36,"column":12},"end":{"line":36,"column":19}}},"severity":"WARNING","code":{"value":"MoonLight.Negation"}}

A desktop build names its firmware `desktop-<os>-<arch>`, such as `desktop-linux-arm64`, where it said `unknown`, and installs `firmware-<firmware>-v<version>.bin` from a release through the Firmware card, the Devices map and `/api/firmware/url`, as a board does. `/api/reboot` restarts a desktop where it ended the process: in place, in its terminal window, under systemd or in a container alike. A script that stopped a desktop through `/api/reboot` stops it with Ctrl+C or its service manager instead.

The package now installs and starts `moonlight.service`, run as its own `moonlight` user from `/var/lib/moonlight/bin`, a folder that user owns, so the Devices map updates it like a board. The program moves from `/usr/bin/MoonLight` to `/usr/lib/moonlight/MoonLight`, the copy the service starts from, and the menu entry opens `http://localhost:8080/`.

A hand-written `/etc/systemd/system/moonlight.service`, or a drop-in in `/etc/systemd/system/moonlight.service.d/` that sets `ExecStart`, overrides the packaged one and names the old path, so remove it before upgrading:

```
sudo systemctl disable --now moonlight
sudo rm /etc/systemd/system/moonlight.service
sudo rm -r /etc/systemd/system/moonlight.service.d # where a drop-in sets ExecStart
```

Settings now live in `/var/lib/moonlight/MoonLight/`. Copy the old user's across once, `pi` here, after the upgrade:

```
sudo systemctl stop moonlight
sudo cp -a /home/pi/.local/share/MoonLight/. /var/lib/moonlight/MoonLight/
sudo chown -R moonlight:moonlight /var/lib/moonlight/MoonLight
sudo systemctl start moonlight
```

A Backup taken before the upgrade and restored after it carries them across too.

### The P4's radio chip status moves from System to the Firmware card

**Action: *nothing*, unless a script reads it; read `version` on the Firmware module's WiFi coproc tab instead of `wifiCoproc` on System.**
Expand Down
Loading
Loading