From 2bfa713471a0e3f204f1a2a032101da54a8edb43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 23 Apr 2026 16:01:41 +0300 Subject: [PATCH 1/7] Show correct network watchdog status when it is not installed Attempting to enable it will produce an error, not attempting to somehow work around it, since this can only happen in non-standard configurations. --- .../bindings/network-watchdog/network_watchdog.ml | 14 +++++++++++++- .../bindings/network-watchdog/network_watchdog.mli | 2 +- controller/server/gui.ml | 7 ++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/controller/bindings/network-watchdog/network_watchdog.ml b/controller/bindings/network-watchdog/network_watchdog.ml index 56be0b4f2..6ae5f62a3 100644 --- a/controller/bindings/network-watchdog/network_watchdog.ml +++ b/controller/bindings/network-watchdog/network_watchdog.ml @@ -5,7 +5,19 @@ let watchdog_service_name = "playos-network-watchdog.service" let watchdog_disable_filepath = "/home/play/.config/playos-network-watchdog/disabled" -let is_disabled () = Lwt_unix.file_exists watchdog_disable_filepath +let is_disabled systemd = + let%lwt is_installed = + Lwt.catch + (fun () -> + let%lwt _ = Systemd.Manager.get_unit systemd watchdog_service_name in + Lwt.return true + ) + (fun _ -> Lwt.return false) + in + let%lwt is_manually_disabled = + Lwt_unix.file_exists watchdog_disable_filepath + in + Lwt.return (is_manually_disabled || not is_installed) let enable systemd = let%lwt exists = Lwt_unix.file_exists watchdog_disable_filepath in diff --git a/controller/bindings/network-watchdog/network_watchdog.mli b/controller/bindings/network-watchdog/network_watchdog.mli index d7bb62c20..6d644abfa 100644 --- a/controller/bindings/network-watchdog/network_watchdog.mli +++ b/controller/bindings/network-watchdog/network_watchdog.mli @@ -1,6 +1,6 @@ (* Tiny interface for enabling/disabling the PlayOS network watchdog *) -val is_disabled : unit -> bool Lwt.t +val is_disabled : Systemd.Manager.t -> bool Lwt.t val enable : Systemd.Manager.t -> unit Lwt.t diff --git a/controller/server/gui.ml b/controller/server/gui.ml index 84f3bc34c..e1365394f 100644 --- a/controller/server/gui.ml +++ b/controller/server/gui.ml @@ -484,10 +484,11 @@ module StatusGui = struct in reboot () - let get_status ~health_s ~(update_s : Update.state React.signal) ~rauc = + let get_status ~systemd ~health_s ~(update_s : Update.state React.signal) + ~rauc = let health_state = health_s |> Lwt_react.S.value in let update_state = update_s |> Lwt_react.S.value in - let%lwt watchdog_disabled = Network_watchdog.is_disabled () in + let%lwt watchdog_disabled = Network_watchdog.is_disabled systemd in let%lwt booted_slot = Rauc.get_booted_slot rauc in let%lwt rauc = match update_state.process_state with @@ -532,7 +533,7 @@ module StatusGui = struct redirect' (Uri.of_string "/status") ) |> get "/status" (fun _req -> - let%lwt status = get_status ~update_s ~health_s ~rauc in + let%lwt status = get_status ~systemd ~update_s ~health_s ~rauc in Lwt.return (page (Status_page.html status)) ) end From 95da421cd21b9bf4a4dbad664c88d4d401036e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Mon, 27 Apr 2026 13:48:32 +0300 Subject: [PATCH 2/7] Make the update download limit in controller configurable --- base/controller-service.nix | 8 ++++ controller/server/update_client.ml | 30 +++++++++----- controller/server/update_client.mli | 4 ++ .../server/update/update_client_tests.ml | 39 +++++++++++++------ 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/base/controller-service.nix b/base/controller-service.nix index 253d92677..8c542029e 100644 --- a/base/controller-service.nix +++ b/base/controller-service.nix @@ -13,6 +13,12 @@ with lib; description = "DNS-SD service types to browse for and annotate networks with in controller UI"; type = types.listOf types.str; }; + downloadLimit = mkOption { + default = "10M"; + example = "500K"; + description = "curl --limit-rate value for update bundle downloads. If unset, no limit is applied."; + type = types.nullOr types.str; + }; }; }; @@ -58,6 +64,8 @@ with lib; environment = { PLAYOS_ANNOTATE_DISCOVERED_SERVICES = mkIf hasAnnotatedServices (concatStringsSep ";" cfg.annotateDiscoveredServices); + PLAYOS_UPDATE_DL_LIMIT = + mkIf (cfg.downloadLimit != null) cfg.downloadLimit; }; }; }; diff --git a/controller/server/update_client.ml b/controller/server/update_client.ml index 4a566a4e9..7e0e31b51 100644 --- a/controller/server/update_client.ml +++ b/controller/server/update_client.ml @@ -13,11 +13,13 @@ module type UpdateClientDeps = sig val download_dir : string + val download_limit : string option + val get_proxy : unit -> Uri.t option Lwt.t end -let make_deps ?download_dir_override get_proxy base_url : - (module UpdateClientDeps) = +let make_deps ?download_dir_override ?download_limit_override get_proxy base_url + : (module UpdateClientDeps) = (module struct let base_url = base_url @@ -29,6 +31,13 @@ let make_deps ?download_dir_override get_proxy base_url : Sys.getenv_opt "STATE_DIRECTORY" |> Option.value ~default:"/tmp" in download_dir_override |> Option.value ~default:default_download_dir + + let download_limit = + match download_limit_override with + | Some _ -> + download_limit_override + | None -> + Sys.getenv_opt "PLAYOS_UPDATE_DL_LIMIT" end ) @@ -71,15 +80,16 @@ module UpdateClient (DepsI : UpdateClientDeps) = struct let bundle_path = Format.sprintf "%s/%s" download_dir (bundle_file_name version) in - let options = - [ "--continue-at" - ; "-" (* resume download *) - ; "--limit-rate" - ; "10M" - ; "--output" - ; bundle_path - ] + let limit_rate_opts = + match DepsI.download_limit with + | Some limit -> + [ "--limit-rate"; limit ] + | None -> + [] in + let resume_opts = [ "--continue-at"; "-" ] in + let output_opts = [ "--output"; bundle_path ] in + let options = List.flatten [ limit_rate_opts; resume_opts; output_opts ] in let%lwt proxy = get_proxy () in match%lwt Curl.request ?proxy ~options url with | RequestSuccess _ -> diff --git a/controller/server/update_client.mli b/controller/server/update_client.mli index b411fccc6..8f9749084 100644 --- a/controller/server/update_client.mli +++ b/controller/server/update_client.mli @@ -14,11 +14,15 @@ module type UpdateClientDeps = sig val download_dir : string + (** If set, passed to curl as --limit-rate *) + val download_limit : string option + val get_proxy : unit -> Uri.t option Lwt.t end val make_deps : ?download_dir_override:string + -> ?download_limit_override:string -> (unit -> Uri.t option Lwt.t) -> Uri.t -> (module UpdateClientDeps) diff --git a/controller/tests/server/update/update_client_tests.ml b/controller/tests/server/update/update_client_tests.ml index 240eac6a0..ee94d19fb 100644 --- a/controller/tests/server/update/update_client_tests.ml +++ b/controller/tests/server/update/update_client_tests.ml @@ -60,7 +60,7 @@ let rec wait_for_mock_server ?(timeout = 0.2) ?(remaining_tries = 3) url = in Lwt.fail (Failure err_msg) -let run_test_case ?(proxy = NoProxy) switch f = +let run_test_case ?(proxy = NoProxy) ?download_limit switch f = let server = mock_server () in let server_url, server_task = server#run () in let%lwt () = wait_for_mock_server server_url in @@ -78,8 +78,8 @@ let run_test_case ?(proxy = NoProxy) switch f = in let () = Sys.mkdir temp_dir 0o777 in let module DepsI = - ( val Update_client.make_deps ~download_dir_override:temp_dir get_proxy - base_url + ( val Update_client.make_deps ~download_dir_override:temp_dir + ?download_limit_override:download_limit get_proxy base_url ) in let module UpdateC = Update_client.Make (DepsI) in @@ -153,9 +153,20 @@ let test_invalid_proxy_fail _ (module Client : S) = ^ Printexc.to_string other_exn ) +let test_download_limit_honored server (module Client : S) = + let version = "1.0.0" in + let bundle = String.make (3 * 1024) 'x' in + let () = server#add_bundle version bundle in + let start = Unix.gettimeofday () in + let%lwt _bundle_path = Client.download version in + let elapsed = Unix.gettimeofday () -. start in + Alcotest.(check bool) + "Download took at least 2.0 seconds with 1K/s limit" true (elapsed >= 2.0) ; + Lwt.return () + let () = let () = setup_log () in - (* All tests cases are run with proxy setup and without to verify it works + (* Common test cases are run with proxy setup and without to verify it works always *) let test_cases = [ ("Get latest version", test_get_version_ok) @@ -163,6 +174,11 @@ let () = ; ("Resume download works", test_resume_bundle_download) ] in + let download_limit_case = + Alcotest_lwt.test_case "Download rate is limited" `Slow (fun switch () -> + run_test_case ~download_limit:"1K" switch test_download_limit_honored + ) + in (* An extra case to check that proxy settings are honored in general *) let invalid_proxy_case = Alcotest_lwt.test_case "Invalid proxy specified" `Quick (fun switch () -> @@ -173,13 +189,14 @@ let () = Lwt_main.run @@ Alcotest_lwt.run "Basic tests" [ ( "without-proxy" - , List.map - (fun (name, test_f) -> - Alcotest_lwt.test_case name `Quick (fun switch () -> - run_test_case switch test_f - ) - ) - test_cases + , download_limit_case + :: List.map + (fun (name, test_f) -> + Alcotest_lwt.test_case name `Quick (fun switch () -> + run_test_case switch test_f + ) + ) + test_cases ) ; ( "with-proxy" , invalid_proxy_case From c1ab2731db3386512d29728938e15d3313f22e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Mon, 27 Apr 2026 13:53:25 +0300 Subject: [PATCH 3/7] Check that download limiting is applied in e2e tests --- .../tests/base/proxy-and-update.nix | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/testing/end-to-end/tests/base/proxy-and-update.nix b/testing/end-to-end/tests/base/proxy-and-update.nix index eb3594a85..bbb3cbab1 100644 --- a/testing/end-to-end/tests/base/proxy-and-update.nix +++ b/testing/end-to-end/tests/base/proxy-and-update.nix @@ -131,6 +131,7 @@ pkgs.testers.runNixOSTest { ${builtins.readFile ../../../helpers/nixos-test-script-helpers.py} ${builtins.readFile ./proxy-and-update-helpers.py} import json + import os product_name = "${safeProductName}" current_version = "1.1.1-TESTMAGIC" @@ -143,6 +144,9 @@ pkgs.testers.runNixOSTest { is_legacy_mode = bool(${toString legacyMode}) # `toString false` returns "" bad_ext4_option = "metadata_csum_seed" + def wait_for_state(state, timeout): + return wait_for_logs(playos, state, unit="playos-controller", timeout=timeout) + create_overlay("${disk}", "${overlayPath}") playos.start(allow_reboot=True) sidekick.start() @@ -212,10 +216,7 @@ pkgs.testers.runNixOSTest { ] for state in expected_states: - wait_for_logs(playos, - state, - unit="playos-controller.service", - timeout=61) + wait_for_state(state, timeout=61) with TestCase("Controller installs the new upstream version") as t: next_version = "${nextVersion}" @@ -227,19 +228,26 @@ pkgs.testers.runNixOSTest { # TODO: override config to reduce check interval instead playos.systemctl("restart playos-controller.service") - expected_states = [ - "Downloading", - f"Installing.*{update_server.bundle_filename(next_version)}", - "RebootRequired" - ] + start_time = time.time() + + # expected state transitions: Downloading -> Installing -> RebootRequired + + # -> Downloading + wait_for_state("Downloading", timeout=10) + + # Downloading -> Installing + bundle_size = os.path.getsize("${nextVersionBundle}") + min_download_time = bundle_size / (10 * 1024 * 1024) # 10 M/s default limit + + wait_for_state(f"Installing.*{update_server.bundle_filename(next_version)}", timeout=min_download_time*2) + + elapsed = time.time() - start_time + t.assertGreater(elapsed, min_download_time, + f"Downloaded bundle too fast ({elapsed:.1f}s), rate limiting not applied?") + + # Installing -> RebootRequired + wait_for_state("RebootRequired", timeout=30) - for state in expected_states: - wait_for_logs(playos, - state, - unit="playos-controller.service", - # curl is limited to 10MB/s in controller, so - # a 600 MB bundle will take at least 60s - timeout=75) with TestCase("RAUC status confirms the installation") as t: rauc_status = json.loads(playos.succeed( From 3eefbec7a89c17c9af78455de75678cde8893683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 23 Apr 2026 16:00:46 +0300 Subject: [PATCH 4/7] Add a "portable" flavor of PlayOS Short-term the plan is to use this flavor with generic laptop devices for "demo" purposes". Long-term this might become a more permanent variation of the standard configuration that is optimized for a different operational context from the usual PlayOS PC installations, which is characterized by: - non-persistent internet connection - power-on only during use - frequent network re-setup - frequent display changes - varied input devices and other aspects yet to be discovered. --- build | 19 +++++++++++++- flavors/portable.nix | 61 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 flavors/portable.nix diff --git a/build b/build index f638a55c6..8269c16b1 100755 --- a/build +++ b/build @@ -5,7 +5,7 @@ set -euo pipefail PRODUCTION_TARGETS="develop|validation|master|release-disk" # other targets used for development/testing/etc -OTHER_TARGETS="vm|stuck|lab-key|shed-key|test-e2e|all" +OTHER_TARGETS="vm|stuck|lab-key|shed-key|test-e2e|portable|all" ALL_TARGETS="$PRODUCTION_TARGETS|$OTHER_TARGETS" @@ -115,6 +115,23 @@ elif [ "$TARGET" == "master" ]; then echo echo "Run ./result/bin/deploy-update to deploy." +elif [ "$TARGET" == "portable" ]; then + + scripts/info-branch-commit + scripts/confirm-or-abort + + (set -x; nix-build \ + --arg applicationPath ./flavors/portable.nix \ + --arg updateCert ./pki/master/cert.pem \ + --arg updateUrl https://dist.dividat.com/releases/playos/portable/ \ + --arg deployUrl s3://dist.dividat.ch/releases/playos/portable/ \ + --arg kioskUrl https://play.dividat.com/ \ + --arg watchdogUrls '["https://play.dividat.com/" "https://api.dividat.com"]' \ + --arg buildDisk false) + + echo + echo "Run ./result/bin/deploy-update to deploy." + elif [ "$TARGET" == "stuck" ]; then echo "Creating a stuck system that will not self-update." diff --git a/flavors/portable.nix b/flavors/portable.nix new file mode 100644 index 000000000..e4d452979 --- /dev/null +++ b/flavors/portable.nix @@ -0,0 +1,61 @@ +let + defaults = import ../application.nix; +in +{ + inherit (defaults) safeProductName version overlays greeting; + + fullProductName = "${defaults.fullProductName} (portable)"; + + module = { config, lib, pkgs, ... }: { + imports = [ + defaults.module + ]; + + # use a lower download limit to ensure minimal interference + playos.controller.downloadLimit = "2M"; + + # we do not expect a stable/permanent network connection + playos.networking.watchdog.enable = lib.mkForce false; + + # metrics are optimized for specific hardware and stable setup, probably + # not very useful for ad-hoc portable setups + playos.monitoring.enable = lib.mkForce false; + + # Do not hard-code HDMI as default + hardware.pulseaudio = { + extraConfig = lib.mkForce '' + # Respond to changes in connected outputs + load-module module-switch-on-port-available + load-module module-switch-on-connect blacklist="" + + # Prevent PulseAudio from remembering previous muted states. + # First, we unload the default restore module, then reload it + # explicitly telling it NOT to restore mute states. + unload-module module-device-restore + load-module module-device-restore restore_volume=true restore_muted=false + ''; + }; + + systemd.user.services.force-unmute = { + description = "Force unmute PulseAudio on login"; + wantedBy = [ "default.target" ]; + after = [ "pulseaudio.service" ]; + + path = [ pkgs.pulseaudio ]; + + script = '' + # Brief pause to ensure PulseAudio has fully initialized its sinks + sleep 2 + pactl set-sink-mute @DEFAULT_SINK@ 0 + + # Optional: Force volume to a safe default so it is never at 0% + pactl set-sink-volume @DEFAULT_SINK@ 70% + ''; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + }; +} From b8e3dd13032e16329e9456b31059b5afa6c49a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Fri, 24 Apr 2026 11:43:36 +0300 Subject: [PATCH 5/7] Add volume media key support --- flavors/portable.nix | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/flavors/portable.nix b/flavors/portable.nix index e4d452979..8aa5f05bf 100644 --- a/flavors/portable.nix +++ b/flavors/portable.nix @@ -57,5 +57,22 @@ in RemainAfterExit = true; }; }; + + # Add bindings for media keys to allow volume control + environment.etc."sxhkd/sxhkdrc".text = '' + XF86AudioLowerVolume + ${pkgs.pulseaudio}/bin/pactl set-sink-volume @DEFAULT_SINK@ -5% + + XF86AudioRaiseVolume + ${pkgs.pulseaudio}/bin/pactl set-sink-volume @DEFAULT_SINK@ +5% + + XF86AudioMute + ${pkgs.pulseaudio}/bin/pactl set-sink-mute @DEFAULT_SINK@ toggle + ''; + + services.xserver.displayManager.sessionCommands = '' + # Provides media key bindings for volume control + ${pkgs.sxhkd}/bin/sxhkd -c /etc/sxhkd/sxhkdrc & + ''; }; } From efa4ec924512459592e77426d3bc35098f6f235b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 7 May 2026 11:54:27 +0300 Subject: [PATCH 6/7] Add target for conveniently building VM with portable flavour --- build | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/build b/build index 8269c16b1..91dc18a60 100755 --- a/build +++ b/build @@ -5,7 +5,7 @@ set -euo pipefail PRODUCTION_TARGETS="develop|validation|master|release-disk" # other targets used for development/testing/etc -OTHER_TARGETS="vm|stuck|lab-key|shed-key|test-e2e|portable|all" +OTHER_TARGETS="vm|stuck|lab-key|shed-key|test-e2e|portable|vm-portable|all" ALL_TARGETS="$PRODUCTION_TARGETS|$OTHER_TARGETS" @@ -44,13 +44,22 @@ fi # See https://nixos.wiki/wiki/FAQ/How_can_I_install_a_proprietary_or_unfree_package%3F export NIXPKGS_ALLOW_UNFREE=1 -if [ "$TARGET" == "vm" ]; then +if [[ "$TARGET" == "vm" ]] || [[ "$TARGET" == "vm-portable" ]] ; then - (set -x; nix-build \ - --arg buildInstaller false \ - --arg buildBundle false \ - --arg buildLive false \ - --arg buildDisk false) + build_args=( + --arg buildInstaller false + --arg buildBundle false + --arg buildLive false + --arg buildDisk false + ) + + if [ "$TARGET" == "vm-portable" ]; then + build_args+=( + --arg applicationPath ./flavors/portable.nix + ) + fi + + (set -x; nix-build "${build_args[@]}") echo -e " Run ./result/bin/run-in-vm to start a VM. From 7a4cda9b5eaa8a9a32578833c42e5a243cc2fec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignas=20Vy=C5=A1niauskas?= Date: Thu, 7 May 2026 11:56:57 +0300 Subject: [PATCH 7/7] Add basic CI test for checking that the portable flavor is buildable --- .github/workflows/test.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe7008613..659efecce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,6 +83,18 @@ jobs: NIX_BINARY_CACHE_PRIVATE_KEY: ${{ secrets.NIX_BINARY_CACHE_PRIVATE_KEY }} - run: NIXPKGS_ALLOW_UNFREE=1 ./build vm + build-vm-portable: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/prep-build-env + with: + NIX_CACHE_ACCOUNT_ID: ${{ secrets.NIX_CACHE_ACCOUNT_ID }} + NIX_CACHE_ACCESS_KEY_ID: ${{ secrets.NIX_CACHE_ACCESS_KEY_ID }} + NIX_CACHE_SECRET_ACCESS_KEY: ${{ secrets.NIX_CACHE_SECRET_ACCESS_KEY }} + NIX_BINARY_CACHE_PRIVATE_KEY: ${{ secrets.NIX_BINARY_CACHE_PRIVATE_KEY }} + - run: NIXPKGS_ALLOW_UNFREE=1 ./build vm-portable + e2e-tests: runs-on: ubuntu-latest steps: