diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e3d8a8ac7..c278a4bde 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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 entry for the version being shipped; a stale + # 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 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 743eb4e5f..4ae68689a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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) diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 11e578249..f0821be06 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index e832eece7..2550de7df 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -358,10 +358,14 @@ Demo Mode (Replay) clear node filter Description + Meshtastic Quit Show Meshtastic Meshtastic Desktop + Meshtastic Desktop %1$s is ready to download. + App update available + Download update %1$s Details Detection Sensor Detection Sensor Config diff --git a/desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml b/desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml index e3dfda7f5..9277b1af2 100644 --- a/desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml +++ b/desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml @@ -92,6 +92,11 @@ + + +

In-app update notifications, native OS notifications, deep-link handling, keyboard shortcuts, window-state persistence, and many stability fixes.

+
+

Initial release of Meshtastic Desktop.

diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index f71126606..86b828c96 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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() + val httpClient = koinInject() + val updateInfo by + produceState(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]. */ diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/UpdateChecker.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/UpdateChecker.kt new file mode 100644 index 000000000..08c637b55 --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/UpdateChecker.kt @@ -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 . + */ +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? = + 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() + } + } +} diff --git a/desktopApp/src/test/kotlin/org/meshtastic/desktop/UpdateCheckerTest.kt b/desktopApp/src/test/kotlin/org/meshtastic/desktop/UpdateCheckerTest.kt new file mode 100644 index 000000000..c698f4034 --- /dev/null +++ b/desktopApp/src/test/kotlin/org/meshtastic/desktop/UpdateCheckerTest.kt @@ -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 . + */ +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")) + } +} diff --git a/scripts/verify-flatpak/desktop-offline.yaml b/scripts/verify-flatpak/desktop-offline.yaml index bb6b47c90..8b2f041fb 100644 --- a/scripts/verify-flatpak/desktop-offline.yaml +++ b/scripts/verify-flatpak/desktop-offline.yaml @@ -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