feat(desktop): update notifications and store-readiness fixes (#6215)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-10 21:40:33 -05:00
committed by GitHub
parent cc4debdbfa
commit b307ceb5c2
9 changed files with 220 additions and 2 deletions

View File

@@ -114,6 +114,17 @@ jobs:
- uses: actions/checkout@v7
- name: Validate store listing metadata lengths
run: python3 scripts/check-metadata-length.py
# Flathub/AppStream needs a <release> entry for the version being shipped; a stale
# <releases> block degrades (or fails) the Flathub listing. Fails the PR that bumps
# VERSION_NAME_BASE until the matching entry is added.
- name: Require AppStream release entry for current version
run: |
VERSION=$(grep '^VERSION_NAME_BASE=' config.properties | cut -d'=' -f2)
METAINFO=desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml
if ! grep -q "version=\"$VERSION\"" "$METAINFO"; then
echo "::error file=$METAINFO::Missing <release version=\"$VERSION\"> entry. Add it alongside the VERSION_NAME_BASE bump."
exit 1
fi
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
# We disable coverage and desktop builds for PRs to keep feedback fast

View File

@@ -306,7 +306,7 @@ jobs:
run: >
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
${{ contains(runner.os, 'macOS') && ':desktopApp:packageReleaseUberJarForCurrentOS' || '' }}
'-PaboutLibraries.release=true' --no-daemon
'-PaboutLibraries.release=true' '-Pdesktop.release=true' --no-daemon
# jpackage computes the .deb Depends: line from the build host's package names.
# Ubuntu 24.04 records time_t64 transition names (libasound2t64, libpng16-16t64)

View File

@@ -334,10 +334,14 @@ demo_mode
demo_mode_replay
desc_node_filter_clear
description
### DESKTOP ###
desktop_notification_title
desktop_tray_quit
desktop_tray_show
desktop_tray_tooltip
desktop_update_available_message
desktop_update_available_title
desktop_update_download
details
detection_sensor
detection_sensor_config

View File

@@ -358,10 +358,14 @@
<string name="demo_mode_replay">Demo Mode (Replay)</string>
<string name="desc_node_filter_clear">clear node filter</string>
<string name="description">Description</string>
<!-- DESKTOP -->
<string name="desktop_notification_title">Meshtastic</string>
<string name="desktop_tray_quit">Quit</string>
<string name="desktop_tray_show">Show Meshtastic</string>
<string name="desktop_tray_tooltip">Meshtastic Desktop</string>
<string name="desktop_update_available_message">Meshtastic Desktop %1$s is ready to download.</string>
<string name="desktop_update_available_title">App update available</string>
<string name="desktop_update_download">Download update %1$s</string>
<string name="details">Details</string>
<string name="detection_sensor">Detection Sensor</string>
<string name="detection_sensor_config">Detection Sensor Config</string>

View File

@@ -92,6 +92,11 @@
</screenshots>
<releases>
<release version="2.8.0" date="2026-07-10">
<description>
<p>In-app update notifications, native OS notifications, deep-link handling, keyboard shortcuts, window-state persistence, and many stability fixes.</p>
</description>
</release>
<release version="2.7.14" date="2026-06-03">
<description>
<p>Initial release of Meshtastic Desktop.</p>

View File

@@ -24,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -66,6 +67,7 @@ import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.first
import okio.Path.Companion.toPath
import org.jetbrains.compose.resources.decodeToSvgPainter
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.core.context.startKoin
@@ -77,14 +79,19 @@ import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.rememberMultiBackstack
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.desktop_tray_quit
import org.meshtastic.core.resources.desktop_tray_show
import org.meshtastic.core.resources.desktop_tray_tooltip
import org.meshtastic.core.resources.desktop_update_available_message
import org.meshtastic.core.resources.desktop_update_available_title
import org.meshtastic.core.resources.desktop_update_download
import org.meshtastic.core.service.MeshServiceOrchestrator
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.rememberOpenUrl
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
@@ -223,6 +230,9 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
notificationManager.fallbackNotifications.collect { notification -> trayState.sendNotification(notification) }
}
val openUrl = rememberOpenUrl()
val updateInfo = rememberAvailableUpdate(notificationManager)
WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true }
Tray(
@@ -231,6 +241,12 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
tooltip = stringResource(Res.string.desktop_tray_tooltip),
onAction = { isAppVisible = true },
menu = {
updateInfo?.let { update ->
Item(
stringResource(Res.string.desktop_update_download, update.versionName),
onClick = { openUrl(update.releaseUrl) },
)
}
Item(stringResource(Res.string.desktop_tray_show), onClick = { isAppVisible = true })
Item(stringResource(Res.string.desktop_tray_quit), onClick = ::exitApplication)
},
@@ -252,6 +268,39 @@ private fun ApplicationScope.MeshtasticDesktopApp(uiViewModel: UIViewModel, isDa
}
}
// ----- Update discovery -----
/**
* Checks once per launch whether a newer release is published on GitHub and surfaces it via a native notification.
* Release builds only — dev builds carry the bare base version and would match or trail every published release.
* Flatpak installs (FLATPAK_ID is set inside the sandbox) update through Flathub instead, so don't point them at
* GitHub.
*/
@Composable
private fun rememberAvailableUpdate(notificationManager: DesktopNotificationManager): UpdateChecker.UpdateInfo? {
val buildConfig = koinInject<BuildConfigProvider>()
val httpClient = koinInject<HttpClient>()
val updateInfo by
produceState<UpdateChecker.UpdateInfo?>(initialValue = null) {
if (!buildConfig.isDebug && System.getenv("FLATPAK_ID") == null) {
value = UpdateChecker(httpClient).check(buildConfig.versionName)
}
}
LaunchedEffect(updateInfo) {
updateInfo?.let { update ->
notificationManager.dispatch(
Notification(
title = getString(Res.string.desktop_update_available_title),
message = getString(Res.string.desktop_update_available_message, update.versionName),
category = Notification.Category.Service,
),
)
}
}
return updateInfo
}
// ----- Window bounds persistence -----
/** Restores window geometry from preferences and persists changes via [snapshotFlow]. */

