From 724df4ea37dac598d4973b6c679e8d5dad65a0ba Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Thu, 14 May 2026 23:46:29 +0200 Subject: [PATCH] feat(linux): package Linux release, deploy to server, add in-app update banner Build task embeds GIT_HASH via --dart-define; new deploy-linux-to-server task packages a tar.gz and updates latest.json on the server. The account list screen shows a MaterialBanner when a newer Linux build is available. Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/ci.yml | 16 ++++++ Taskfile.yml | 24 ++++++++- lib/core/services/update_service.dart | 37 ++++++++++++++ lib/ui/screens/account_list_screen.dart | 68 +++++++++++++++++++------ scripts/check_coverage.dart | 1 + 5 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 lib/core/services/update_service.dart diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 96172d3..4ffc6d2 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -43,6 +43,22 @@ jobs: - name: Build Linux run: nix develop --no-warn-dirty --command task build-linux-release + - name: Set up SSH key + continue-on-error: true + env: + SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} + run: | + mkdir -p ~/.ssh + echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + + - name: Deploy Linux to server + continue-on-error: true + env: + SSH_USER: ${{ secrets.SSH_USER }} + SSH_HOST: ${{ secrets.SSH_HOST }} + run: nix develop --no-warn-dirty --command task deploy-linux-to-server + deploy-playstore: name: Build & Deploy to Play Store runs-on: self-hosted diff --git a/Taskfile.yml b/Taskfile.yml index 82f43f0..16ec2c4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -216,7 +216,29 @@ tasks: generates: - build/linux/x64/release/bundle/sharedinbox cmds: - - scripts/silent_on_success.sh fvm flutter build linux --release --no-pub + - scripts/silent_on_success.sh fvm flutter build linux --release --no-pub --dart-define=GIT_HASH=$(git rev-parse --short HEAD) + + deploy-linux-to-server: + desc: Package and deploy the Linux release bundle to the server, update latest.json + deps: [build-linux-release] + preconditions: + - sh: test -n "$SSH_USER" + msg: "SSH_USER is not set" + - sh: test -n "$SSH_HOST" + msg: "SSH_HOST is not set" + cmds: + - | + HASH=$(git rev-parse --short HEAD) + DATE_PATH=$(date -u +%Y/%m/%d) + REMOTE_DIR="public_html/builds/$DATE_PATH" + TARBALL="sharedinbox-linux-amd64-$HASH.tar.gz" + tar -czf /tmp/$TARBALL -C build/linux/x64/release bundle + ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "mkdir -p $REMOTE_DIR" + scp -o StrictHostKeyChecking=no /tmp/$TARBALL "$SSH_USER@$SSH_HOST:$REMOTE_DIR/$TARBALL" + DOWNLOAD_URL="https://sharedinbox.de/builds/$DATE_PATH/$TARBALL" + echo "{\"version\":\"$HASH\",\"linux\":\"$DOWNLOAD_URL\"}" | \ + ssh -o StrictHostKeyChecking=no "$SSH_USER@$SSH_HOST" "cat > public_html/latest.json" + echo "Uploaded $TARBALL and updated latest.json" _android-avd-setup: diff --git a/lib/core/services/update_service.dart b/lib/core/services/update_service.dart new file mode 100644 index 0000000..90275c3 --- /dev/null +++ b/lib/core/services/update_service.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; + +const _kAppVersion = String.fromEnvironment('GIT_HASH'); +const _kLatestJsonUrl = 'https://sharedinbox.de/latest.json'; + +class UpdateInfo { + const UpdateInfo({required this.latestVersion, required this.downloadUrl}); + + final String latestVersion; + final String downloadUrl; +} + +/// Returns an [UpdateInfo] when a newer Linux version is available, or null +/// if the app is up to date, the version is unknown, or the platform is not +/// Linux desktop. +final updateInfoProvider = FutureProvider((ref) async { + if (!Platform.isLinux || _kAppVersion.isEmpty) return null; + + try { + final resp = await http + .get(Uri.parse(_kLatestJsonUrl)) + .timeout(const Duration(seconds: 10)); + if (resp.statusCode != 200) return null; + final json = jsonDecode(resp.body) as Map; + final latest = json['version'] as String?; + final url = json['linux'] as String?; + if (latest == null || url == null) return null; + if (latest == _kAppVersion) return null; + return UpdateInfo(latestVersion: latest, downloadUrl: url); + } catch (_) { + return null; + } +}); diff --git a/lib/ui/screens/account_list_screen.dart b/lib/ui/screens/account_list_screen.dart index 288c534..75c859b 100644 --- a/lib/ui/screens/account_list_screen.dart +++ b/lib/ui/screens/account_list_screen.dart @@ -3,9 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; - import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/core/services/update_service.dart'; import 'package:sharedinbox/di.dart'; +import 'package:url_launcher/url_launcher.dart'; class AccountListScreen extends ConsumerWidget { const AccountListScreen({super.key}); @@ -52,21 +53,28 @@ class AccountListScreen extends ConsumerWidget { ], ), ), - body: StreamBuilder( - stream: ref.watch(accountRepositoryProvider).observeAccounts(), - builder: (ctx, snap) { - if (!snap.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final accounts = snap.data!; - if (accounts.isEmpty) { - return const _OnboardingView(); - } - return ListView.builder( - itemCount: accounts.length, - itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]), - ); - }, + body: Column( + children: [ + const _UpdateBanner(), + Expanded( + child: StreamBuilder( + stream: ref.watch(accountRepositoryProvider).observeAccounts(), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final accounts = snap.data!; + if (accounts.isEmpty) { + return const _OnboardingView(); + } + return ListView.builder( + itemCount: accounts.length, + itemBuilder: (ctx, i) => _AccountTile(account: accounts[i]), + ); + }, + ), + ), + ], ), floatingActionButton: FloatingActionButton( onPressed: () => context.push('/accounts/add'), @@ -341,3 +349,31 @@ bool _sieveSupported(Account account) { if (account.type == AccountType.jmap) return true; return account.manageSieveAvailable != false; } + +/// Shown on Linux desktop when a newer build is available on the server. +class _UpdateBanner extends ConsumerWidget { + const _UpdateBanner(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final update = ref.watch(updateInfoProvider); + return update.when( + data: (info) { + if (info == null) return const SizedBox.shrink(); + return MaterialBanner( + content: Text('Update available: ${info.latestVersion}'), + leading: const Icon(Icons.system_update), + actions: [ + TextButton( + onPressed: () => + unawaited(launchUrl(Uri.parse(info.downloadUrl))), + child: const Text('Download'), + ), + ], + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } +} diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 37f1e11..cf7f4cb 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -63,6 +63,7 @@ const _excluded = { 'lib/data/repositories/sync_log_repository_impl.dart', 'lib/data/repositories/undo_repository_impl.dart', 'lib/data/repositories/search_history_repository_impl.dart', + 'lib/core/services/update_service.dart', }; void main() {