From 70966f41cabcae639feb28e58a9a3add3e9a42a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 10:52:22 +0100 Subject: [PATCH 1/8] Report the image channel in /v1/me so LoopFollow can tell whether a newer hub is out on the same channel --- Api/HubApi.cs | 2 +- Api/HubInfo.cs | 8 ++++++++ docs/SETUP.md | 4 +++- lfhub.csproj | 2 ++ tests/LFHub.Tests/Api/HubApiTests.cs | 1 + 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Api/HubApi.cs b/Api/HubApi.cs index e052447..7b6314c 100644 --- a/Api/HubApi.cs +++ b/Api/HubApi.cs @@ -249,7 +249,7 @@ await Results.Problem($"the request body is larger than {MaxBodyBytes / 1024} KB var alarmCount = engine.AlarmExport(device)?.Alarms.Count; return Results.Ok(new { - hub = new { version = HubInfo.Version, api = HubInfo.Api, features = HubInfo.Features }, + hub = new { version = HubInfo.Version, api = HubInfo.Api, features = HubInfo.Features, channel = HubInfo.Channel }, device = new { id = device.Id, diff --git a/Api/HubInfo.cs b/Api/HubInfo.cs index 32b0c75..10d09ae 100644 --- a/Api/HubInfo.cs +++ b/Api/HubInfo.cs @@ -16,6 +16,14 @@ public static class HubInfo (typeof(HubInfo).Assembly.GetCustomAttribute()?.InformationalVersion ?? "0") .Split('+')[0]; + /// + /// The image tag the hub was published under: latest (built from main) or + /// dev (built from dev); null for a build of its own. + /// + public static readonly string? Channel = + typeof(HubInfo).Assembly.GetCustomAttributes() + .FirstOrDefault(a => a.Key == "Channel")?.Value; + /// Features this hub provides. public static readonly string[] Features = ["receipts", "speak", "heartbeat", "bundle", "latestBg", "devices", "roles", "invites", "sourceRevision", "sharedApnsKey"]; diff --git a/docs/SETUP.md b/docs/SETUP.md index 3c95875..7e1e3a9 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -207,4 +207,6 @@ Pull the new image and restart the hub. It updates the database on start, by adding tables and columns, so the version before can run on it again if an upgrade has to be rolled back. A hub older than the phones' LoopFollow, or the other way around, shows on the LoopFollow Hub page, and the phone keeps alarming by itself -until both understand each other. +until both understand each other. The LoopFollow Hub page also shows the newest +version of the hub's image (`latest` or `dev`), and LoopFollow on an admin phone +reminds about a newer one every two weeks. diff --git a/lfhub.csproj b/lfhub.csproj index 1df33ca..31f7956 100644 --- a/lfhub.csproj +++ b/lfhub.csproj @@ -18,6 +18,8 @@ + + diff --git a/tests/LFHub.Tests/Api/HubApiTests.cs b/tests/LFHub.Tests/Api/HubApiTests.cs index db158d8..a345dcf 100644 --- a/tests/LFHub.Tests/Api/HubApiTests.cs +++ b/tests/LFHub.Tests/Api/HubApiTests.cs @@ -623,6 +623,7 @@ public async Task Me_ReportsAlarmsReceivedPushHealthAndWhetherThePatientIsFollow Assert.Equal(1, me!["hub"]!["api"]!.GetValue()); Assert.Contains("receipts", me["hub"]!["features"]!.AsArray().Select(f => f!.GetValue())); Assert.False(string.IsNullOrEmpty(me["hub"]!["version"]!.GetValue())); + Assert.True(me["hub"]!.AsObject().ContainsKey("channel")); var device = me["device"]!; Assert.False(device["alarmsReceived"]!.GetValue()); Assert.Equal(0, device["alarmCount"]!.GetValue()); From 02adb0696bbb83ca3265821a8758550f22dce05a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 10:52:22 +0100 Subject: [PATCH 2/8] Bump the patch version on every push to dev and build the dev image from that commit, tag X.Y.0 releases on main, warn on PRs to main, and add a release script --- .github/workflows/build-image.yml | 67 +++++++++++++++++++++++-- .github/workflows/tag_on_main.yml | 52 +++++++++++++++++++ .github/workflows/warn_main_pr.yml | 25 ++++++++++ README.md | 24 ++++++++- release.sh | 80 ++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/tag_on_main.yml create mode 100644 .github/workflows/warn_main_pr.yml create mode 100755 release.sh diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index a79bbd2..10b3b01 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -4,6 +4,14 @@ name: Build and Push lfhub Image # linux-arm64 and pushed to ghcr.io//: main as `latest`, dev as # `dev`, each also under its commit's SHA. A manual run on another branch runs the # tests alone. +# +# Versions: `` in lfhub.csproj is MAJOR.MINOR.PATCH. A release on main is +# X.Y.0 (set by release.sh); every push to dev raises PATCH by one in a commit of its +# own ("CI: Bump dev version to X.Y.Z [skip ci]"), and the dev image is built from +# that commit, so the version a hub reports matches lfhub.csproj on its branch. A push +# that sets the version itself (a release sync) is built as it is. Forks build without +# bumping. The bump pushes with the LFHUB_TOKEN_AUTOBUMP secret when it exists (needed +# once dev is protected), otherwise with the workflow's token. on: push: @@ -52,9 +60,59 @@ jobs: LFHUB_TEST_DATABASE: Host=localhost;Port=5432;Username=postgres run: dotnet test --solution lfhub.slnx -c Release --no-build - build-and-push: + bump-version: needs: test - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && !github.event.repository.fork + runs-on: ubuntu-latest + concurrency: + group: bump-${{ github.workflow }} + cancel-in-progress: false + permissions: + contents: write + outputs: + sha: ${{ steps.bump.outputs.sha }} + + steps: + - name: Check out dev + uses: actions/checkout@v6 + with: + ref: dev + fetch-depth: 0 + token: ${{ secrets.LFHUB_TOKEN_AUTOBUMP || github.token }} + + - name: Bump the patch version + id: bump + env: + BEFORE: ${{ github.event.before }} + run: | + if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && + git diff "$BEFORE..${GITHUB_SHA}" -- lfhub.csproj | grep -qE '^\+ *'; then + echo "The version was set in this push; building it as it is." + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CURRENT=$(sed -nE 's|^ *([0-9]+\.[0-9]+\.[0-9]+).*|\1|p' lfhub.csproj) + if [ -z "$CURRENT" ]; then + echo "::error::No MAJOR.MINOR.PATCH in lfhub.csproj" + exit 1 + fi + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + NEW="$MAJOR.$MINOR.$((PATCH + 1))" + sed -i -E "s|${CURRENT//./\\.}|$NEW|" lfhub.csproj + echo "Version: $CURRENT -> $NEW" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -m "CI: Bump dev version to $NEW [skip ci]" lfhub.csproj + git push origin HEAD:dev + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + build-and-push: + needs: [test, bump-version] + if: >- + !cancelled() && needs.test.result == 'success' && needs.bump-version.result != 'failure' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') runs-on: ubuntu-latest concurrency: group: build-${{ github.workflow }} @@ -63,6 +121,8 @@ jobs: steps: - name: Check out code uses: actions/checkout@v6 + with: + ref: ${{ needs.bump-version.outputs.sha || github.sha }} - name: Set up .NET uses: actions/setup-dotnet@v5 @@ -86,4 +146,5 @@ jobs: -p:ContainerRegistry=ghcr.io -p:ContainerRepository=${REPOSITORY,,} -p:RepositoryUrl=${{ github.server_url }}/${{ github.repository }} - "-p:ContainerImageTags=\"${CHANNEL};${{ github.sha }}\"" + -p:HubChannel=${CHANNEL} + "-p:ContainerImageTags=\"${CHANNEL};$(git rev-parse HEAD)\"" diff --git a/.github/workflows/tag_on_main.yml b/.github/workflows/tag_on_main.yml new file mode 100644 index 0000000..afeabc9 --- /dev/null +++ b/.github/workflows/tag_on_main.yml @@ -0,0 +1,52 @@ +# Tags a release when main reaches a release version (X.Y.0): the version is +# `` in lfhub.csproj and the tag is `v`. Other versions, an +# existing tag and forks are skipped. A manual run against main creates a missing tag. + +name: Tag release on main + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + tag: + if: ${{ !github.event.repository.fork }} + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Read the version + id: version + run: | + VERSION=$(sed -nE 's|^ *([^<]+).*|\1|p' lfhub.csproj) + if [ -z "$VERSION" ]; then + echo "::error::No in lfhub.csproj" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.0$ ]]; then + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "release=false" >> "$GITHUB_OUTPUT" + echo "$VERSION is not a release version (X.Y.0); not tagging." + fi + + - name: Create and push the tag + if: steps.version.outputs.release == 'true' + run: | + TAG="v${{ steps.version.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "$TAG exists." + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "$TAG" + git push origin "$TAG" + fi diff --git a/.github/workflows/warn_main_pr.yml b/.github/workflows/warn_main_pr.yml new file mode 100644 index 0000000..64cb43a --- /dev/null +++ b/.github/workflows/warn_main_pr.yml @@ -0,0 +1,25 @@ +name: Warn PR targets main + +on: + pull_request_target: + types: [opened, edited] + branches: [main] + +jobs: + warn: + if: ${{ !github.event.repository.fork && !startsWith(github.head_ref, 'release/') }} + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - name: Comment on PR + uses: actions/github-script@v8 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: 'This PR targets `main`, which only takes releases. Please retarget it to `dev`.' + }); diff --git a/README.md b/README.md index 76580d6..614e142 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,12 @@ be made that way becomes `/v2`, served next to `/v1` for a transition period. Each side reports what it supports. The app sends `appVersion` and `features` with `PUT /v1/device/settings`; `GET /v1/me` answers with -`hub: {version, api, features}`. A feature is used only once both sides have reported it: +`hub: {version, api, features, channel}`, where `channel` is the image tag the hub was +published under (`latest`, built from `main`, or `dev`, built from `dev`) and null for a +build of its own. The app compares `version` with the `` in `lfhub.csproj` on the +channel's branch (`dev` for a build of its own) to tell whether a newer hub is out (see +Versions). A feature is used only once both +sides have reported it: | Feature | Meaning | |---|---| @@ -428,4 +433,21 @@ only then is the image built for linux-x64 and linux-arm64 with `dotnet publish /t:PublishContainer` and pushed to `ghcr.io//` as `latest` from `main` and `dev` from `dev`, and as the commit's SHA. +## Versions + +The version is `` in `lfhub.csproj`, `MAJOR.MINOR.PATCH`. Pull requests go to +`dev` and leave the version alone. + +- A release on `main` is `X.Y.0`, tagged `vX.Y.0`, and right after it `dev` is at the + same version. +- Every push to `dev` raises the last number in a commit of its own (`X.Y.1`, `X.Y.2`, + …), and the `dev` image is built from that commit. +- A maintainer releases with `./release.sh`: it raises the version to `X.Y+1.0` (or + `X+1.0.0`) on a `release/v…` branch cut from `dev` and opens PRs to `main` and `dev`. + Merged with merge commits, `main` builds the `latest` image and is tagged, and `dev` + continues from the release. + +The bump on `dev` pushes with the repository secret `LFHUB_TOKEN_AUTOBUMP` when it is +set, which a protected `dev` needs, and with the workflow's token otherwise. + Setting up and running a hub: [docs/SETUP.md](docs/SETUP.md). diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..6f42ec3 --- /dev/null +++ b/release.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------ +# release.sh – cuts a release of LF Hub from dev +# +# Bumps in lfhub.csproj to M.N+1.0 (minor) or M+1.0.0 (major) on a +# release/vX.Y.0 branch cut from dev, pushes it and opens two PRs: release → main +# (merging it builds the `latest` image and tags vX.Y.0) and release → dev (so the +# dev bumps continue from the release). Merge both with a merge commit. +# ------------------------------------------------------------ +set -euo pipefail +trap 'echo "Error, aborting"; exit 1' ERR + +VERSION_FILE="lfhub.csproj" +DEV_BRANCH="dev" +MAIN_BRANCH="main" + +pause() { read -rp "Press Enter to continue (Ctrl-C to abort)… "; } +echo_run() { echo "+ $*"; "$@"; } + +echo_run git fetch origin "$DEV_BRANCH" +if ! diff -q <(git show "origin/${DEV_BRANCH}:release.sh") "${BASH_SOURCE[0]}" >/dev/null 2>&1; then + echo "This release.sh differs from origin/${DEV_BRANCH}:release.sh. Switch to the latest ${DEV_BRANCH} and run it again." + exit 1 +fi + +if [ -n "$(git status --porcelain)" ]; then + echo "The working tree has changes. Commit or stash them first." + exit 1 +fi + +echo_run git switch "$DEV_BRANCH" +echo_run git pull --ff-only + +old_ver=$(sed -nE 's|^ *([0-9]+\.[0-9]+\.[0-9]+).*|\1|p' "$VERSION_FILE") +if [ -z "$old_ver" ]; then + echo "No MAJOR.MINOR.PATCH in $VERSION_FILE." + exit 1 +fi +major_candidate="$(awk -F. '{printf "%d.0.0", $1 + 1}' <<<"$old_ver")" +minor_candidate="$(awk -F. '{printf "%d.%d.0", $1, $2 + 1}' <<<"$old_ver")" + +echo +echo "dev is at $old_ver. Which release?" +echo " 1) Major → $major_candidate" +echo " 2) Minor → $minor_candidate" +read -rp "Enter 1 or 2 (default 2): " choice +case "$choice" in + 1) new_ver="$major_candidate" ;; + ""|2) new_ver="$minor_candidate" ;; + *) echo "Invalid choice."; exit 1 ;; +esac + +RELEASE_BRANCH="release/v${new_ver}" +echo_run git switch -c "$RELEASE_BRANCH" +perl -i -pe "s|\Q${old_ver}\E|${new_ver}|" "$VERSION_FILE" +echo_run git diff "$VERSION_FILE"; pause +# No [skip ci]: main's push builds the `latest` image and tags the release; dev's +# workflow sees the version set in the push and does not bump it again. +echo_run git commit -m "Update version to ${new_ver}" "$VERSION_FILE" + +echo "Build and test the release branch now (dotnet build, dotnet test)."; pause + +read -rp "Push ${RELEASE_BRANCH} and open the PRs? (y/n): " confirm +if [[ ! $confirm =~ ^[Yy]$ ]]; then + echo "Not pushed. When ready: git push origin ${RELEASE_BRANCH}" + exit 0 +fi + +echo_run git push origin "$RELEASE_BRANCH" +REPO_SLUG="$(git remote get-url origin | sed -E -e 's#\.git$##' -e 's#^.*github\.com[:/]##')" + +gh pr create --repo "$REPO_SLUG" --base "$DEV_BRANCH" --head "$RELEASE_BRANCH" \ + --title "Sync v${new_ver} version to dev" \ + --body "Brings the v${new_ver} version back to dev so the dev bumps continue from it. Merge with a merge commit." + +gh pr create --repo "$REPO_SLUG" --base "$MAIN_BRANCH" --head "$RELEASE_BRANCH" \ + --title "Release v${new_ver}" \ + --body "Release v${new_ver}. Merging builds the latest image and tags v${new_ver}. Merge with a merge commit." + +echo "Merge both PRs, then create a GitHub release for v${new_ver} once the tag exists." From 2cfff560998ef4c2fc6b60336568c20db860f831 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 09:53:38 +0000 Subject: [PATCH 3/8] CI: Bump dev version to 0.2.1 [skip ci] --- lfhub.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lfhub.csproj b/lfhub.csproj index 31f7956..65d7e20 100644 --- a/lfhub.csproj +++ b/lfhub.csproj @@ -6,7 +6,7 @@ enable LFHub true - 0.2.0 + 0.2.1 mcr.microsoft.com/dotnet/aspnet:10.0 From 60a22f772232dc37b28560b089d96d905f56d812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 19:22:27 +0100 Subject: [PATCH 4/8] Base the Loop predictive low forecast on the full forecast and drop downloadPrediction --- Engine/State/ClientSettings.cs | 8 ++----- Engine/State/DeviceStatusState.cs | 12 ++++------ Engine/State/PatientState.cs | 29 ++++++++++++----------- README.md | 2 +- tests/LFHub.Tests/State/LoopStateTests.cs | 13 ++++++---- 5 files changed, 31 insertions(+), 33 deletions(-) diff --git a/Engine/State/ClientSettings.cs b/Engine/State/ClientSettings.cs index 5c4e92d..9be2a3b 100644 --- a/Engine/State/ClientSettings.cs +++ b/Engine/State/ClientSettings.cs @@ -16,13 +16,10 @@ public sealed record ClientSettings /// "units": "mg/dL" or "mmol/L"; the snapshot's `unit`. public bool Mmol { get; init; } - /// "predictionToLoad", hours: the OpenAPS min/max window, the length of Loop's - /// stored forecast, the carbs horizon and the override graph's future edge. + /// "predictionToLoad", hours: the OpenAPS min/max window, the carbs horizon + /// and the override graph's future edge. public double PredictionToLoad { get; init; } = 1.0; - /// "downloadPrediction": Loop's forecast and min/max are stored only when on. - public bool DownloadPrediction { get; init; } = true; - /// "downloadTreatments": off leaves every treatments-derived value unset. public bool DownloadTreatments { get; init; } = true; @@ -118,7 +115,6 @@ public static ClientSettings FromJson(JsonObject? json) { Mmol = mmol, PredictionToLoad = Swift.Dbl(json["predictionToLoad"]) ?? d.PredictionToLoad, - DownloadPrediction = StrictBool(json, "downloadPrediction") ?? d.DownloadPrediction, DownloadTreatments = StrictBool(json, "downloadTreatments") ?? d.DownloadTreatments, DownloadDays = (int?)Swift.Int(json["downloadDays"]) is { } days and >= 1 ? Math.Min(days, MaxDownloadDays) diff --git a/Engine/State/DeviceStatusState.cs b/Engine/State/DeviceStatusState.cs index 635ad23..62f4075 100644 --- a/Engine/State/DeviceStatusState.cs +++ b/Engine/State/DeviceStatusState.cs @@ -40,10 +40,10 @@ internal sealed partial class DeviceStatusState public double? IsfMgdl { get; private set; } public double? CarbRatio { get; private set; } - /// Loop's forecast as MainViewController.predictionData holds it with downloadPrediction on: - /// the raw values and the loop time the first point is placed at. + /// MainViewController.loopPredBGs and loopPredUpdatedTime: Loop's full forecast + /// and the loop time the first point is placed at. public (List Values, double Start)? LoopForecast { get; private set; } - /// Storage.lastMinBgMgdl/lastMaxBgMgdl as Loop last set them (downloadPrediction on), with a write sequence. + /// Storage.lastMinBgMgdl/lastMaxBgMgdl as Loop last set them, with a write sequence. public (double Min, double Max, long Seq)? LoopMinMax { get; private set; } /// The predBGs OpenAPS last set lastMinBg/lastMaxBg from; the window depends on predictionToLoad. public (Dictionary> PredBGs, long Seq)? OpenApsMinMax { get; private set; } @@ -162,10 +162,8 @@ private void ApplyLoop(JsonObject loop, double previousLoopTime, double lastLoop if (values is { Count: > 0 }) { if (previousLoopTime < lastLoopTime || LoopForecast is null) - { - LoopForecast = (values, lastLoopTime); LoopMinMax = (values.Min(), values.Max(), ++_seq); - } + LoopForecast = (values, lastLoopTime); } else { @@ -244,7 +242,7 @@ private void ApplyOpenAps(JsonObject ds, JsonObject openaps, DateTime now, NsPro /// The min/max the snapshot shows for a device with these prediction settings. public (double Min, double Max)? MinMax(ClientSettings settings) { - var loop = settings.DownloadPrediction ? LoopMinMax : null; + var loop = LoopMinMax; var openAps = OpenApsMinMax; if (loop is { } l && (openAps is null || l.Seq > openAps.Value.Seq)) return (l.Min, l.Max); if (openAps is not { } o) return null; diff --git a/Engine/State/PatientState.cs b/Engine/State/PatientState.cs index c383f63..394aef4 100644 --- a/Engine/State/PatientState.cs +++ b/Engine/State/PatientState.cs @@ -652,7 +652,7 @@ private static bool TryDbSize(JsonNode? response, out double? percentage) return new AlarmData { BgReadings = bgReadings, - PredictionData = Forecast(settings, now), + PredictionData = Forecast(now), ExpireDate = Utc(_profile?.ExpirationDate), LastLoopTime = Utc(s.LastLoopTime), LatestOverrideStart = Utc(overrides.LastOrDefault(o => o.Date <= now)?.Date), @@ -678,24 +678,25 @@ private static bool TryDbSize(JsonNode? response, out double? percentage) } } - /// AlarmTask.alarmPredictionData: Loop's stored forecast, or Trio's four collapsed to their lowest. - private List Forecast(ClientSettings settings, double now) + /// AlarmTask.alarmPredictionData: Loop's forecast, or Trio's four collapsed to their lowest, + /// both independent of the graph settings. + private List Forecast(double now) { var s = _status; + List> forecasts; + double start; if (s.Device == "Loop") { - if (!settings.DownloadPrediction || s.LoopForecast is not { } f) return []; - // DeviceStatusLoop.swift: predictionToLoad × 12 points past the first, clamped to 39…400. - var toLoad = (int)(settings.PredictionToLoad * 12); - var points = new List(); - for (var i = 0; i <= toLoad && i < f.Values.Count && points.Count < AlarmForecastPointCap; i++) - points.Add(new GlucoseValue(Math.Clamp(Swift.RoundToInt(f.Values[i]), 39, 400), Swift.FromUnix(f.Start + 300.0 * i))); - return points; + if (s.LoopForecast is not { } f) return []; + forecasts = [f.Values]; + start = f.Start; + } + else + { + if (s.OpenApsPredBGs is not { } predBGs) return []; + forecasts = DeviceStatusState.PredictionTypes.Where(predBGs.ContainsKey).Select(t => predBGs[t]).ToList(); + start = s.OpenApsPredUpdatedTime ?? now; } - - if (s.OpenApsPredBGs is not { } predBGs) return []; - var forecasts = DeviceStatusState.PredictionTypes.Where(predBGs.ContainsKey).Select(t => predBGs[t]).ToList(); - var start = s.OpenApsPredUpdatedTime ?? now; var count = Math.Min(forecasts.Max(f => f.Count), AlarmForecastPointCap); var result = new List(); for (var i = 0; i < count; i++) diff --git a/README.md b/README.md index 614e142..8c14e37 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ A request body is at most 256 KB; a larger one is a 413. | `GET /v1/me` | The device (settings, APNs state, last push, push health, alarm list state, active alarm, alarms the hub disabled, Live Activity state), the patient (name, Nightscout host, whether Dexcom is set, whether it is followed and has data) and the live stream status. Never returns keys, tokens or passwords. See below. | | `PUT /v1/device/apns` | `{token, environment: production\|sandbox, bundleId, teamId, keyId?, key?}`. `key` (the .p8) is kept when left out. Without `keyId`, or without `key` on first registration, the device pushes with the key of another device of the patient registered with a key of its own for the same team and bundle id, an admin device's first (`apnsKeyShared` in `/v1/me`), and follows a new key any such device sends; with no such device it is a 400. A device that sends its own key stops sharing. `token` must be hex, `bundleId` letters, digits, `.` and `-`, `teamId` and `keyId` ten upper-case letters or digits, and `key` a P-256 private key in PKCS #8 (Apple's .p8, with or without its PEM lines); anything else is a 400 and nothing is stored. Registering a push token removes any other device with the same token and bundle id, so a reinstalled app does not leave a second device alarming. | | `PUT /v1/device/settings` | Merge of `{badge: bool, liveActivity: bool, alarms: bool, criticalAlerts: bool, unit: mgdl\|mmol, appVersion: string, features: [string]}` (`appVersion` and each feature at most 32 characters, at most 32 features; see Compatibility); any other key, or a value of another type, is a 400. `alarms: true` is a 409 while the device has not reported every feature the hub requires (`receipts`). `alarms: true` is a 409 while the device has no alarm list that parses (upload it with `PUT /v1/device/alarms` first), or no client settings whose `timeZone` the hub knows (upload them with `PUT /v1/device/clientsettings` first), since day and night are the phone's. Answers the merged settings. | -| `PUT /v1/device/clientsettings` | The LoopFollow settings that change what the hub builds, keyed as LoopFollow stores them (`units`, `predictionToLoad`, `downloadPrediction`, `downloadTreatments`, `downloadDays`, `showYesterdayLine` (the BG history then reaches one day further back, for the graph's yesterday overlay), `bgUpdateDelay`, `infoDisplayItems`, `persistentNotification`, and Speak BG's `speakBG`, `speakBGAlways`, `speakLowBG`, `speakProactiveLowBG`, `speakHighBG`, `speakFastDropDelta`, `speakLowBGLimit`, `speakHighBGLimit`, `speakLanguage`), plus the phone's `timeZone` (an IANA zone such as `Europe/Stockholm`), `decimalSeparator` and `timeFormat`. Missing or mistyped keys take LoopFollow's defaults; without a `timeZone` the hub knows, day and night and local times are taken in UTC, which the hub logs once per device that names a zone or has alarms on. | +| `PUT /v1/device/clientsettings` | The LoopFollow settings that change what the hub builds, keyed as LoopFollow stores them (`units`, `predictionToLoad`, `downloadTreatments`, `downloadDays`, `showYesterdayLine` (the BG history then reaches one day further back, for the graph's yesterday overlay), `bgUpdateDelay`, `infoDisplayItems`, `persistentNotification`, and Speak BG's `speakBG`, `speakBGAlways`, `speakLowBG`, `speakProactiveLowBG`, `speakHighBG`, `speakFastDropDelta`, `speakLowBGLimit`, `speakHighBGLimit`, `speakLanguage`), plus the phone's `timeZone` (an IANA zone such as `Europe/Stockholm`), `decimalSeparator` and `timeFormat`. Missing or mistyped keys take LoopFollow's defaults; without a `timeZone` the hub knows, day and night and local times are taken in UTC, which the hub logs once per device that names a zone or has alarms on. | | `PUT /v1/device/alarms` | The app's alarm export (`AlarmSettingsExport` JSON) as is: alarms plus the global alarm configuration. Snoozes, Snooze All and Mute travel this way. An alarm of a type or option the hub does not know is skipped and logged; the others keep working. An export that does not parse at all, or that holds more than 200 alarms, is a 400. The hub parses each export once, when it first checks it. | | `POST /v1/receipt/{token}` | No body and no device key: the token is the credential. The phone's delivery receipt for an alarm push, `token` being the push's `lfhubReceipt`. 204 when the token is known, unused and less than 30 minutes old, which uses it up; 404 for any other token; 429 (with `Retry-After`) beyond 120 receipts a minute across the hub. See Alarms. | | `POST /v1/device/alarm/{alarmId}/handled` | The app snoozed or acknowledged the alarm, by any of its snooze paths. See Alarms. | diff --git a/tests/LFHub.Tests/State/LoopStateTests.cs b/tests/LFHub.Tests/State/LoopStateTests.cs index 4b1ad9e..4dc52f0 100644 --- a/tests/LFHub.Tests/State/LoopStateTests.cs +++ b/tests/LFHub.Tests/State/LoopStateTests.cs @@ -95,6 +95,7 @@ private static string Entries() private static readonly ClientSettings UtcMgdl = ClientSettings.FromJson(JsonNode.Parse("""{"units":"mg/dL"}""")!.AsObject()); private static readonly ClientSettings La = ClientSettings.FromJson(JsonNode.Parse( """{"timeZone":"America/Los_Angeles","downloadPrediction":false,"predictionToLoad":3,"timeFormat":"h:mm a","downloadDays":2}""")!.AsObject()); + private static readonly ClientSettings ShortForecast = ClientSettings.FromJson(JsonNode.Parse("""{"predictionToLoad":0.25}""")!.AsObject()); private sealed class Patient : IDisposable { @@ -211,7 +212,7 @@ public async Task Snapshot_FollowsEachDevicesSettings() var l = p.State.BuildSnapshot(La, p.Now)!; Assert.Equal(40.0, N(l, "carbsToday")); Assert.Equal("Temporary sensor issue at 4:58 AM", l["cgmSensorState"]?.ToString()); - Assert.Null(l["minBgMgdl"]); + Assert.Equal(30.0, N(l, "minBgMgdl")); Assert.Equal(U("2026-10-24T13:00:00Z"), N(l, "overrideEndAt")); } @@ -223,11 +224,13 @@ public async Task AlarmData_BgAndForecast() Assert.Equal(24, a.BgReadings.Count); Assert.Equal(101, a.BgReadings[^1].Sgv); Assert.DoesNotContain(a.BgReadings, g => g.Sgv > 600); - var expected = Pred.Take(13).Select((v, i) => - $"{Math.Clamp((int)Math.Round(v, MidpointRounding.AwayFromZero), 39, 400)}@{U("2026-10-24T11:57:30Z") + 300 * i}"); - Assert.Equal(string.Join(",", expected), + var expected = string.Join(",", Pred.Take(13).Select((v, i) => + $"{(int)Math.Round(v, MidpointRounding.AwayFromZero)}@{U("2026-10-24T11:57:30Z") + 300 * i}")); + Assert.Equal(expected, string.Join(",", a.PredictionData.Select(x => $"{x.Sgv}@{(x.Date - DateTime.UnixEpoch).TotalSeconds}"))); - Assert.Empty(p.State.BuildAlarmData(La, p.Now)!.PredictionData); + Assert.Equal(expected, + string.Join(",", p.State.BuildAlarmData(La, p.Now)!.PredictionData.Select(x => $"{x.Sgv}@{(x.Date - DateTime.UnixEpoch).TotalSeconds}"))); + Assert.Equal(13, p.State.BuildAlarmData(ShortForecast, p.Now)!.PredictionData.Count); } [Fact] From 52c42fdad069fecf952349ce79b9650aca3ff8bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Nov 2026 09:50:08 +0000 Subject: [PATCH 5/8] CI: Bump dev version to 0.2.2 [skip ci] --- lfhub.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lfhub.csproj b/lfhub.csproj index 65d7e20..5eec167 100644 --- a/lfhub.csproj +++ b/lfhub.csproj @@ -6,7 +6,7 @@ enable LFHub true - 0.2.1 + 0.2.2 mcr.microsoft.com/dotnet/aspnet:10.0 From c8640f52ff2a32b2ade8f7162d26aa2a7813fe4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 5 Nov 2026 09:33:00 +0100 Subject: [PATCH 6/8] Remove the heartbeat push and note that a change to the alarm types the hub checks needs a new API version --- Api/HubInfo.cs | 2 +- Apns/ApnsClient.cs | 28 -------- Apns/IApnsClient.cs | 2 - Engine/Alarms/AlarmType.cs | 4 ++ Engine/HubEngine.Alarms.cs | 27 -------- Engine/HubEngine.cs | 5 +- README.md | 14 +--- tests/LFHub.Tests/Engine/HeartbeatTests.cs | 81 ---------------------- tests/LFHub.Tests/Fakes/FakeApns.cs | 7 -- 9 files changed, 9 insertions(+), 161 deletions(-) delete mode 100644 tests/LFHub.Tests/Engine/HeartbeatTests.cs diff --git a/Api/HubInfo.cs b/Api/HubInfo.cs index 10d09ae..384cf4e 100644 --- a/Api/HubInfo.cs +++ b/Api/HubInfo.cs @@ -25,7 +25,7 @@ public static class HubInfo .FirstOrDefault(a => a.Key == "Channel")?.Value; /// Features this hub provides. - public static readonly string[] Features = ["receipts", "speak", "heartbeat", "bundle", "latestBg", "devices", "roles", "invites", "sourceRevision", "sharedApnsKey"]; + public static readonly string[] Features = ["receipts", "speak", "bundle", "latestBg", "devices", "roles", "invites", "sourceRevision", "sharedApnsKey"]; /// The app posts a delivery receipt for every alarm push (POST /v1/receipt/{token}). public const string Receipts = "receipts"; diff --git a/Apns/ApnsClient.cs b/Apns/ApnsClient.cs index 017243c..2d82ef5 100644 --- a/Apns/ApnsClient.cs +++ b/Apns/ApnsClient.cs @@ -202,34 +202,6 @@ internal static (JsonObject Payload, Dictionary Headers) Speak(s return (payload, headers); } - /// - /// The heartbeat sent every 20 minutes to a device with alarms on: a background push - /// whose lfhubHeartbeat (Unix seconds) tells the app when it was sent. It goes at - /// priority 10; iOS may still hold a background push back or drop it. - /// - public Task SendHeartbeatAsync(ApnsConfig cfg, DateTime nowUtc, CancellationToken ct) - { - var (payload, headers) = Heartbeat(nowUtc); - return SendAsync(cfg, cfg.Token, cfg.BundleId, payload, "background", "10", ct, headers); - } - - /// The heartbeat's payload and its collapse and expiration headers; it expires after 20 minutes. - internal static (JsonObject Payload, Dictionary Headers) Heartbeat(DateTime nowUtc) - { - var unix = new DateTimeOffset(DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds(); - var payload = new JsonObject - { - ["aps"] = new JsonObject { ["content-available"] = 1 }, - ["lfhubHeartbeat"] = unix, - }; - var headers = new Dictionary - { - ["apns-collapse-id"] = "lfhub-heartbeat", - ["apns-expiration"] = (unix + 20 * 60).ToString(System.Globalization.CultureInfo.InvariantCulture), - }; - return (payload, headers); - } - private static JsonObject Alert(string title, string subtitle, string? body) { var alert = new JsonObject { ["title"] = title, ["subtitle"] = subtitle }; diff --git a/Apns/IApnsClient.cs b/Apns/IApnsClient.cs index 6999e39..6cdd9de 100644 --- a/Apns/IApnsClient.cs +++ b/Apns/IApnsClient.cs @@ -28,8 +28,6 @@ Task SendAlarmAsync(ApnsConfig cfg, Guid alarmId, string title, stri Task SendAlarmStopAsync(ApnsConfig cfg, Guid alarmId, string title, string subtitle, string? body, AlarmStopReason reason, CancellationToken ct); - Task SendHeartbeatAsync(ApnsConfig cfg, DateTime nowUtc, CancellationToken ct); - Task SendLatestBgAsync(ApnsConfig cfg, string subtitle, CancellationToken ct); Task SendSpeakAsync(ApnsConfig cfg, string title, int current, int previous, string language, diff --git a/Engine/Alarms/AlarmType.cs b/Engine/Alarms/AlarmType.cs index b10c7f4..419e852 100644 --- a/Engine/Alarms/AlarmType.cs +++ b/Engine/Alarms/AlarmType.cs @@ -3,6 +3,10 @@ namespace LFHub.Engine.Alarms; /// /// LoopFollow's alarm types (Alarm/AlarmType/AlarmType.swift). Declaration /// order is priority order: a lower value is checked first. +/// The app keeps its own list of the types each hub API version checks +/// (HubClient.hubAlarmTypes) and checks the other types itself. A change to the types +/// the hub checks needs a new version and that +/// version's list in the app. /// public enum AlarmType { diff --git a/Engine/HubEngine.Alarms.cs b/Engine/HubEngine.Alarms.cs index 702c350..d4cf826 100644 --- a/Engine/HubEngine.Alarms.cs +++ b/Engine/HubEngine.Alarms.cs @@ -29,13 +29,6 @@ public sealed partial class HubEngine /// private static readonly TimeSpan SingleRing = TimeSpan.FromSeconds(30); - /// - /// How often a device with alarms on gets a heartbeat push. The app logs how late each - /// one arrives; the heartbeat drives no alarm. - /// - private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromMinutes(20); - - private readonly ConcurrentDictionary _lastHeartbeat = new(); private readonly ConcurrentDictionary _lastAlarmCheck = new(); /// Devices with alarms on that cannot get them, logged once until that changes. private readonly ConcurrentDictionary _alarmsUnsentWarned = new(); @@ -245,26 +238,6 @@ private async Task StopForAlarmsOffAsync(Device device, ActiveAlarm sounding, Ca await StopRingingAsync(device, sounding, AlarmStopReason.Silenced, ct); } - /// - /// Sends the device a heartbeat at its first check with alarms on and APNs registered, - /// then every 20 minutes while that holds. The time of the last one is kept in memory. - /// - private async Task HeartbeatAsync(Device device, CancellationToken ct) - { - if (!device.AlarmsEnabled || device.Apns is null) - { - _lastHeartbeat.TryRemove(device.Id, out _); - return; - } - var now = UtcNow; - if (_lastHeartbeat.TryGetValue(device.Id, out var last) && now - last < HeartbeatInterval) return; - _lastHeartbeat[device.Id] = now; - var result = await _apns.SendHeartbeatAsync(device.Apns, now, ct); - await TrackPushAsync(device.Id, result, PushKind.Device); - if (!result.Delivered) - _log.LogWarning("Heartbeat to {Device} failed: {Status}", device.Id, result.Status); - } - /// /// The persistent "Latest BG" notification, sent once per new reading when no /// alarm fired, as AlarmManager.checkAlarms does it. diff --git a/Engine/HubEngine.cs b/Engine/HubEngine.cs index b84a424..16bcba9 100644 --- a/Engine/HubEngine.cs +++ b/Engine/HubEngine.cs @@ -402,7 +402,7 @@ internal async Task EvaluateAsync(Guid patientId, bool refresh, bool newData, Ca using var alarmCheck = new CancellationTokenSource(Timeout.InfiniteTimeSpan, _time); using var onStop = ct.Register(() => alarmCheck.CancelAfter(AlarmCheckShutdownGrace)); - // Each device's badge, Live Activity, alarms, speech and heartbeat run on their own, so a + // Each device's badge, Live Activity, alarms and speech run on their own, so a // failure in one never holds back another. foreach (var device in devices) { @@ -412,7 +412,6 @@ internal async Task EvaluateAsync(Guid patientId, bool refresh, bool newData, Ca await GuardAsync("Alarm check", device.Id, () => AlarmsAsync(device, rt.State, settings[device.Id], newData, alarmCheck.Token), alarmCheck.Token); await GuardAsync("Speak", device.Id, () => SpeakAsync(device, rt.State, settings[device.Id], ct), ct); - await GuardAsync("Heartbeat", device.Id, () => HeartbeatAsync(device, ct), ct); } } catch (Exception ex) when (!ct.IsCancellationRequested) @@ -482,7 +481,7 @@ private ClientSettings ClientSettingsFor(Device device) /// internal enum PushKind { - /// A badge, alarm stop, Latest BG, speech or heartbeat. + /// A badge, alarm stop, Latest BG or speech. Device, /// An alarm. Alarm, diff --git a/README.md b/README.md index 8c14e37..056fb50 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ A request body is at most 256 KB; a larger one is a 413. answer to it. Only badge pushes set them; every push counts in `lastPushOkAt` and `pushFailingSince`. - `lastPushOkAt`: when a push to the device's own push token (badge, alarm, - alarm stop, Latest BG, speech, heartbeat, test alarm) was last accepted by APNs. Pushes + alarm stop, Latest BG, speech, test alarm) was last accepted by APNs. Pushes to a Live Activity token are not counted: a rejected one means the activity ended. - `pushFailingSince`: when the pushes to the device began to be rejected; null while the last one was accepted. Pushes rejected for 10 minutes are logged as a @@ -128,7 +128,7 @@ sides have reported it: |---|---| | `receipts` | The app posts a delivery receipt for every alarm push. Required: `alarms: true` is refused (409, "update LoopFollow") until the app reports it. | | `speak` | The app's notification service extension speaks Speak BG pushes; the hub sends them only to apps that report it. | -| `heartbeat`, `bundle`, `latestBg` | Provided by the hub. | +| `bundle`, `latestBg` | Provided by the hub. | | `devices` | Joining in a device's place or as its copy, the device list, and reading back the sources, alarms and client settings. | | `roles`, `invites` | Admin and member devices, and joining with an invite. | | `sourceRevision` | `patient.sourceRevision` in `/v1/me`. | @@ -324,16 +324,6 @@ memory; a device seen for the first time since the hub started, or whose speech off, starts from the current reading without speaking it, so a restarted hub repeats no reading. -**Heartbeat**: each device with `alarms: true` and APNs registered gets a -background push at its first check and then every 20 minutes: -`{"aps":{"content-available":1},"lfhubHeartbeat":}`, with -`apns-push-type: background`, `apns-priority: 10`, `apns-collapse-id: lfhub-heartbeat` and an expiration 20 minutes -ahead. The app logs how long each one took to arrive; iOS may hold a background push -back or drop it, so these delays decide whether the heartbeat can drive a dead-man -alarm on the phone. The time of the last heartbeat is kept in memory, so a restarted -hub sends one at its first check. A rejected heartbeat counts toward the device's -push health like any other push. - The alarm state (the engine's state and the alarm being delivered) is stored per device, written before a check sends its pushes. On shutdown, an alarm check that has begun runs to its end, for at most 10 seconds, so the outcome of its push is diff --git a/tests/LFHub.Tests/Engine/HeartbeatTests.cs b/tests/LFHub.Tests/Engine/HeartbeatTests.cs deleted file mode 100644 index 392b0e5..0000000 --- a/tests/LFHub.Tests/Engine/HeartbeatTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text.Json.Nodes; -using LFHub.Apns; -using LFHub.Engine.Alarms; - -namespace LFHub.Tests.Engine; - -/// The heartbeat push a device with alarms on gets every 20 minutes. -public class HeartbeatTests -{ - [Fact] - public async Task Heartbeat_SentAtTheFirstCheckThenEveryTwentyMinutes() - { - var h = new EngineHarness(); - h.AlarmDevice([Alarm.Create(AlarmType.Low)]); - await h.ReconcileAsync(); - await h.TickAsync(); - Assert.Equal([EngineHarness.Start], h.Apns.Heartbeats); - - await h.TicksAsync(TimeSpan.FromMinutes(19.5)); - Assert.Single(h.Apns.Heartbeats); - await h.TicksAsync(TimeSpan.FromSeconds(30)); - Assert.Equal([EngineHarness.Start, EngineHarness.Start.AddMinutes(20)], h.Apns.Heartbeats); - } - - [Fact] - public async Task Heartbeat_NotSentWithAlarmsOffOrWithoutApns() - { - var h = new EngineHarness(); - h.AlarmDevice([Alarm.Create(AlarmType.Low)], edit: r => r.Settings["alarms"] = false); - var unregistered = h.AlarmDevice([Alarm.Create(AlarmType.Low)], edit: r => r.Apns = null); - h.AddDevice(r => r.Settings = new JsonObject { ["badge"] = true }); - await h.ReconcileAsync(); - await h.TicksAsync(TimeSpan.FromMinutes(45)); - Assert.Empty(h.Apns.Heartbeats); - - // Once eligible, the next check sends one. - unregistered.Apns = new LFHub.Db.ApnsConfig("device-token", "sandbox", "org.example.app", "TEAM000000", "KEY0000000", "unused"); - await h.TickAsync(); - Assert.Equal([h.Now], h.Apns.Heartbeats); - } - - [Fact] - public async Task Heartbeat_SentAgainAtOnceWhenAlarmsAreTurnedBackOn() - { - var h = new EngineHarness(); - var device = h.AlarmDevice([Alarm.Create(AlarmType.Low)]); - await h.ReconcileAsync(); - await h.TickAsync(); - device.Settings["alarms"] = false; - await h.TicksAsync(TimeSpan.FromMinutes(5)); - device.Settings["alarms"] = true; - await h.TickAsync(); - Assert.Equal([EngineHarness.Start, h.Now], h.Apns.Heartbeats); - } - - [Fact] - public async Task FailedHeartbeat_CountsTowardPushHealth() - { - var h = new EngineHarness(); - var device = h.AlarmDevice([Alarm.Create(AlarmType.Low)]); - h.Apns.Result = new PushResult(false, "503 ServiceUnavailable", false, 503); - await h.ReconcileAsync(); - await h.TickAsync(); - Assert.Single(h.Apns.Heartbeats); - Assert.Equal(EngineHarness.Start, device.PushFailingSince); - Assert.Null(device.LastAlarmPushOk); - } - - [Fact] - public void Payload_IsABackgroundPushThatExpiresWithTheNextOneDue() - { - var now = new DateTime(2026, 10, 24, 12, 0, 0, DateTimeKind.Utc); - var unix = new DateTimeOffset(now).ToUnixTimeSeconds(); - var (payload, headers) = ApnsClient.Heartbeat(now); - Assert.Equal( - $$"""{"aps":{"content-available":1},"lfhubHeartbeat":{{unix}}}""", - payload.ToJsonString()); - Assert.Equal("lfhub-heartbeat", headers["apns-collapse-id"]); - Assert.Equal((unix + 1200).ToString(System.Globalization.CultureInfo.InvariantCulture), headers["apns-expiration"]); - } -} diff --git a/tests/LFHub.Tests/Fakes/FakeApns.cs b/tests/LFHub.Tests/Fakes/FakeApns.cs index 9acef7b..a4c4c3f 100644 --- a/tests/LFHub.Tests/Fakes/FakeApns.cs +++ b/tests/LFHub.Tests/Fakes/FakeApns.cs @@ -21,7 +21,6 @@ public sealed record SpeakPush(string Title, int Current, int Previous, string L public List LatestBg { get; } = []; public List Speeches { get; } = []; - public List Heartbeats { get; } = []; public List LiveActivities { get; } = []; public PushResult Result { get; set; } = new(true, "200", false, 200); @@ -54,12 +53,6 @@ public Task SendAlarmStopAsync(ApnsConfig cfg, Guid alarmId, string return Task.FromResult(Result); } - public Task SendHeartbeatAsync(ApnsConfig cfg, DateTime nowUtc, CancellationToken ct) - { - Heartbeats.Add(nowUtc); - return Task.FromResult(Result); - } - public Task SendLatestBgAsync(ApnsConfig cfg, string subtitle, CancellationToken ct) { LatestBg.Add(subtitle); From a3dddbb87d9f431edb34fd3fe60fa5af074cd18c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 5 Nov 2026 08:40:41 +0000 Subject: [PATCH 7/8] CI: Bump dev version to 0.2.3 [skip ci] --- lfhub.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lfhub.csproj b/lfhub.csproj index 5eec167..8f6d8f5 100644 --- a/lfhub.csproj +++ b/lfhub.csproj @@ -6,7 +6,7 @@ enable LFHub true - 0.2.2 + 0.2.3 mcr.microsoft.com/dotnet/aspnet:10.0 From 8988382f164ceead79f56bdb342640aca2ea72a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 5 Nov 2026 09:45:45 +0100 Subject: [PATCH 8/8] Update version to 0.3.0 --- lfhub.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lfhub.csproj b/lfhub.csproj index 8f6d8f5..49efe5c 100644 --- a/lfhub.csproj +++ b/lfhub.csproj @@ -6,7 +6,7 @@ enable LFHub true - 0.2.3 + 0.3.0 mcr.microsoft.com/dotnet/aspnet:10.0