View File

@@ -0,0 +1,81 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop
import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Checks GitHub Releases for a newer published desktop build.
*
* The `releases/latest` endpoint only ever returns a published, non-draft, non-prerelease release, so users are only
* pointed at builds that completed the full promotion pipeline (see `.github/workflows/promote.yml`).
*/
class UpdateChecker(private val httpClient: HttpClient) {
/** A published release newer than the running build. */
data class UpdateInfo(val versionName: String, val releaseUrl: String)
/** Returns the latest published release when it is newer than [currentVersionName], null otherwise. */
suspend fun check(currentVersionName: String): UpdateInfo? = runCatching {
val release = Json.parseToJsonElement(httpClient.get(LATEST_RELEASE_URL).bodyAsText()).jsonObject
val tag = release["tag_name"]?.jsonPrimitive?.content
if (tag != null && isNewer(latest = tag, current = currentVersionName)) {
UpdateInfo(
versionName = tag.removePrefix("v"),
releaseUrl = release["html_url"]?.jsonPrimitive?.content ?: RELEASES_PAGE_URL,
)
} else {
null
}
}
.getOrElse { e ->
Logger.d(e) { "Update check skipped: offline, rate-limited, or malformed response" }
null
}
companion object {
private const val LATEST_RELEASE_URL =
"https://api.github.com/repos/meshtastic/Meshtastic-Android/releases/latest"
private const val RELEASES_PAGE_URL = "https://github.com/meshtastic/Meshtastic-Android/releases"
private val VERSION_REGEX = Regex("""(\d+)\.(\d+)\.(\d+)""")
/**
* True when [latest] carries a strictly newer numeric X.Y.Z than [current]. Suffixes (`v`, `-internal.N`) are
* ignored; versions that don't parse never trigger an update.
*/
internal fun isNewer(latest: String, current: String): Boolean {
val latestVersion = parse(latest)
val currentVersion = parse(current)
return latestVersion != null &&
currentVersion != null &&
compareValuesBy(latestVersion, currentVersion, { it.first }, { it.second }, { it.third }) > 0
}
private fun parse(raw: String): Triple<Int, Int, Int>? =
VERSION_REGEX.find(raw)?.destructured?.let { (major, minor, patch) ->
// runCatching: \d+ can match components too large for Int; treat those as unparseable.
runCatching { Triple(major.toInt(), minor.toInt(), patch.toInt()) }.getOrNull()
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UpdateCheckerTest {
@Test
fun `newer patch release is an update`() {
assertTrue(UpdateChecker.isNewer(latest = "v2.8.1", current = "2.8.0"))
}
@Test
fun `same version is not an update`() {
assertFalse(UpdateChecker.isNewer(latest = "v2.8.0", current = "2.8.0"))
}
@Test
fun `older release is not an update`() {
assertFalse(UpdateChecker.isNewer(latest = "v2.7.14", current = "2.8.0"))
}
@Test
fun `channel suffixes are ignored when comparing`() {
assertFalse(UpdateChecker.isNewer(latest = "v2.8.0", current = "2.8.0-internal.24"))
assertTrue(UpdateChecker.isNewer(latest = "v2.8.1", current = "2.8.0-internal.24"))
}
@Test
fun `components compare numerically not lexically`() {
assertTrue(UpdateChecker.isNewer(latest = "v2.10.0", current = "2.9.9"))
assertTrue(UpdateChecker.isNewer(latest = "v3.0.0", current = "2.99.0"))
}
@Test
fun `unparseable versions never trigger an update`() {
assertFalse(UpdateChecker.isNewer(latest = "snapshot", current = "2.8.0"))
assertFalse(UpdateChecker.isNewer(latest = "v2.8.1", current = "unknown"))
}
@Test
fun `oversized version components never trigger an update`() {
assertFalse(UpdateChecker.isNewer(latest = "v99999999999999999999.0.0", current = "2.8.0"))
}
}

View File

@@ -83,7 +83,9 @@ modules:
's/^\(\s*\)vendor\.set(JvmVendorSpec\.JETBRAINS)/\1\/\/ vendor.set(JvmVendorSpec.JETBRAINS)/'
desktopApp/build.gradle.kts
# Force Gradle to resolve from the bundled offline-repository ONLY (true offline test).
- ./gradlew --offline :desktopApp:packageUberJarForCurrentOS
# -Pdesktop.release=true: shipped builds must have IS_DEBUG=false (no debug HTTP
# logging, enables the in-app update check). Mirror this in the upstream Flathub manifest.
- ./gradlew --offline -Pdesktop.release=true :desktopApp:packageUberJarForCurrentOS
- >
JAR_FILE=$(find desktopApp/build/compose/jars/ -name "*.jar" -type f | head -1)
&& install -Dm755 "$JAR_FILE" /app/lib/meshtastic-desktop.jar