From 623e7e39dd197504d293e885fbf33142a071dd35 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 03:48:35 +0200 Subject: [PATCH 1/7] feat: add channel-aware app update delivery --- .github/workflows/release-checks.yml | 31 ++- .github/workflows/release.yml | 24 ++- app/android/app/build.gradle.kts | 9 + .../app/src/direct/AndroidManifest.xml | 12 ++ .../app/src/direct/res/xml/update_paths.xml | 3 + .../kotlin/com/papyrus/reader/AppUpdates.kt | 109 ++++++++++ .../kotlin/com/papyrus/reader/MainActivity.kt | 7 + app/assets/updates/install_windows.ps1 | 24 +++ app/lib/main.dart | 11 +- app/lib/updates/update_appimage.dart | 25 +++ app/lib/updates/update_controller.dart | 188 ++++++++++++++++++ app/lib/updates/update_download.dart | 68 +++++++ app/lib/updates/update_platform.dart | 3 + app/lib/updates/update_platform_io.dart | 113 +++++++++++ app/lib/updates/update_platform_stub.dart | 8 + app/lib/updates/update_platform_web.dart | 11 + app/lib/updates/update_release.dart | 66 ++++++ app/lib/updates/update_settings.dart | 130 ++++++++++++ .../profile/about_settings_section.dart | 11 + app/pubspec.yaml | 2 + app/test/updates/update_appimage_test.dart | 30 +++ app/test/updates/update_controller_test.dart | 181 +++++++++++++++++ app/test/updates/update_download_test.dart | 61 ++++++ app/test/updates/update_settings_test.dart | 63 ++++++ docs/INSTALL.md | 36 +++- docs/RELEASING.md | 8 +- docs/UPDATE_DELIVERY.md | 93 +++++++++ tools/apt_repository.py | 99 +++++++++ tools/check_android_distribution.py | 28 +++ tools/check_packaged_reader.ps1 | 31 ++- tools/release_assets.py | 12 ++ tools/tests/test_apt_repository.py | 130 ++++++++++++ 32 files changed, 1609 insertions(+), 18 deletions(-) create mode 100644 app/android/app/src/direct/AndroidManifest.xml create mode 100644 app/android/app/src/direct/res/xml/update_paths.xml create mode 100644 app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt create mode 100644 app/assets/updates/install_windows.ps1 create mode 100644 app/lib/updates/update_appimage.dart create mode 100644 app/lib/updates/update_controller.dart create mode 100644 app/lib/updates/update_download.dart create mode 100644 app/lib/updates/update_platform.dart create mode 100644 app/lib/updates/update_platform_io.dart create mode 100644 app/lib/updates/update_platform_stub.dart create mode 100644 app/lib/updates/update_platform_web.dart create mode 100644 app/lib/updates/update_release.dart create mode 100644 app/lib/updates/update_settings.dart create mode 100644 app/test/updates/update_appimage_test.dart create mode 100644 app/test/updates/update_controller_test.dart create mode 100644 app/test/updates/update_download_test.dart create mode 100644 app/test/updates/update_settings_test.dart create mode 100644 docs/UPDATE_DELIVERY.md create mode 100644 tools/apt_repository.py create mode 100644 tools/check_android_distribution.py create mode 100644 tools/tests/test_apt_repository.py diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml index f149491f..cf3910d8 100644 --- a/.github/workflows/release-checks.yml +++ b/.github/workflows/release-checks.yml @@ -2,10 +2,10 @@ name: Release configuration checks on: pull_request: - paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**'] + paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/lib/updates/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**'] push: branches: [development, master] - paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**'] + paths: ['tools/**', '.github/workflows/**', 'app/pubspec.yaml', 'app/android/**', 'app/lib/updates/**', 'app/linux/**', 'app/windows/**', 'packaging/**', 'packages/**'] permissions: contents: read @@ -19,3 +19,30 @@ jobs: with: python-version: '3.12' - run: python -m unittest discover -s tools/tests -v + + android-updates: + runs-on: ubuntu-latest + strategy: + matrix: + flavor: [play, direct] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: gradle + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.41.2' + cache: true + - run: flutter pub get --enforce-lockfile + working-directory: app + - run: flutter build apk --debug --flavor ${{ matrix.flavor }} --no-pub + working-directory: app + - name: Check distribution permissions + env: + FLAVOR: ${{ matrix.flavor }} + run: | + manifest=$(find app/build/app/intermediates/merged_manifests -path "*/${FLAVOR}Debug/*/AndroidManifest.xml" -print -quit) + python tools/check_android_distribution.py "$manifest" --flavor "$FLAVOR" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad895539..baf5b817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,24 +85,30 @@ jobs: run: flutter pub get --enforce-lockfile - name: Build signed Google Play bundle run: | - flutter build appbundle --release \ + flutter build appbundle --flavor play --release \ --dart-define=PAPYRUS_API_BASE_URL="$PAPYRUS_API_BASE_URL" \ --dart-define=POWERSYNC_SERVICE_URL="$POWERSYNC_SERVICE_URL" + - name: Check Play distribution permissions + run: python ../tools/check_android_distribution.py build/app/intermediates/merged_manifests/playRelease/processPlayReleaseManifest/AndroidManifest.xml --flavor play - name: Check 16 KB native and bundle alignment run: | - python ../tools/check_android_bundle.py build/app/outputs/bundle/release/app-release.aab + python ../tools/check_android_bundle.py build/app/outputs/bundle/playRelease/app-play-release.aab curl --fail --location --retry 3 https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar --output "$RUNNER_TEMP/bundletool.jar" printf 'a099cfa1543f55593bc2ed16a70a7c67fe54b1747bb7301f37fdfd6d91028e29 %s/bundletool.jar\n' "$RUNNER_TEMP" | sha256sum --check - java -jar "$RUNNER_TEMP/bundletool.jar" dump config --bundle=build/app/outputs/bundle/release/app-release.aab > "$RUNNER_TEMP/bundle-config.json" + java -jar "$RUNNER_TEMP/bundletool.jar" dump config --bundle=build/app/outputs/bundle/playRelease/app-play-release.aab > "$RUNNER_TEMP/bundle-config.json" python -c 'import json, os; from pathlib import Path; config = json.loads((Path(os.environ["RUNNER_TEMP"]) / "bundle-config.json").read_text()); assert config["optimizations"]["uncompressNativeLibraries"]["alignment"] == "PAGE_ALIGNMENT_16K", "Bundle must request 16 KB APK alignment"' - name: Package signed direct-install APK run: | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "build-tools;36.0.0" - python ../tools/package_android_apk.py build/app/outputs/bundle/release/app-release.aab \ + flutter build appbundle --flavor direct --release \ + --dart-define=PAPYRUS_API_BASE_URL="$PAPYRUS_API_BASE_URL" \ + --dart-define=POWERSYNC_SERVICE_URL="$POWERSYNC_SERVICE_URL" + python ../tools/check_android_distribution.py build/app/intermediates/merged_manifests/directRelease/processDirectReleaseManifest/AndroidManifest.xml --flavor direct + python ../tools/package_android_apk.py build/app/outputs/bundle/directRelease/app-direct-release.aab \ --bundletool "$RUNNER_TEMP/bundletool.jar" \ --build-tools "$ANDROID_HOME/build-tools/36.0.0" --output . --tag "$RELEASE_TAG" - name: Name Android bundle download - run: cp build/app/outputs/bundle/release/app-release.aab "papyrus-${RELEASE_TAG}-android-universal.aab" + run: cp build/app/outputs/bundle/playRelease/app-play-release.aab "papyrus-${RELEASE_TAG}-android-universal.aab" - uses: actions/upload-artifact@v4 with: name: android-release @@ -147,6 +153,7 @@ jobs: - name: Build web run: | flutter build web --release \ + --dart-define=PAPYRUS_BUILD_NUMBER=${{ needs.version.outputs.build_number }} \ --dart-define=PAPYRUS_API_BASE_URL="${PAPYRUS_API_BASE_URL}" \ --dart-define=POWERSYNC_SERVICE_URL="${POWERSYNC_SERVICE_URL}" \ --build-name=${{ needs.version.outputs.version }} \ @@ -274,6 +281,7 @@ jobs: if-no-files-found: error release: + environment: release needs: [version, build-android, build-web, build-linux, build-windows] if: needs.version.outputs.release == 'true' runs-on: ubuntu-24.04 @@ -291,6 +299,12 @@ jobs: pattern: '*-release' merge-multiple: true path: artifacts + - name: Build signed APT repository + if: vars.APT_UPDATES_ENABLED == 'true' + env: + APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }} + APT_SIGNING_FINGERPRINT: ${{ vars.APT_SIGNING_FINGERPRINT }} + run: python3 tools/apt_repository.py artifacts --tag "$RELEASE_TAG" - name: Verify and seal release run: python3 tools/release_assets.py seal artifacts --tag "$RELEASE_TAG" - name: Publish immutable downloads diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index c1118806..5561749b 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -48,6 +48,13 @@ android { versionName = flutter.versionName } + buildFeatures { buildConfig = true } + flavorDimensions += "distribution" + productFlavors { + create("play") { dimension = "distribution" } + create("direct") { dimension = "distribution" } + } + signingConfigs { if (hasUploadKey) { create("upload") { @@ -71,5 +78,7 @@ flutter { } dependencies { + implementation("com.google.android.play:app-update:2.1.0") + implementation("androidx.core:core-ktx:1.15.0") implementation("org.slf4j:slf4j-nop:1.7.36") } diff --git a/app/android/app/src/direct/AndroidManifest.xml b/app/android/app/src/direct/AndroidManifest.xml new file mode 100644 index 00000000..97a54aa9 --- /dev/null +++ b/app/android/app/src/direct/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/app/android/app/src/direct/res/xml/update_paths.xml b/app/android/app/src/direct/res/xml/update_paths.xml new file mode 100644 index 00000000..5e8a6489 --- /dev/null +++ b/app/android/app/src/direct/res/xml/update_paths.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt new file mode 100644 index 00000000..bf6ab9d4 --- /dev/null +++ b/app/android/app/src/main/kotlin/com/papyrus/reader/AppUpdates.kt @@ -0,0 +1,109 @@ +package com.papyrus.reader + +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.UpdateAvailability +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File + +class AppUpdates(private val activity: Activity) { + private val packageManager get() = activity.packageManager + private val packageName get() = activity.packageName + private val cacheDir get() = activity.cacheDir + private val preferences by lazy { activity.getPreferences(Activity.MODE_PRIVATE) } + private val updates by lazy { AppUpdateManagerFactory.create(activity) } + private val requestCode = 7412 + + fun configure(flutterEngine: FlutterEngine) { + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.papyrus.reader/updates") + .setMethodCallHandler { call, result -> + try { + when (call.method) { + "channel" -> result.success(BuildConfig.FLAVOR) + "check" -> updates.appUpdateInfo + .addOnSuccessListener { info -> + val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE && + info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) + result.success(if (available) info.availableVersionCode() else null) + } + .addOnFailureListener { result.error("play", "Google Play could not check for updates", null) } + "installPlay" -> updates.appUpdateInfo + .addOnSuccessListener { info -> + try { + val started = updates.startUpdateFlowForResult( + info, activity, AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), requestCode + ) + if (started) { + preferences.edit().putBoolean("playUpdateStarted", true).apply() + result.success(null) + } + else result.error("play", "Google Play did not start the update", null) + } catch (error: Exception) { + result.error("play", "Could not start the Play update", null) + } + } + .addOnFailureListener { result.error("play", "Google Play update is unavailable", null) } + "canInstall" -> { + check(BuildConfig.FLAVOR == "direct") + val allowed = Build.VERSION.SDK_INT < 26 || packageManager.canRequestPackageInstalls() + if (!allowed) { + activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName"))) + } + result.success(allowed) + } + "installApk" -> { + installApk(requireNotNull(call.argument("path"))) + result.success(null) + } + else -> result.notImplemented() + } + } catch (error: Exception) { + result.error("update", "Could not prepare the update", null) + } + } + } + + @Suppress("DEPRECATION") + private fun installApk(filename: String) { + check(BuildConfig.FLAVOR == "direct") + val file = File(filename).canonicalFile + val directory = File(cacheDir, "papyrus-updates").canonicalFile + require(file.path.startsWith(directory.path + File.separator) && file.isFile) + val flags = PackageManager.GET_SIGNATURES + val candidate = requireNotNull(packageManager.getPackageArchiveInfo(file.path, flags)) + val installed = packageManager.getPackageInfo(packageName, flags) + require(candidate.packageName == packageName && candidate.versionCode > installed.versionCode) + require(candidate.signatures?.toSet() == installed.signatures?.toSet() && !candidate.signatures.isNullOrEmpty()) + val uri = FileProvider.getUriForFile(activity, "$packageName.updates", file) + activity.startActivity(Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }) + } + + fun resumeUpdate() { + if (BuildConfig.FLAVOR != "play" || !preferences.getBoolean("playUpdateStarted", false)) return + updates.appUpdateInfo.addOnSuccessListener { info -> + if (info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) { + try { + updates.startUpdateFlowForResult( + info, activity, AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(), requestCode + ) + } catch (_: Exception) { + // A failed or cancelled update must leave the library usable. + } + } else { + preferences.edit().remove("playUpdateStarted").apply() + } + } + } +} diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt index c4a0299f..ea0d40bb 100644 --- a/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/papyrus/reader/MainActivity.kt @@ -15,6 +15,7 @@ import java.util.UUID import java.util.concurrent.Executors class MainActivity : FlutterActivity() { + private val appUpdates by lazy { AppUpdates(this) } private val folderRequest = 7142 private var folderResult: MethodChannel.Result? = null private val worker = Executors.newSingleThreadExecutor() @@ -24,6 +25,7 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + appUpdates.configure(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "papyrus/folders").setMethodCallHandler { call, result -> if (call.method == "pick") { if (folderResult != null) { @@ -124,6 +126,11 @@ class MainActivity : FlutterActivity() { } } + override fun onResume() { + super.onResume() + appUpdates.resumeUpdate() + } + override fun onDestroy() { worker.execute { cursors.values.forEach { it.close() } diff --git a/app/assets/updates/install_windows.ps1 b/app/assets/updates/install_windows.ps1 new file mode 100644 index 00000000..2efd0b1c --- /dev/null +++ b/app/assets/updates/install_windows.ps1 @@ -0,0 +1,24 @@ +param( + [Parameter(Mandatory)][int]$ParentId, + [Parameter(Mandatory)][string]$Installer, + [Parameter(Mandatory)][string]$Checksum, + [Parameter(Mandatory)][string]$InstallDirectory +) +$ErrorActionPreference = 'Stop' +$Log = Join-Path (Split-Path $Installer) 'update.log' +try { + # Wait for the user's normal shutdown, preserving pending library writes. + $Parent = Get-Process -Id $ParentId -ErrorAction SilentlyContinue + if ($Parent -and -not $Parent.WaitForExit(86400000)) { throw 'Update expired while Papyrus was still running.' } + if ((Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Checksum) { + throw 'Update checksum changed after download.' + } + $Arguments = @('/SP-', '/SILENT', '/NORESTART', '/NOCLOSEAPPLICATIONS', '/SUPPRESSMSGBOXES', "/DIR=`"$InstallDirectory`"", "/LOG=`"$Log`"") + $Setup = Start-Process -FilePath $Installer -ArgumentList $Arguments -PassThru -Wait + if ($Setup.ExitCode -ne 0) { throw "Installer failed with exit code $($Setup.ExitCode). See $Log" } + Start-Process -FilePath (Join-Path $InstallDirectory 'papyrus.exe') + Remove-Item -LiteralPath $Installer +} catch { + $_ | Out-String | Add-Content -LiteralPath $Log + # Keep the verified installer and diagnostics for a manual retry. +} diff --git a/app/lib/main.dart b/app/lib/main.dart index c5767b29..16efa8ff 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,6 +1,8 @@ import 'package:papyrus/folders/directory_access_factory.dart'; import 'package:papyrus/folders/folder_import_target.dart'; import 'package:papyrus/folders/watched_folders.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_settings.dart'; import 'package:papyrus/services/reading_device_identity.dart'; import 'dart:async'; import 'dart:convert'; @@ -214,6 +216,7 @@ class _PapyrusState extends State { late final AcquisitionAvailabilityProvider _acquisitionAvailabilityProvider; late final AcquisitionDownloadsComposition _acquisitionDownloadsComposition; late final PreferencesProvider _preferencesProvider; + late final UpdateController _updateController; late final SyncSettingsProvider _syncSettingsProvider; late final MediaUploadQueue _mediaUploadQueue; late final BookImportService _bookImportService; @@ -253,6 +256,7 @@ class _PapyrusState extends State { _dataStore = DataStore(); _preferencesProvider = PreferencesProvider(widget.prefs); + _updateController = UpdateController(preferences: widget.prefs)..start(); _mediaUploadQueue = widget._uploads ?? MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); _bookImportService = widget._importer ?? BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); @@ -363,6 +367,7 @@ class _PapyrusState extends State { _authProvider.dispose(); _syncSettingsProvider.dispose(); _preferencesProvider.dispose(); + _updateController.dispose(); super.dispose(); } @@ -647,6 +652,7 @@ class _PapyrusState extends State { create: (_) => LibraryProvider(dataStore: _dataStore, preferences: _preferencesProvider), ), ChangeNotifierProvider.value(value: _preferencesProvider), + ChangeNotifierProvider.value(value: _updateController), ], child: widget._child ?? @@ -680,7 +686,10 @@ class _PapyrusState extends State { isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context) ? Duration.zero : kThemeAnimationDuration, - builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!), + builder: (context, child) => AppAccessibilityScope( + preferences: preferencesProvider, + child: UpdateNotice(controller: _updateController, router: _appRouter.router, child: child!), + ), routerConfig: _appRouter.router, ); }, diff --git a/app/lib/updates/update_appimage.dart b/app/lib/updates/update_appimage.dart new file mode 100644 index 00000000..7215e53c --- /dev/null +++ b/app/lib/updates/update_appimage.dart @@ -0,0 +1,25 @@ +import 'dart:io'; + +import 'package:path/path.dart' as path; + +Future replaceAppImage(File target, File verifiedDownload) async { + if (await FileSystemEntity.isLink(target.path) || !await target.exists()) { + throw const FileSystemException('Update the original AppImage, not a symbolic link'); + } + + // Stage on the same filesystem so rename cannot leave a partial executable. + final staging = await Directory(path.dirname(target.path)).createTemp('.papyrus-update-'); + + try { + final replacement = await verifiedDownload.copy(path.join(staging.path, 'Papyrus.AppImage')); + final result = await Process.run('/bin/chmod', ['755', replacement.path]); + + if (result.exitCode != 0) { + throw const FileSystemException('Cannot make the new AppImage executable'); + } + + await replacement.rename(target.path); + } finally { + await staging.delete(recursive: true); + } +} diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart new file mode 100644 index 00000000..31285e22 --- /dev/null +++ b/app/lib/updates/update_controller.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/services/app_info.dart'; +import 'package:papyrus/updates/update_platform.dart' as platform; +import 'package:papyrus/updates/update_release.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UpdateController extends ChangeNotifier { + final SharedPreferences preferences; + final Future Function() appInfo; + final Future Function() channelProvider; + final Future Function() playCheck; + final Future> Function() manifest; + final Future Function(UpdateAsset?, void Function(double?)) installer; + UpdateChannel? channel; + UpdateRelease? release; + bool busy = false; + bool available = false; + bool staged = false; + double? progress; + String message = 'Check for a newer version of Papyrus.'; + Timer? _timer; + bool _disposed = false; + + UpdateController({ + required this.preferences, + this.appInfo = loadAppInfo, + this.channelProvider = platform.updateChannel, + this.playCheck = platform.checkPlayUpdate, + this.manifest = fetchManifest, + this.installer = platform.installUpdate, + }); + + bool get automatic => preferences.getBool('updates.automatic') ?? true; + bool get external => channel == UpdateChannel.apt || channel == UpdateChannel.manual; + + void start() { + if (kReleaseMode) { + unawaited(_backgroundCheck()); + _timer = Timer.periodic(const Duration(hours: 1), (_) => unawaited(_backgroundCheck())); + } + } + + Future setAutomatic(bool value) async { + await preferences.setBool('updates.automatic', value); + _notify(); + + if (value) { + await _backgroundCheck(); + } + } + + Future _backgroundCheck() async { + final lastCheck = preferences.getInt('updates.lastCheck') ?? 0; + + if (!automatic || DateTime.now().millisecondsSinceEpoch - lastCheck < const Duration(days: 1).inMilliseconds) { + return; + } + + await check(); + } + + Future check() async { + if (busy || staged || _disposed) { + return; + } + + busy = true; + message = 'Checking for updates…'; + _notify(); + + try { + channel = await channelProvider(); + final info = await appInfo(); + final compiledBuild = const int.fromEnvironment('PAPYRUS_BUILD_NUMBER'); + final currentBuild = kIsWeb && compiledBuild > 0 ? compiledBuild : int.tryParse(info.buildNumber); + + if (currentBuild == null || currentBuild < 1) { + throw const FormatException('Installed build number is unavailable'); + } + + if (channel == UpdateChannel.play) { + final build = await playCheck(); + available = build != null && build > currentBuild; + } else { + final data = await manifest(); + release = UpdateRelease.fromJson({...data, 'assets': data['assets'] ?? {}}); + available = release!.buildNumber > currentBuild; + + if (available && !external && channel != UpdateChannel.web) { + release!.asset(_assetKey); + } + } + + message = available ? 'A newer version of Papyrus is available.' : 'Papyrus is up to date.'; + await preferences.setInt('updates.lastCheck', DateTime.now().millisecondsSinceEpoch); + } catch (_) { + available = false; + release = null; + message = 'Could not check for updates. Check your connection and try again.'; + } finally { + busy = false; + _notify(); + } + } + + String get _assetKey => switch (channel) { + UpdateChannel.apk => 'android_apk', + UpdateChannel.windows => 'installer', + UpdateChannel.appImage => 'appimage', + _ => throw StateError('No downloadable package for this channel'), + }; + + Future install() async { + if (busy || !available || staged || external || _disposed) { + return; + } + + busy = true; + progress = null; + message = 'Preparing the update…'; + _notify(); + + try { + final asset = channel == UpdateChannel.play || channel == UpdateChannel.web ? null : release!.asset(_assetKey); + + message = await installer(asset, (value) { + progress = value; + message = value == null ? 'Downloading update…' : 'Downloading update: ${(value * 100).floor()}%'; + _notify(); + }); + + staged = channel == UpdateChannel.windows || channel == UpdateChannel.appImage; + } catch (_) { + message = 'Could not finish the update. Please try again.'; + } finally { + busy = false; + progress = null; + _notify(); + } + } + + void _notify() { + if (!_disposed) { + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + super.dispose(); + } + + static Future> fetchManifest() async { + final client = http.Client(); + + try { + final uri = platform.updateManifestUri.replace( + queryParameters: {'check': '${DateTime.now().millisecondsSinceEpoch}'}, + ); + final response = await client.send(http.Request('GET', uri)).timeout(const Duration(seconds: 15)); + + if (response.statusCode != 200) { + throw const FormatException('Update metadata unavailable'); + } + + final bytes = []; + + await for (final chunk in response.stream.timeout(const Duration(seconds: 15))) { + bytes.addAll(chunk); + + if (bytes.length > 65536) { + throw const FormatException('Update metadata exceeds limit'); + } + } + + return jsonDecode(utf8.decode(bytes)) as Map; + } finally { + client.close(); + } + } +} diff --git a/app/lib/updates/update_download.dart b/app/lib/updates/update_download.dart new file mode 100644 index 00000000..7dd22ab4 --- /dev/null +++ b/app/lib/updates/update_download.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:papyrus/updates/update_release.dart'; + +// Release metadata and bytes are authenticated by HTTPS to the fixed Papyrus +// GitHub repository. Hashes bind downloaded bytes to that release's manifest. +Future downloadUpdate( + UpdateAsset asset, + File destination, + void Function(double?) progress, { + http.Client? client, +}) async { + final transport = client ?? http.Client(); + IOSink? sink; + const maximumBytes = 2 * 1024 * 1024 * 1024; + + try { + final response = await transport.send(http.Request('GET', asset.uri)).timeout(const Duration(seconds: 30)); + + if (response.statusCode != 200 || (response.contentLength ?? 0) > maximumBytes) { + throw const HttpException('Update download is unavailable'); + } + + sink = destination.openWrite(); + var received = 0; + final total = response.contentLength; + + await for (final bytes in response.stream.timeout(const Duration(seconds: 30))) { + received += bytes.length; + + if (received > maximumBytes) { + throw const HttpException('Update exceeds download limit'); + } + + sink.add(bytes); + progress(total == null || total == 0 ? null : received / total); + } + + await sink.flush(); + await sink.close(); + sink = null; + + if (received == 0 || (total != null && received != total)) { + throw const HttpException('Incomplete update download'); + } + + final digest = await sha256.bind(destination.openRead()).first; + + if (digest.toString() != asset.sha256) { + throw const FormatException('Update checksum mismatch'); + } + } catch (_) { + await sink?.close(); + + if (await destination.exists()) { + await destination.delete(); + } + + rethrow; + } finally { + if (client == null) { + transport.close(); + } + } +} diff --git a/app/lib/updates/update_platform.dart b/app/lib/updates/update_platform.dart new file mode 100644 index 00000000..f5b4dea0 --- /dev/null +++ b/app/lib/updates/update_platform.dart @@ -0,0 +1,3 @@ +export 'update_platform_stub.dart' + if (dart.library.io) 'update_platform_io.dart' + if (dart.library.js_interop) 'update_platform_web.dart'; diff --git a/app/lib/updates/update_platform_io.dart b/app/lib/updates/update_platform_io.dart new file mode 100644 index 00000000..6c588267 --- /dev/null +++ b/app/lib/updates/update_platform_io.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:papyrus/updates/update_download.dart'; +import 'package:papyrus/updates/update_appimage.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +const _android = MethodChannel('com.papyrus.reader/updates'); +Uri get updateManifestUri => + Uri.https('github.com', '/PapyrusReader/client/releases/latest/download/release-manifest.json'); + +Future updateChannel() async { + if (Platform.isAndroid) { + return await _android.invokeMethod('channel') == 'direct' ? UpdateChannel.apk : UpdateChannel.play; + } + + if (Platform.isWindows) { + final uninstaller = File(path.join(path.dirname(Platform.resolvedExecutable), 'unins000.exe')); + return await uninstaller.exists() ? UpdateChannel.windows : UpdateChannel.manual; + } + + if (Platform.isLinux) { + final appImage = Platform.environment['APPIMAGE']; + + if (appImage != null && path.isAbsolute(appImage) && await File(appImage).exists()) { + return UpdateChannel.appImage; + } + + if (Platform.resolvedExecutable == '/opt/papyrus/papyrus') { + return UpdateChannel.apt; + } + } + + return UpdateChannel.manual; +} + +Future checkPlayUpdate() => _android.invokeMethod('check'); + +Future installUpdate(UpdateAsset? asset, void Function(double?) progress) async { + final channel = await updateChannel(); + + if (channel == UpdateChannel.play) { + await _android.invokeMethod('installPlay'); + return 'Follow Google Play to finish the update.'; + } + + if (asset == null) { + throw StateError('No update package is available'); + } + + if (channel == UpdateChannel.apk && await _android.invokeMethod('canInstall') != true) { + return 'Allow Papyrus to install updates in Android settings, then return and tap Update again.'; + } + + final temporary = await getTemporaryDirectory(); + final directory = await Directory(path.join(temporary.path, 'papyrus-updates')).create(recursive: true); + final staging = await directory.createTemp('download-'); + final file = File(path.join(staging.path, asset.name)); + await downloadUpdate(asset, file, progress); + + if (channel == UpdateChannel.apk) { + await _android.invokeMethod('installApk', {'path': file.path}); + return 'Follow Android to finish the update. If permission was requested, enable it and tap Update again.'; + } + + if (channel == UpdateChannel.windows) { + final script = File(path.join(staging.path, 'install.ps1')); + await script.writeAsString(await rootBundle.loadString('assets/updates/install_windows.ps1')); + final powershell = path.join( + Platform.environment['SystemRoot'] ?? r'C:\Windows', + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe', + ); + + await Process.start( + powershell, + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-File', + script.path, + '-ParentId', + '$pid', + '-Installer', + file.path, + '-Checksum', + asset.sha256, + '-InstallDirectory', + path.dirname(Platform.resolvedExecutable), + ], + mode: ProcessStartMode.detached, + ); + + return 'Update ready. Close Papyrus normally to install it. Papyrus will reopen when the update finishes.'; + } + + if (channel == UpdateChannel.appImage) { + final target = File(Platform.environment['APPIMAGE']!); + + await replaceAppImage(target, file); + await staging.delete(recursive: true); + + return 'Update installed. Close and reopen Papyrus when you have finished reading.'; + } + + throw StateError('This installation uses external updates'); +} diff --git a/app/lib/updates/update_platform_stub.dart b/app/lib/updates/update_platform_stub.dart new file mode 100644 index 00000000..fe7744cd --- /dev/null +++ b/app/lib/updates/update_platform_stub.dart @@ -0,0 +1,8 @@ +import 'package:papyrus/updates/update_release.dart'; + +Future updateChannel() async => UpdateChannel.manual; +Future checkPlayUpdate() async => null; +Future installUpdate(UpdateAsset? asset, void Function(double?) progress) async => + 'Install the latest release from the Papyrus downloads page.'; +Uri get updateManifestUri => + Uri.https('github.com', '/PapyrusReader/client/releases/latest/download/release-manifest.json'); diff --git a/app/lib/updates/update_platform_web.dart b/app/lib/updates/update_platform_web.dart new file mode 100644 index 00000000..5a163dce --- /dev/null +++ b/app/lib/updates/update_platform_web.dart @@ -0,0 +1,11 @@ +import 'package:papyrus/updates/update_release.dart'; +import 'package:web/web.dart' as web; + +Future updateChannel() async => UpdateChannel.web; +Future checkPlayUpdate() async => null; +Uri get updateManifestUri => Uri.base.resolve('/release.json'); + +Future installUpdate(UpdateAsset? asset, void Function(double?) progress) async { + web.window.location.reload(); + return 'Reloading Papyrus…'; +} diff --git a/app/lib/updates/update_release.dart b/app/lib/updates/update_release.dart new file mode 100644 index 00000000..a166d1ad --- /dev/null +++ b/app/lib/updates/update_release.dart @@ -0,0 +1,66 @@ +class UpdateRelease { + final String tag; + final String version; + final int buildNumber; + final Map assets; + + const UpdateRelease({required this.tag, required this.version, required this.buildNumber, required this.assets}); + + factory UpdateRelease.fromJson(Map json) { + final tag = json['tag']; + final version = json['version']; + final build = json['build_number']; + + if (tag is! String || + version is! String || + build is! int || + build < 1 || + !RegExp(r'^\d+\.\d+\.\d+$').hasMatch(version) || + tag != 'v$version+$build' || + json['assets'] is! Map) { + throw const FormatException('Invalid update release'); + } + + return UpdateRelease( + tag: tag, + version: version, + buildNumber: build, + assets: json['assets'] as Map, + ); + } + + UpdateAsset asset(String key) { + final suffix = switch (key) { + 'android_apk' => 'android-universal.apk', + 'installer' => 'windows-x64-setup.exe', + 'appimage' => 'linux-x64.AppImage', + _ => throw const FormatException('Unsupported update package'), + }; + + final entry = assets[key]; + final name = 'papyrus-$tag-$suffix'; + + if (entry is! Map || + entry['name'] != name || + entry['sha256'] is! String || + !RegExp(r'^[a-f0-9]{64}$').hasMatch(entry['sha256'] as String)) { + throw const FormatException('Invalid update package'); + } + + return UpdateAsset( + name: name, + sha256: entry['sha256'] as String, + uri: Uri.https('github.com', '/PapyrusReader/client/releases/download/$tag/$name'), + ); + } +} + +class UpdateAsset { + final String name; + final String sha256; + final Uri uri; + + const UpdateAsset({required this.name, required this.sha256, required this.uri}); +} + +enum UpdateChannel { play, apk, windows, appImage, apt, web, manual } diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart new file mode 100644 index 00000000..98c0732e --- /dev/null +++ b/app/lib/updates/update_settings.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/services/about_links.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; + +Future showUpdateSettings(BuildContext context, UpdateController controller) { + return showDialog( + context: context, + builder: (context) => ListenableBuilder( + listenable: controller, + builder: (context, child) => AlertDialog( + title: const Text('App updates'), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(controller.message), + const SizedBox(height: Spacing.md), + SettingsToggleRow( + label: 'Check automatically', + value: controller.automatic, + onChanged: controller.setAutomatic, + ), + if (controller.channel == UpdateChannel.apt) ...[ + const SizedBox(height: Spacing.md), + const Text( + 'Install updates through your system’s software updater. Enable the Papyrus repository once to receive future releases.', + ), + TextButton( + onPressed: () => AboutLinks.open( + context, + Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md'), + ), + child: const Text('Repository setup'), + ), + ], + if (controller.channel == UpdateChannel.manual) ...[ + const SizedBox(height: Spacing.md), + const Text( + 'This portable or development build uses manual installation. Install a supported package to receive integrated updates.', + ), + TextButton( + onPressed: () => AboutLinks.open(context, AboutLinks.releases), + child: const Text('Downloads'), + ), + ], + if (controller.available && !controller.external && !controller.staged) ...[ + const SizedBox(height: Spacing.md), + Text(switch (controller.channel) { + UpdateChannel.windows => 'The update will install after you close Papyrus normally.', + UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', + UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', + _ => 'Your device will guide you through installation.', + }), + ], + ], + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), + TextButton( + onPressed: controller.busy || controller.staged ? null : controller.check, + child: const Text('Check now'), + ), + if (controller.available && !controller.external && !controller.staged) + FilledButton( + onPressed: controller.busy ? null : controller.install, + child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), + ), + ], + ), + ), + ); +} + +class UpdateNotice extends StatefulWidget { + final UpdateController controller; + final GoRouter router; + final Widget child; + + const UpdateNotice({super.key, required this.controller, required this.router, required this.child}); + + @override + State createState() => _UpdateNoticeState(); +} + +class _UpdateNoticeState extends State { + bool _dismissed = false; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([widget.controller, widget.router.routeInformationProvider]), + builder: (context, child) { + final reading = widget.router.routeInformationProvider.value.uri.path.contains('/read/'); + final visible = widget.controller.available && !widget.controller.staged && !_dismissed && !reading; + + return Column( + children: [ + if (visible) + SafeArea( + bottom: false, + child: MaterialBanner( + content: const Text('A Papyrus update is available.'), + actions: [ + TextButton(onPressed: () => setState(() => _dismissed = true), child: const Text('Later')), + TextButton( + onPressed: () => showUpdateSettings( + widget.router.routerDelegate.navigatorKey.currentContext!, + widget.controller, + ), + child: const Text('View update'), + ), + ], + ), + ), + Expanded(child: widget.child), + ], + ); + }, + ); + } +} diff --git a/app/lib/widgets/profile/about_settings_section.dart b/app/lib/widgets/profile/about_settings_section.dart index c6a5c1cc..d419234f 100644 --- a/app/lib/widgets/profile/about_settings_section.dart +++ b/app/lib/widgets/profile/about_settings_section.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_settings.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/services/about_links.dart'; @@ -21,6 +24,8 @@ class _AboutSettingsSectionState extends State { @override Widget build(BuildContext context) { + final updates = context.watch(); + return FutureBuilder( future: _packageInfo, builder: (context, snapshot) { @@ -38,6 +43,12 @@ class _AboutSettingsSectionState extends State { final rows = [ SettingsRow(label: 'Version', value: version, showChevron: false), + if (updates != null) + SettingsRow( + label: 'App updates', + value: updates.available ? 'Update available' : 'Check for updates', + onTap: () => showUpdateSettings(context, updates), + ), SettingsRow(label: 'Licenses', onTap: () => context.push('/profile/licenses')), SettingsRow(label: 'Support', onTap: () => context.push('/profile/support')), SettingsRow( diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 246729e9..d0e592df 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -63,12 +63,14 @@ dev_dependencies: shared_preferences_platform_interface: ^2.4.1 flutter: + default-flavor: play config: enable-swift-package-manager: false uses-material-design: true assets: + - assets/updates/install_windows.ps1 - assets/images/google_logo.png - assets/images/logo.png - assets/images/logo-icon-light.svg diff --git a/app/test/updates/update_appimage_test.dart b/app/test/updates/update_appimage_test.dart new file mode 100644 index 00000000..3452f4a5 --- /dev/null +++ b/app/test/updates/update_appimage_test.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/updates/update_appimage.dart'; + +void main() { + test('AppImage replacement preserves other files and leaves an executable', () async { + final directory = await Directory.systemTemp.createTemp('papyrus-appimage-test'); + addTearDown(() => directory.delete(recursive: true)); + final image = await File('${directory.path}/Papyrus.AppImage').writeAsString('old'); + final download = await File('${directory.path}/download').writeAsString('new'); + final library = await File('${directory.path}/books.sqlite').writeAsString('durable'); + await replaceAppImage(image, download); + expect(await image.readAsString(), 'new'); + expect(await library.readAsString(), 'durable'); + expect((await image.stat()).mode & 0x49, 0x49); + expect(await directory.list().length, 3); + }, skip: Platform.isWindows); + + test('missing replacement and symbolic targets preserve the original image', () async { + final directory = await Directory.systemTemp.createTemp('papyrus-appimage-test'); + addTearDown(() => directory.delete(recursive: true)); + final image = await File('${directory.path}/Papyrus.AppImage').writeAsString('old'); + await expectLater(replaceAppImage(image, File('${directory.path}/missing')), throwsA(isA())); + expect(await image.readAsString(), 'old'); + final link = await Link('${directory.path}/link').create(image.path); + await expectLater(replaceAppImage(File(link.path), image), throwsA(isA())); + expect(await image.readAsString(), 'old'); + }, skip: Platform.isWindows); +} diff --git a/app/test/updates/update_controller_test.dart b/app/test/updates/update_controller_test.dart new file mode 100644 index 00000000..175a16b3 --- /dev/null +++ b/app/test/updates/update_controller_test.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Map metadata({int build = 7}) => { + 'tag': 'v0.2.0+$build', + 'version': '0.2.0', + 'build_number': build, + 'assets': { + 'installer': {'name': 'papyrus-v0.2.0+$build-windows-x64-setup.exe', 'sha256': 'a' * 64}, + }, +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences preferences; + final info = PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + preferences = await SharedPreferences.getInstance(); + }); + + UpdateController controller({ + UpdateChannel channel = UpdateChannel.windows, + Future> Function()? manifest, + Future Function()? play, + Future Function(UpdateAsset?, void Function(double?))? installer, + }) { + final controller = UpdateController( + preferences: preferences, + appInfo: () async => info, + channelProvider: () async => channel, + manifest: manifest ?? () async => metadata(), + playCheck: play ?? () async => null, + installer: installer ?? (_, _) async => 'Installed', + ); + + addTearDown(controller.dispose); + return controller; + } + + test('downloads the exact newer platform asset and prevents duplicate installs', () async { + var installs = 0; + final updates = controller( + installer: (asset, progress) async { + installs++; + expect( + asset!.uri.toString(), + 'https://github.com/PapyrusReader/client/releases/download/v0.2.0+7/papyrus-v0.2.0+7-windows-x64-setup.exe', + ); + progress(0.5); + return 'Close Papyrus to update'; + }, + ); + + await updates.check(); + expect(updates.available, isTrue); + await updates.install(); + await updates.install(); + expect(installs, 1); + expect(updates.staged, isTrue); + }); + + test('same and older builds are never offered', () async { + for (final build in [5, 6]) { + final updates = controller(manifest: () async => metadata(build: build)); + await updates.check(); + expect(updates.available, isFalse); + } + }); + + test('Play eligibility is authoritative and never falls back to APK feed', () async { + final updates = controller( + channel: UpdateChannel.play, + manifest: () async => throw StateError('GitHub must not be queried'), + play: () async => 7, + installer: (asset, _) async { + expect(asset, isNull); + return 'Play started'; + }, + ); + + await updates.check(); + expect(updates.available, isTrue); + await updates.install(); + expect(updates.message, 'Play started'); + expect(updates.staged, isFalse); + }); + + test('offline and invalid metadata remain retryable', () async { + var attempts = 0; + final updates = controller( + manifest: () async { + if (attempts++ == 0) throw StateError('offline'); + return metadata(); + }, + ); + + await updates.check(); + expect(updates.available, isFalse); + expect(updates.busy, isFalse); + expect(preferences.getInt('updates.lastCheck'), isNull); + await updates.check(); + expect(updates.available, isTrue); + }); + + test('malicious asset names and checksums are rejected', () async { + for (final badAsset in [ + {'name': '../installer.exe', 'sha256': 'a' * 64}, + {'name': 'papyrus-v0.2.0+7-windows-x64-setup.exe', 'sha256': 'not a hash'}, + ]) { + final data = metadata(); + (data['assets'] as Map)['installer'] = badAsset; + final updates = controller(manifest: () async => data); + await updates.check(); + expect(updates.available, isFalse); + } + }); + + test('APT never runs a downloaded installer', () async { + var installed = false; + final updates = controller( + channel: UpdateChannel.apt, + installer: (_, _) async { + installed = true; + return ''; + }, + ); + + await updates.check(); + await updates.install(); + expect(updates.available, isTrue); + expect(updates.external, isTrue); + expect(installed, isFalse); + }); + + test('failed installation can retry without clearing update', () async { + final updates = controller(installer: (_, _) async => throw StateError('disk full')); + await updates.check(); + await updates.install(); + expect(updates.busy, isFalse); + expect(updates.available, isTrue); + expect(updates.staged, isFalse); + }); + + test('automatic preference persists and a check in flight cannot duplicate', () async { + final response = Completer>(); + var checks = 0; + final updates = controller( + manifest: () { + checks++; + return response.future; + }, + ); + + await updates.setAutomatic(false); + expect(preferences.getBool('updates.automatic'), isFalse); + final first = updates.check(); + await Future.delayed(Duration.zero); + await updates.check(); + response.complete(metadata()); + await first; + expect(checks, 1); + }); + + test('invalid release identity fails closed', () { + for (final data in [ + {...metadata(), 'tag': 'v0.2.0+8'}, + {...metadata(), 'version': '../0.2.0'}, + {...metadata(), 'build_number': '7'}, + {...metadata(), 'build_number': 0}, + ]) { + expect(() => UpdateRelease.fromJson(data), throwsFormatException); + } + }); +} diff --git a/app/test/updates/update_download_test.dart b/app/test/updates/update_download_test.dart new file mode 100644 index 00000000..f7117aab --- /dev/null +++ b/app/test/updates/update_download_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/updates/update_download.dart'; +import 'package:papyrus/updates/update_release.dart'; + +void main() { + late Directory temporary; + late File destination; + final bytes = [1, 2, 3, 4]; + final asset = UpdateAsset( + name: 'update.exe', + sha256: sha256.convert(bytes).toString(), + uri: Uri.https('github.com', '/update'), + ); + + setUp(() async { + temporary = await Directory.systemTemp.createTemp('papyrus-update-test'); + destination = File('${temporary.path}/update.exe'); + }); + tearDown(() => temporary.delete(recursive: true)); + + test('verified bytes are retained and progress reaches completion', () async { + final progress = []; + await downloadUpdate( + asset, + destination, + progress.add, + client: MockClient((_) async => http.Response.bytes(bytes, 200)), + ); + expect(await destination.readAsBytes(), bytes); + expect(progress.last, 1); + }); + + test('corrupt and unavailable downloads leave no executable', () async { + for (final response in [http.Response('corrupted', 200), http.Response('missing', 404)]) { + await expectLater( + downloadUpdate(asset, destination, (_) {}, client: MockClient((_) async => response)), + throwsA(anything), + ); + expect(await destination.exists(), isFalse); + } + }); + + test('interrupted streams remove partial downloads', () async { + final client = MockClient.streaming( + (_, _) async => http.StreamedResponse( + Stream>.fromIterable([bytes]).asyncExpand((chunk) async* { + yield chunk; + throw const SocketException('connection lost'); + }), + 200, + ), + ); + await expectLater(downloadUpdate(asset, destination, (_) {}, client: client), throwsA(isA())); + expect(await destination.exists(), isFalse); + }); +} diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart new file mode 100644 index 00000000..6d75f6df --- /dev/null +++ b/app/test/updates/update_settings_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:papyrus/updates/update_settings.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('notice waits until reader is closed and opens usable narrow update dialog', (tester) async { + tester.view.physicalSize = const Size(360, 780); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.play, + playCheck: () async => 7, + ); + addTearDown(updates.dispose); + final router = GoRouter( + initialLocation: '/library/read/book', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('Library')), + ), + GoRoute( + path: '/library/read/book', + builder: (_, _) => const Scaffold(body: Text('Reader')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + builder: (context, child) => UpdateNotice(controller: updates, router: router, child: child!), + ), + ); + await updates.check(); + await tester.pumpAndSettle(); + expect(find.text('A Papyrus update is available.'), findsNothing); + router.go('/'); + await tester.pumpAndSettle(); + await tester.tap(find.text('View update')); + await tester.pumpAndSettle(); + expect(find.text('App updates'), findsOneWidget); + expect(find.text('Update'), findsOneWidget); + await tester.tap(find.text('Check automatically')); + await tester.pumpAndSettle(); + expect(updates.automatic, isFalse); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Later')); + await tester.pumpAndSettle(); + expect(find.text('A Papyrus update is available.'), findsNothing); + }); +} diff --git a/docs/INSTALL.md b/docs/INSTALL.md index c8293d76..03bb6b7f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -9,11 +9,14 @@ or compare the selected file's hash with its entry in `SHA256SUMS`. Download `papyrus-vVERSION+BUILD-android-universal.apk` for direct installation. Open it on your Android device and allow installation from that browser or file -manager when prompted. Install subsequent APKs over the existing app to retain -your library and settings. +manager when prompted. Use About → App updates for subsequent releases. Android may ask you to allow +Papyrus to install updates. Updates retain your library and settings. Older +builds need one manual APK upgrade to acquire the updater. Google Play internal testers can instead use the Play opt-in link. The AAB is -for Play publishing. Use the APK when installing a downloaded file. +for Play publishing. Play-installed builds receive Play updates, including while +in internal testing. Enable auto-update in Play or use About → App updates. +Use the APK when installing a downloaded file. Direct APKs use Papyrus's upload signing key. Google Play uses its app signing key, so Android may reject switching between the two installation sources. @@ -30,7 +33,9 @@ storage, offline libraries and accounts are not cleared by deployment. Download `papyrus-vVERSION+BUILD-windows-x64-setup.exe` and run it as your normal user. It installs under your user profile, adds a Start menu shortcut and can -upgrade an existing installation. Close Papyrus before upgrading. +upgrade an existing installation. For future releases, use About → App updates. Once downloaded, close Papyrus +normally to install the update and reopen the app. Older builds need one manual +upgrade to acquire the updater. Setup offers an optional desktop shortcut, unchecked on a fresh installation. The installer is unsigned: Windows may show an unknown-publisher/SmartScreen @@ -86,7 +91,9 @@ If FUSE is unavailable, extract and run it: ./squashfs-root/AppRun ``` -Replace the AppImage to upgrade. The `.tar.gz` bundle remains available for manual +Use About → App updates to download and verify a new AppImage. The image must +be writable. Close and reopen Papyrus to use the update. Older builds need one +manual replacement to acquire the updater. The `.tar.gz` bundle remains available for manual installation with the same system-library requirements as the `.deb`. Ubuntu 24.04 and Debian 13 are the AppImage validation targets. Only releases whose @@ -102,3 +109,22 @@ pattern with that exact path; extracted launches must enter the same named profi with `aa-exec -p PROFILE -- /path/to/squashfs-root/AppRun`. Native CI validates this explicit namespace prerequisite, rather than claiming compatibility with Ubuntu's unmodified default policy. + +## Subscribe to Linux package updates + +This is available after the first release containing `InRelease` and +`papyrus-archive-keyring.gpg`. Download the key from that release and compare its +fingerprint with the maintainer's published `APT_SIGNING_FINGERPRINT` before +installing it. Then run: + +```sh +sudo install -m 644 papyrus-archive-keyring.gpg /usr/share/keyrings/papyrus-archive-keyring.gpg +printf '%s\n' 'deb [arch=amd64 signed-by=/usr/share/keyrings/papyrus-archive-keyring.gpg] https://github.com/PapyrusReader/client/releases/latest/download/ ./' | sudo tee /etc/apt/sources.list.d/papyrus.list +sudo apt update +sudo apt install papyrus +``` + +Future versions appear in your system's package updater. Automatic installation +depends on your system's settings for third-party repositories. If a new release +appears between refreshing the index and installing, run `sudo apt update` again. +Do not use `trusted=yes` or disable signature verification. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4526baa1..c3ccf605 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -102,7 +102,7 @@ an actionable message; they cannot silently use the debug certificate. 3. Configure the public API, PowerSync, SMTP and Google OAuth settings using the server deployment runbook. Check the endpoints from outside your local network. 4. Merge the initial `0.0.1+1` version change (or dispatch Release on `master`), download the - `android-release` artifact, and upload `app-release.aab` to internal testing. + `android-release` artifact, and upload the `*-android-universal.aab` to internal testing. The local validation bundle uses a temporary certificate and placeholder URLs and must **never** be submitted to Play. 5. Add testers and share Play's opt-in link. Install through Play on real devices @@ -110,7 +110,7 @@ an actionable message; they cannot silently use the debug certificate. media upload/download, sync/reconnect and reader resume. 6. Check Play's native-library/page-size diagnostics. ELF alignment checks do not replace APK packaging or actual Android 16 KB emulator/device tests. Use - `bundletool dump config --bundle=app-release.aab` to verify + `bundletool dump config --bundle=app-play-release.aab` to verify `PAGE_ALIGNMENT_16K`, and test an APK generated from the bundle. After GitHub publication, the delivery workflow automatically sends the signed @@ -160,7 +160,9 @@ Windows uses an unsigned, per-user Inno Setup installer. Visual C++ runtimes are included, and Microsoft's Authenticode-verified offline WebView2 installer runs only when needed. Test a clean Windows x64 machine both with and without WebView2, then upgrade and uninstall while retaining a real library. No trusted signing -service, Microsoft Store submission or automatic desktop updater is configured. +service or Microsoft Store submission is configured. Installed Windows and +AppImage builds include integrated update delivery. See [update delivery](UPDATE_DELIVERY.md) +for the plan, APT signing configuration and native acceptance checks. The Windows packaging workflow also runs on app changes and supports manual dispatch. It saves a `windows-preview-RUN_ID` artifact of the production entrypoint diff --git a/docs/UPDATE_DELIVERY.md b/docs/UPDATE_DELIVERY.md new file mode 100644 index 00000000..d5a77286 --- /dev/null +++ b/docs/UPDATE_DELIVERY.md @@ -0,0 +1,93 @@ +# App update delivery + +## Plan and scope + +1. Add a shared update controller and About → App updates. Release builds check + at startup and at most once daily while running, with an opt-out. Checks never + block library startup. Notices stay hidden while reading and can be dismissed. +2. Keep Android updates in their distribution channel. Play builds ask Google + Play for account-eligible updates, including internal testing releases. A + separate `direct` flavor installs verified APKs using Android's confirmation + flow. Only that flavor declares installation permission and a narrow file + provider. Both retain `com.papyrus.reader` and existing signing identities. +3. Installed Windows builds download and verify the Inno installer, then wait for + normal app shutdown. A helper rechecks the checksum, upgrades the existing + directory without forcing shutdown or reboot, and relaunches Papyrus. Portable + ZIP installations retain manual delivery. Installer failures leave a log and + the verified installer in the temporary update directory for recovery. +4. AppImage builds download and verify the full replacement, stage it beside the + existing file and rename atomically. The running process keeps its old image + until the user closes it. This uses the same manifest updater as Windows rather + than introducing a separate delta-update dependency. Extracted and tarball + builds retain manual delivery. +5. Publish an optional signed flat APT repository as GitHub release assets. Once + configured, `.deb` users subscribe once and use their normal software updater. +6. Web builds compare their compiled build number with the deployed `release.json` + and reload only when requested. Existing centrally deployed delivery is retained. + +## Trust and release contract + +The native manifest URL is fixed to the public PapyrusReader/client GitHub +repository over HTTPS. It accepts only increasing build numbers, expected asset +names, and SHA-256 hashes. Download URLs are constructed from the validated tag +and platform, never taken from arbitrary feed URLs. Partial and corrupt downloads +are discarded. GitHub release publication permissions are the trust boundary. +Checksums do not provide an independent signature if the release account itself +is compromised. Windows Authenticode signing remains a separate future setup. +Android additionally verifies the installed package identity and signing +certificate before handing the APK to the OS installer. + +The Play AAB has no sideload-install permission. Build it with `--flavor play` +(the default). Build the direct APK from the `--flavor direct` AAB. The release +workflow produces both from the same revision. Never redirect Play builds to the +APK feed or switch keys to make an update install. + +The update controller and preferences are app-wide. Update packages use temporary +storage. No updater migrates, clears, or replaces library databases, profiles, +books, credentials, or reading positions. Normal database migrations still apply +when the new app starts. + +## APT activation + +Before the first APT-enabled release, create a dedicated OpenPGP signing key, +back it up securely, and configure the client repository's `release` environment: + +- Secret `APT_SIGNING_KEY`: ASCII-armored private signing key usable by batch GPG. +- Variable `APT_SIGNING_FINGERPRINT`: the complete 40-character fingerprint. +- Variable `APT_UPDATES_ENABLED`: `true`. + +Keep that signing identity stable across releases. Set a rotation procedure before +expiry. Once enabled, keep APT enabled for all subsequent releases, because the +subscription follows GitHub's latest published release. Missing signing material +fails publication when enabled. Disabled APT leaves existing release delivery +working but does not provide a repository. No secret is stored in this repository. + +The release job publishes `Packages`, `Packages.gz`, `Release`, `Release.gpg`, `InRelease`, +and `papyrus-archive-keyring.gpg` together with the `.deb`. The common release +manifest and checksum index cover them too. APT verifies the signed index and +package hash independently. If a release changes between `apt update` and +installation, repeat `apt update` to refresh the flat repository snapshot. + +## Acceptance before enabling distribution + +- Run the controller, downloader, widget, release-tool and package checks. +- Build web and both Android flavors. Inspect merged manifests to verify only + `direct` requests package installation permission. +- Play: install build N through internal testing, publish N+1 to the same track, + check and accept the update. Also cancel it and confirm offline reading works. +- APK: install N, decline then allow installation permission, update to N+1, + cancel and retry. Confirm Android rejects a package signed with another key. +- Windows x64: install N, download N+1 through the app, keep reading, close + normally and verify upgrade/relaunch and retained local books/positions. Test + paths with spaces, a failed installer, and portable ZIP fallback. +- Linux: update a writable AppImage while running, restart and verify version and + retained data. Test read-only paths and extracted-image fallback. Install the + APT signing key once, upgrade a real `.deb` through the repository and verify + a tampered index/package is rejected. +- Web: leave N open, deploy N+1, check/reload outside the reader and verify local + data survives. Test an offline check and a narrow e-ink layout. + +Older installed desktop/APK builds need one manual upgrade to acquire this code. +Play-installed testers already receive updates through Play. Source changes do +not activate a release, provision APT signing material, or establish native-device +acceptance on their own. diff --git a/tools/apt_repository.py b/tools/apt_repository.py new file mode 100644 index 00000000..08ec82d1 --- /dev/null +++ b/tools/apt_repository.py @@ -0,0 +1,99 @@ +"""Build a signed flat APT repository alongside immutable GitHub release downloads.""" + +import argparse +import datetime +import email.utils +import gzip +import hashlib +import os +import subprocess +import tempfile +from pathlib import Path + +APT_FILES = { + "apt_packages": "Packages.gz", + "apt_packages_plain": "Packages", + "apt_release": "Release", + "apt_signature": "Release.gpg", + "apt_inrelease": "InRelease", + "apt_key": "papyrus-archive-keyring.gpg", +} + + +def package_index(package: Path, version: str, number: int) -> bytes: + control = subprocess.check_output(["dpkg-deb", "--field", str(package)], text=True) + fields = dict(line.split(": ", 1) for line in control.splitlines() if ": " in line and not line.startswith(" ")) + + if (fields.get("Package"), fields.get("Version"), fields.get("Architecture")) != ( + "papyrus", f"{version}-{number}", "amd64" + ): + raise ValueError("APT package identity does not match the release") + + if any(name in fields for name in ("Filename", "Size", "SHA256")): + raise ValueError("Package control cannot override repository metadata") + + checksum = hashlib.sha256(package.read_bytes()).hexdigest() + return ( + control.rstrip() + f"\nFilename: {package.name}\nSize: {package.stat().st_size}\nSHA256: {checksum}\n\n" + ).encode() + + +def release_index(index: bytes, packages: bytes, date: datetime.datetime) -> str: + return ( + "Origin: PapyrusReader\nLabel: Papyrus\nSuite: stable\nCodename: papyrus\nArchitectures: amd64\n" + f"Date: {email.utils.format_datetime(date, usegmt=True)}\n" + f"SHA256:\n {hashlib.sha256(index).hexdigest()} {len(index)} Packages\n" + f" {hashlib.sha256(packages).hexdigest()} {len(packages)} Packages.gz\n" + ) + + +def build(root: Path, tag: str, private_key: str, fingerprint: str) -> None: + from release_assets import TAG, names + import re + + match = TAG.fullmatch(tag) + + if not match or not re.fullmatch(r"[A-Fa-f0-9]{40}", fingerprint) or not private_key.strip(): + raise ValueError("APT publishing requires a release tag, signing key and full signing-key fingerprint") + + package = root / names(tag)["deb"] + index = package_index(package, match[1], int(match[2])) + compressed = gzip.compress(index, mtime=0) + (root / "Packages").write_bytes(index) + (root / "Packages.gz").write_bytes(compressed) + date = datetime.datetime.now(datetime.timezone.utc) + (root / "Release").write_text(release_index(index, compressed, date)) + + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + home.chmod(0o700) + command = ["gpg", "--homedir", str(home), "--batch", "--yes"] + subprocess.run([*command, "--import"], input=private_key.encode(), capture_output=True, check=True) + key = subprocess.check_output([*command, "--export", fingerprint]) + + if not key: + raise ValueError("APT signing fingerprint does not identify the imported key") + + (root / "papyrus-archive-keyring.gpg").write_bytes(key) + + for output, operation in [("InRelease", "--clearsign"), ("Release.gpg", "--detach-sign")]: + subprocess.run([ + *command, "--local-user", fingerprint, "--digest-algo", "SHA256", "--output", str(root / output), + operation, str(root / "Release"), + ], check=True, capture_output=True) + + subprocess.run([ + *command, "--verify", str(root / "Release.gpg"), str(root / "Release"), + ], check=True, capture_output=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + parser.add_argument("--tag", required=True) + args = parser.parse_args() + build(args.root, args.tag, os.environ.get("APT_SIGNING_KEY", ""), os.environ.get("APT_SIGNING_FINGERPRINT", "")) + + +if __name__ == "__main__": + main() diff --git a/tools/check_android_distribution.py b/tools/check_android_distribution.py new file mode 100644 index 00000000..f2fcb05f --- /dev/null +++ b/tools/check_android_distribution.py @@ -0,0 +1,28 @@ +"""Check that APK installation permissions are confined to the direct flavor.""" + +import argparse +import xml.etree.ElementTree as ET +from pathlib import Path + +ANDROID = '{http://schemas.android.com/apk/res/android}' + + +def check(manifest: str, flavor: str) -> None: + root = ET.fromstring(manifest) + permissions = {item.get(ANDROID + 'name') for item in root.findall('uses-permission')} + providers = {item.get(ANDROID + 'authorities') for item in root.findall('application/provider')} + direct = flavor == 'direct' + + if ('android.permission.REQUEST_INSTALL_PACKAGES' in permissions) != direct: + raise ValueError('Only direct APK builds may request package installation') + + if ('com.papyrus.reader.updates' in providers) != direct: + raise ValueError('Only direct APK builds may expose update packages') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('manifest', type=Path) + parser.add_argument('--flavor', choices=['play', 'direct'], required=True) + args = parser.parse_args() + check(args.manifest.read_text(), args.flavor) diff --git a/tools/check_packaged_reader.ps1 b/tools/check_packaged_reader.ps1 index 6960ff38..af0fcff8 100644 --- a/tools/check_packaged_reader.ps1 +++ b/tools/check_packaged_reader.ps1 @@ -3,12 +3,38 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot 'windows_test_helpers.ps1') Set-Location (Join-Path $PSScriptRoot '../app') -$Directory = Join-Path ([IO.Path]::GetTempPath()) ('papyrus-reader-' + [Guid]::NewGuid().ToString()) +$Directory = Join-Path ([IO.Path]::GetTempPath()) ('papyrus reader ' + [Guid]::NewGuid().ToString()) function Install-TestPackage([string]$Version) { $Installer = (Resolve-Path "dist/papyrus-$Version-windows-x64-setup.exe").Path $Process = Start-Process $Installer -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/DIR=`"$Directory`"") -Wait -PassThru if ($Process.ExitCode -ne 0) { throw "Installer failed for $Version" } } +function Install-TestUpdate([string]$Version) { + $Download = Join-Path $Directory 'update download' + New-Item -ItemType Directory -Path $Download | Out-Null + $Installer = Join-Path $Download 'setup.exe' + Copy-Item "dist/papyrus-$Version-windows-x64-setup.exe" $Installer + $Checksum = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash.ToLowerInvariant() + $Report = Join-Path $PWD 'build/package-smoke-results/windows-updater.json' + Remove-Item -LiteralPath $Report -ErrorAction SilentlyContinue + $env:PAPYRUS_PERSISTENCE_PHASE = 'verify' + $env:PAPYRUS_SMOKE_REPORT = $Report + $Parent = Start-Process powershell.exe -ArgumentList @('-NoProfile', '-Command', 'Start-Sleep -Seconds 3') -PassThru + $Helper = (Resolve-Path 'assets/updates/install_windows.ps1').Path + $Arguments = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', "`"$Helper`"", '-ParentId', $Parent.Id, + '-Installer', "`"$Installer`"", '-Checksum', $Checksum, '-InstallDirectory', "`"$Directory`"") + $Updater = Start-Process powershell.exe -ArgumentList $Arguments -PassThru + Start-Sleep -Milliseconds 500 + if ($Updater.HasExited) { throw 'Updater did not wait for normal app exit' } + if (-not $Updater.WaitForExit(120000)) { throw 'Updater timed out' } + $Deadline = [DateTime]::UtcNow.AddSeconds(300) + while (-not (Test-Path $Report) -and [DateTime]::UtcNow -lt $Deadline) { Start-Sleep -Seconds 1 } + if (-not (Test-Path $Report)) { throw "Updater did not relaunch the reader. See $Download/update.log" } + $Result = Get-Content -LiteralPath $Report -Raw | ConvertFrom-Json + if ($Result.passed -ne $true) { throw 'Updater did not preserve the seeded library' } + Remove-Item Env:PAPYRUS_SMOKE_REPORT +} + function Uninstall-TestPackage { $Uninstall = Start-Process (Join-Path $Directory 'unins000.exe') -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') -Wait -PassThru if ($Uninstall.ExitCode -ne 0) { throw 'Uninstall failed' } @@ -23,7 +49,7 @@ try { $env:PAPYRUS_PERSISTENCE_SESSION = [Guid]::NewGuid().ToString() Install-TestPackage $PreviousTag Test-Persistence 'seed' 'windows-persistence-seed' - Install-TestPackage $Tag + Install-TestUpdate $Tag $VersionKey = 'HKCU:/Software/Microsoft/Windows/CurrentVersion/Uninstall/{EE12464D-190B-4F05-B5D7-C333AE7DFA91}_is1' if ((Get-ItemProperty $VersionKey).DisplayVersion -ne $Tag.Substring(1)) { throw 'Upgrade did not update installed version' } $Shortcut = Join-Path $env:APPDATA 'Microsoft/Windows/Start Menu/Programs/Papyrus/Papyrus.lnk' @@ -39,6 +65,7 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Installed reader smoke failed' } Uninstall-TestPackage } finally { + Remove-Item Env:PAPYRUS_SMOKE_REPORT -ErrorAction SilentlyContinue Remove-Item Env:PAPYRUS_PERSISTENCE_PHASE -ErrorAction SilentlyContinue Remove-Item Env:PAPYRUS_PERSISTENCE_SESSION -ErrorAction SilentlyContinue Remove-TestDirectory $Directory diff --git a/tools/release_assets.py b/tools/release_assets.py index fef2e17f..3428f567 100644 --- a/tools/release_assets.py +++ b/tools/release_assets.py @@ -12,6 +12,7 @@ from release_gate import parse from prepare_web_offline import prepare as prepare_web_offline +from apt_repository import APT_FILES TAG = re.compile(r"v([0-9]+\.[0-9]+\.[0-9]+)\+([1-9][0-9]*)") @@ -51,6 +52,14 @@ def seal(root: Path, tag: str, revision: str) -> dict: metadata = identity(tag, revision) files = names(tag) + apt_present = set(APT_FILES.values()) & {path.name for path in root.iterdir()} + + if apt_present: + if apt_present != set(APT_FILES.values()): + raise ValueError("APT repository assets must be published together") + + files.update(APT_FILES) + if {path.name for path in root.iterdir()} != set(files.values()): raise ValueError("Release must contain exactly the expected assets") @@ -74,6 +83,9 @@ def verify(root: Path, tag: str, revision: str) -> dict: if "android_apk" not in metadata["assets"]: files.pop("android_apk") + if set(APT_FILES) & set(metadata["assets"]): + files.update(APT_FILES) + if set(metadata["assets"]) != set(files): raise ValueError("Unexpected release assets") diff --git a/tools/tests/test_apt_repository.py b/tools/tests/test_apt_repository.py new file mode 100644 index 00000000..91d5a9cc --- /dev/null +++ b/tools/tests/test_apt_repository.py @@ -0,0 +1,130 @@ +"""Exercise signed APT metadata and publication as a complete release asset set.""" + +import gzip +import hashlib +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import apt_repository as apt +import release_assets as assets + +TAG = "v0.2.0+7" + + +class AptMetadataTest(unittest.TestCase): + def test_index_binds_package_identity_size_and_hash(self): + with tempfile.TemporaryDirectory() as directory: + package = Path(directory) / assets.names(TAG)["deb"] + package.write_bytes(b"package bytes") + control = "Package: papyrus\nVersion: 0.2.0-7\nArchitecture: amd64\nDescription: reader\n" + + with patch.object(apt.subprocess, "check_output", return_value=control): + index = apt.package_index(package, "0.2.0", 7).decode() + self.assertIn(f"SHA256: {hashlib.sha256(package.read_bytes()).hexdigest()}", index) + self.assertIn(f"Filename: {package.name}", index) + + with self.assertRaisesRegex(ValueError, "identity"): + apt.package_index(package, "0.2.0", 8) + + def test_partial_repository_cannot_be_sealed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + for name in assets.names(TAG).values(): + (root / name).write_bytes(b"asset") + + (root / "InRelease").write_text("partial") + + with self.assertRaisesRegex(ValueError, "together"): + assets.seal(root, TAG, "a" * 40) + + def test_repository_assets_are_covered_by_release_verification(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + + for name in [*assets.names(TAG).values(), *apt.APT_FILES.values()]: + (root / name).write_bytes(b"asset") + + assets.seal(root, TAG, "a" * 40) + assets.verify(root, TAG, "a" * 40) + (root / "InRelease").write_bytes(b"tampered") + + with self.assertRaisesRegex(ValueError, "checksum"): + assets.verify(root, TAG, "a" * 40) + + def test_missing_signing_material_fails_closed(self): + with self.assertRaisesRegex(ValueError, "signing"): + apt.build(Path("unused"), TAG, "", "") + + +@unittest.skipUnless(shutil.which("gpg") and shutil.which("dpkg-deb"), "APT integration requires GPG and dpkg") +class AptSigningTest(unittest.TestCase): + def test_real_signature_and_package_index(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + home = root / "gnupg" + home.mkdir(mode=0o700) + gpg = ["gpg", "--homedir", str(home), "--batch", "--pinentry-mode", "loopback", "--passphrase", ""] + subprocess.run( + [*gpg, "--quick-generate-key", "Papyrus test ", "ed25519", "sign", "1d"], + check=True, capture_output=True, + ) + listing = subprocess.check_output([*gpg, "--with-colons", "--list-keys"], text=True, stderr=subprocess.DEVNULL) + fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + private_key = subprocess.check_output([*gpg, "--armor", "--export-secret-keys", fingerprint], text=True) + package_root = root / "package" + (package_root / "DEBIAN").mkdir(parents=True) + (package_root / "DEBIAN/control").write_text( + "Package: papyrus\nVersion: 0.2.0-7\nArchitecture: amd64\n" + "Maintainer: Test \nDescription: test package\n" + ) + repository = root / "repository" + repository.mkdir() + subprocess.run( + ["dpkg-deb", "--build", str(package_root), str(repository / assets.names(TAG)["deb"])], + check=True, capture_output=True, + ) + apt.build(repository, TAG, private_key, fingerprint) + subprocess.run( + ["gpgv", "--keyring", str(repository / "papyrus-archive-keyring.gpg"), str(repository / "InRelease")], + check=True, capture_output=True, + ) + index = gzip.decompress((repository / "Packages.gz").read_bytes()).decode() + self.assertIn("Version: 0.2.0-7", index) + + # Use APT itself with isolated lists/cache to prove the flat feed is consumable. + if shutil.which("apt-get"): + (root / "lists/partial").mkdir(parents=True) + (root / "cache/archives/partial").mkdir(parents=True) + source = root / "papyrus.list" + source.write_text( + f"deb [signed-by={repository}/papyrus-archive-keyring.gpg] file:{repository} ./\n" + ) + command = [ + "apt-get", "-o", f"Dir::Etc::sourcelist={source}", "-o", "Dir::Etc::sourceparts=-", + "-o", f"Dir::State::lists={root}/lists", "-o", f"Dir::Cache={root}/cache", + "-o", "APT::Sandbox::User=root", + ] + result = subprocess.run([*command, "update"], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + update_output = result.stdout + result.stderr + result = subprocess.run([*command, "download", "papyrus=0.2.0-7"], cwd=root, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, update_output + result.stdout + result.stderr) + self.assertTrue((root / "papyrus_0.2.0-7_amd64.deb").exists()) + + (repository / "InRelease").write_bytes((repository / "InRelease").read_bytes().replace(b"PapyrusReader", b"OtherReader!!")) + result = subprocess.run( + ["gpgv", "--keyring", str(repository / "papyrus-archive-keyring.gpg"), str(repository / "InRelease")], + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main() From 63d248e881a535beb2c0cf5358d8b9e4fd432e5f Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 03:53:44 +0200 Subject: [PATCH 2/7] fix: coordinate updates with offline web caches and current client --- app/lib/updates/update_controller.dart | 15 ++ app/lib/updates/update_download.dart | 24 +-- app/lib/updates/update_platform_web.dart | 10 +- app/lib/updates/update_settings.dart | 147 +++++++++++-------- app/pubspec.yaml | 1 - app/test/updates/update_settings_test.dart | 111 +++++++------- app/test/web/app_updates_test.cjs | 51 +++++++ app/test/web/offline_service_worker_test.cjs | 32 +++- app/web/app_updates.js | 54 +++++++ app/web/index.html | 1 + app/web/papyrus_service_worker.js | 13 ++ docs/UPDATE_DELIVERY.md | 6 +- tools/check_packaged_reader.ps1 | 4 + tools/prepare_web_offline.py | 2 +- tools/tests/test_web_offline.py | 2 +- 15 files changed, 336 insertions(+), 137 deletions(-) create mode 100644 app/test/web/app_updates_test.cjs create mode 100644 app/web/app_updates.js diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart index 31285e22..bb75dc2d 100644 --- a/app/lib/updates/update_controller.dart +++ b/app/lib/updates/update_controller.dart @@ -21,6 +21,7 @@ class UpdateController extends ChangeNotifier { bool busy = false; bool available = false; bool staged = false; + bool viewingSettings = false; double? progress; String message = 'Check for a newer version of Papyrus.'; Timer? _timer; @@ -38,6 +39,11 @@ class UpdateController extends ChangeNotifier { bool get automatic => preferences.getBool('updates.automatic') ?? true; bool get external => channel == UpdateChannel.apt || channel == UpdateChannel.manual; + void setViewingSettings(bool value) { + viewingSettings = value; + _notify(); + } + void start() { if (kReleaseMode) { unawaited(_backgroundCheck()); @@ -128,7 +134,16 @@ class UpdateController extends ChangeNotifier { try { final asset = channel == UpdateChannel.play || channel == UpdateChannel.web ? null : release!.asset(_assetKey); + var previousPercent = -1; + message = await installer(asset, (value) { + final percent = value == null ? 0 : (value * 100).floor(); + + if (percent == previousPercent) { + return; + } + + previousPercent = percent; progress = value; message = value == null ? 'Downloading update…' : 'Downloading update: ${(value * 100).floor()}%'; _notify(); diff --git a/app/lib/updates/update_download.dart b/app/lib/updates/update_download.dart index 7dd22ab4..f06faf3b 100644 --- a/app/lib/updates/update_download.dart +++ b/app/lib/updates/update_download.dart @@ -28,16 +28,18 @@ Future downloadUpdate( var received = 0; final total = response.contentLength; - await for (final bytes in response.stream.timeout(const Duration(seconds: 30))) { - received += bytes.length; + await sink.addStream( + response.stream.timeout(const Duration(seconds: 30)).map((bytes) { + received += bytes.length; - if (received > maximumBytes) { - throw const HttpException('Update exceeds download limit'); - } + if (received > maximumBytes) { + throw const HttpException('Update exceeds download limit'); + } - sink.add(bytes); - progress(total == null || total == 0 ? null : received / total); - } + progress(total == null || total == 0 ? null : received / total); + return bytes; + }), + ); await sink.flush(); await sink.close(); @@ -53,7 +55,11 @@ Future downloadUpdate( throw const FormatException('Update checksum mismatch'); } } catch (_) { - await sink?.close(); + try { + await sink?.close(); + } catch (_) { + // addStream can close the sink itself when the network stream fails. + } if (await destination.exists()) { await destination.delete(); diff --git a/app/lib/updates/update_platform_web.dart b/app/lib/updates/update_platform_web.dart index 5a163dce..056d82d8 100644 --- a/app/lib/updates/update_platform_web.dart +++ b/app/lib/updates/update_platform_web.dart @@ -1,11 +1,15 @@ +import 'dart:js_interop'; + import 'package:papyrus/updates/update_release.dart'; import 'package:web/web.dart' as web; +@JS('papyrusApplyUpdate') +external JSPromise _applyUpdate(); + Future updateChannel() async => UpdateChannel.web; Future checkPlayUpdate() async => null; -Uri get updateManifestUri => Uri.base.resolve('/release.json'); +Uri get updateManifestUri => Uri.parse(web.document.baseURI).resolve('release.json'); Future installUpdate(UpdateAsset? asset, void Function(double?) progress) async { - web.window.location.reload(); - return 'Reloading Papyrus…'; + return (await _applyUpdate().toDart).toDart; } diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index 98c0732e..4b28e5e4 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -2,82 +2,94 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/updates/update_controller.dart'; import 'package:papyrus/updates/update_release.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; -Future showUpdateSettings(BuildContext context, UpdateController controller) { - return showDialog( - context: context, - builder: (context) => ListenableBuilder( - listenable: controller, - builder: (context, child) => AlertDialog( - title: const Text('App updates'), - content: SizedBox( - width: 420, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(controller.message), - const SizedBox(height: Spacing.md), - SettingsToggleRow( - label: 'Check automatically', - value: controller.automatic, - onChanged: controller.setAutomatic, - ), - if (controller.channel == UpdateChannel.apt) ...[ +Future showUpdateSettings(BuildContext context, UpdateController controller) async { + if (controller.viewingSettings) { + return; + } + + controller.setViewingSettings(true); + + try { + await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (context) => ListenableBuilder( + listenable: controller, + builder: (context, child) => AlertDialog( + title: const Text('App updates'), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(controller.message), const SizedBox(height: Spacing.md), - const Text( - 'Install updates through your system’s software updater. Enable the Papyrus repository once to receive future releases.', + SettingsToggleRow( + label: 'Check automatically', + value: controller.automatic, + onChanged: controller.setAutomatic, ), - TextButton( - onPressed: () => AboutLinks.open( - context, - Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md'), + if (controller.channel == UpdateChannel.apt) ...[ + const SizedBox(height: Spacing.md), + const Text( + 'Install updates through your system’s software updater. Enable the Papyrus repository once to receive future releases.', ), - child: const Text('Repository setup'), - ), - ], - if (controller.channel == UpdateChannel.manual) ...[ - const SizedBox(height: Spacing.md), - const Text( - 'This portable or development build uses manual installation. Install a supported package to receive integrated updates.', - ), - TextButton( - onPressed: () => AboutLinks.open(context, AboutLinks.releases), - child: const Text('Downloads'), - ), - ], - if (controller.available && !controller.external && !controller.staged) ...[ - const SizedBox(height: Spacing.md), - Text(switch (controller.channel) { - UpdateChannel.windows => 'The update will install after you close Papyrus normally.', - UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', - UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', - _ => 'Your device will guide you through installation.', - }), + TextButton( + onPressed: () => AboutLinks.open( + context, + Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md'), + ), + child: const Text('Repository setup'), + ), + ], + if (controller.channel == UpdateChannel.manual) ...[ + const SizedBox(height: Spacing.md), + const Text( + 'This portable or development build uses manual installation. Install a supported package to receive integrated updates.', + ), + TextButton( + onPressed: () => AboutLinks.open(context, AboutLinks.releases), + child: const Text('Downloads'), + ), + ], + if (controller.available && !controller.external && !controller.staged) ...[ + const SizedBox(height: Spacing.md), + Text(switch (controller.channel) { + UpdateChannel.windows => 'The update will install after you close Papyrus normally.', + UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', + UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', + _ => 'Your device will guide you through installation.', + }), + ], ], - ], + ), ), ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), - TextButton( - onPressed: controller.busy || controller.staged ? null : controller.check, - child: const Text('Check now'), - ), - if (controller.available && !controller.external && !controller.staged) - FilledButton( - onPressed: controller.busy ? null : controller.install, - child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), + TextButton( + onPressed: controller.busy || controller.staged ? null : controller.check, + child: const Text('Check now'), ), - ], + if (controller.available && !controller.external && !controller.staged) + FilledButton( + onPressed: controller.busy ? null : controller.install, + child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), + ), + ], + ), ), - ), - ); + ); + } finally { + controller.setViewingSettings(false); + } } class UpdateNotice extends StatefulWidget { @@ -100,7 +112,12 @@ class _UpdateNoticeState extends State { listenable: Listenable.merge([widget.controller, widget.router.routeInformationProvider]), builder: (context, child) { final reading = widget.router.routeInformationProvider.value.uri.path.contains('/read/'); - final visible = widget.controller.available && !widget.controller.staged && !_dismissed && !reading; + final visible = + widget.controller.available && + !widget.controller.staged && + !widget.controller.viewingSettings && + !_dismissed && + !reading; return Column( children: [ diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d0e592df..15bf9707 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -63,7 +63,6 @@ dev_dependencies: shared_preferences_platform_interface: ^2.4.1 flutter: - default-flavor: play config: enable-swift-package-manager: false diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index 6d75f6df..bc57fe2c 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -3,61 +3,70 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/updates/update_release.dart'; import 'package:papyrus/updates/update_settings.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { - testWidgets('notice waits until reader is closed and opens usable narrow update dialog', (tester) async { - tester.view.physicalSize = const Size(360, 780); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - SharedPreferences.setMockInitialValues({}); - final updates = UpdateController( - preferences: await SharedPreferences.getInstance(), - appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), - channelProvider: () async => UpdateChannel.play, - playCheck: () async => 7, - ); - addTearDown(updates.dispose); - final router = GoRouter( - initialLocation: '/library/read/book', - routes: [ - GoRoute( - path: '/', - builder: (_, _) => const Scaffold(body: Text('Library')), + for (final scale in [1.0, 2.0]) { + testWidgets('notice and dialog work on a narrow e-ink screen at text scale $scale', (tester) async { + tester.view.physicalSize = const Size(360, 780); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.play, + playCheck: () async => 7, + ); + addTearDown(updates.dispose); + final router = GoRouter( + initialLocation: '/library/read/book', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('Library')), + ), + GoRoute( + path: '/library/read/book', + builder: (_, _) => const Scaffold(body: Text('Reader')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: scale == 2 ? AppTheme.eink : AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: UpdateNotice(controller: updates, router: router, child: child!), + ), ), - GoRoute( - path: '/library/read/book', - builder: (_, _) => const Scaffold(body: Text('Reader')), - ), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - MaterialApp.router( - routerConfig: router, - builder: (context, child) => UpdateNotice(controller: updates, router: router, child: child!), - ), - ); - await updates.check(); - await tester.pumpAndSettle(); - expect(find.text('A Papyrus update is available.'), findsNothing); - router.go('/'); - await tester.pumpAndSettle(); - await tester.tap(find.text('View update')); - await tester.pumpAndSettle(); - expect(find.text('App updates'), findsOneWidget); - expect(find.text('Update'), findsOneWidget); - await tester.tap(find.text('Check automatically')); - await tester.pumpAndSettle(); - expect(updates.automatic, isFalse); - expect(tester.takeException(), isNull); - await tester.tap(find.text('Close')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Later')); - await tester.pumpAndSettle(); - expect(find.text('A Papyrus update is available.'), findsNothing); - }); + ); + await updates.check(); + await tester.pumpAndSettle(); + expect(find.text('A Papyrus update is available.'), findsNothing); + router.go('/'); + await tester.pumpAndSettle(); + await tester.tap(find.text('View update')); + await tester.pumpAndSettle(); + expect(find.text('App updates'), findsOneWidget); + expect(find.text('Update'), findsOneWidget); + await tester.ensureVisible(find.text('Check automatically')); + await tester.tap(find.text('Check automatically')); + await tester.pumpAndSettle(); + expect(updates.automatic, isFalse); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Later')); + await tester.pumpAndSettle(); + expect(find.text('A Papyrus update is available.'), findsNothing); + }); + } } diff --git a/app/test/web/app_updates_test.cjs b/app/test/web/app_updates_test.cjs new file mode 100644 index 00000000..34750cd3 --- /dev/null +++ b/app/test/web/app_updates_test.cjs @@ -0,0 +1,51 @@ +const assert = require('node:assert/strict'); +const {readFileSync} = require('node:fs'); +const {join} = require('node:path'); +const {runInNewContext} = require('node:vm'); +const test = require('node:test'); +const source = readFileSync(join(__dirname, '../../web/app_updates.js'), 'utf8'); + +function page({waiting = false, otherTabs = false} = {}) { + let reloaded = false; + let activated; + let checked = false; + const registration = { + async update() { checked = true; }, + waiting: waiting ? {postMessage(message, ports) { + assert.equal(message, 'activate-update'); + if (otherTabs) ports[0].reply('other-tabs'); else activated(); + }} : null, + }; + const window = {location: {reload() { reloaded = true; }}}; + runInNewContext(source, { + window, document: {baseURI: 'https://papyrus.test/reader/'}, setTimeout, clearTimeout, + navigator: {serviceWorker: { + async getRegistration(scope) { assert.equal(scope, 'https://papyrus.test/reader/'); return registration; }, + addEventListener(_, handler) { activated = handler; }, + removeEventListener() {}, + }}, + MessageChannel: class { + constructor() { this.port1 = {close() {}}; this.port2 = {reply: data => this.port1.onmessage({data})}; } + }, + }); + return {apply: window.papyrusApplyUpdate, reloaded: () => reloaded, checked: () => checked}; +} + +test('explicit reload activates the waiting worker first', async () => { + const app = page({waiting: true}); + await app.apply(); + assert.ok(app.checked()); + assert.ok(app.reloaded()); +}); + +test('other reading tabs prevent activation and receive an actionable message', async () => { + const app = page({waiting: true, otherTabs: true}); + assert.match(await app.apply(), /Close other Papyrus tabs/); + assert.equal(app.reloaded(), false); +}); + +test('no waiting worker reloads the current deployment', async () => { + const app = page(); + await app.apply(); + assert.ok(app.reloaded()); +}); diff --git a/app/test/web/offline_service_worker_test.cjs b/app/test/web/offline_service_worker_test.cjs index 0f57f157..9b839351 100644 --- a/app/test/web/offline_service_worker_test.cjs +++ b/app/test/web/offline_service_worker_test.cjs @@ -9,13 +9,14 @@ const source = readFileSync(join(__dirname, '../../web/papyrus_service_worker.js const origin = 'https://papyrus.test'; const scope = `${origin}/reader/`; -function worker({failAsset = false} = {}) { +function worker({failAsset = false, windows = 1} = {}) { const handlers = {}; const stores = new Map([['unrelated-cache', new Map()], ['papyrus-offline-old', new Map()]]); const requests = []; let offline = false; + let activated = false; const key = request => new URL(typeof request === 'string' ? request : request.href || request.url, scope).href; - const manifest = {revision: 'test', assets: ['index.html', 'main.dart.js', 'book_worker.js', 'canvaskit/canvaskit.wasm']}; + const manifest = {revision: 'test', assets: ['index.html', 'main.dart.js', 'book_worker.js', 'canvaskit/canvaskit.wasm', 'release.json']}; const fetch = async request => { requests.push(key(request)); if (offline) throw new Error('Offline'); @@ -44,12 +45,21 @@ function worker({failAsset = false} = {}) { async delete(name) { return stores.delete(name); }, }, self: { - registration: {scope}, location: {origin}, clients: {async claim() {}}, + registration: {scope}, location: {origin}, clients: {async claim() {}, async matchAll() { return Array(windows).fill({}); }}, + async skipWaiting() { activated = true; }, addEventListener(name, handler) { handlers[name] = handler; }, }, }); return { stores, requests, + activated: () => activated, + async activateUpdate() { + let operation; + let response; + handlers.message({data: 'activate-update', ports: [{postMessage(value) { response = value; }}], waitUntil(value) { operation = value; }}); + await operation; + return response; + }, offline() { offline = true; }, lifecycle(name) { let operation; @@ -94,3 +104,19 @@ test('failed preparation removes the partial cache and leaves the existing relea assert.ok(!runtime.stores.has('papyrus-offline-test')); assert.ok(runtime.stores.has('papyrus-offline-old')); }); + +test('release checks bypass cached release metadata', async () => { + const runtime = worker(); + await runtime.lifecycle('install'); + assert.equal(runtime.fetch(scope + 'release.json?check=123'), undefined); +}); + +test('only explicit activation from a sole tab skips waiting', async () => { + const single = worker(); + assert.equal(single.activated(), false); + await single.activateUpdate(); + assert.equal(single.activated(), true); + const multiple = worker({windows: 2}); + assert.equal(await multiple.activateUpdate(), 'other-tabs'); + assert.equal(multiple.activated(), false); +}); diff --git a/app/web/app_updates.js b/app/web/app_updates.js new file mode 100644 index 00000000..6ff1d0fc --- /dev/null +++ b/app/web/app_updates.js @@ -0,0 +1,54 @@ +'use strict'; + +window.papyrusApplyUpdate = async () => { + if (!('serviceWorker' in navigator)) { + window.location.reload(); + return 'Reloading Papyrus…'; + } + const registration = await navigator.serviceWorker.getRegistration(document.baseURI); + if (!registration) { + window.location.reload(); + return 'Reloading Papyrus…'; + } + await registration.update(); + const installing = registration.installing; + if (installing) { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => finish(new Error('Update download timed out')), 120000); + const changed = () => { + if (installing.state === 'installed') finish(); + if (installing.state === 'redundant') finish(new Error('Update download failed')); + }; + const finish = error => { + clearTimeout(timeout); + installing.removeEventListener('statechange', changed); + if (error) reject(error); else resolve(); + }; + installing.addEventListener('statechange', changed); + changed(); + }); + } + if (!registration.waiting) { + window.location.reload(); + return 'Reloading Papyrus…'; + } + return new Promise(resolve => { + const channel = new MessageChannel(); + const finish = message => { + clearTimeout(timeout); + navigator.serviceWorker.removeEventListener('controllerchange', activated); + channel.port1.close(); + resolve(message); + }; + const activated = () => { + finish('Reloading Papyrus…'); + window.location.reload(); + }; + const timeout = setTimeout(() => finish('The update is not ready. Please try again.'), 30000); + navigator.serviceWorker.addEventListener('controllerchange', activated); + channel.port1.onmessage = event => { + if (event.data === 'other-tabs') finish('Close other Papyrus tabs before updating, then try again.'); + }; + registration.waiting.postMessage('activate-update', [channel.port2]); + }); +}; diff --git a/app/web/index.html b/app/web/index.html index 1dd89b2f..b8e0c312 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -34,6 +34,7 @@ + diff --git a/app/web/papyrus_service_worker.js b/app/web/papyrus_service_worker.js index e45bc7c3..ff9add12 100644 --- a/app/web/papyrus_service_worker.js +++ b/app/web/papyrus_service_worker.js @@ -37,6 +37,18 @@ self.addEventListener('install', event => { })()); }); +self.addEventListener('message', event => { + if (event.data !== 'activate-update') return; + event.waitUntil((async () => { + const windows = await self.clients.matchAll({type: 'window', includeUncontrolled: true}); + if (windows.length > 1) { + event.ports[0]?.postMessage('other-tabs'); + return; + } + await self.skipWaiting(); + })()); +}); + self.addEventListener('activate', event => { event.waitUntil((async () => { await metadata(); @@ -51,6 +63,7 @@ self.addEventListener('fetch', event => { const request = event.request; const url = new URL(request.url); if (request.method !== 'GET' || url.origin !== self.location.origin) return; + if (url.pathname === new URL('release.json', self.registration.scope).pathname) return; // Never cache authenticated endpoints or user media. if (url.pathname.includes('/v1/') || url.pathname.includes('/api/')) return; event.respondWith((async () => { diff --git a/docs/UPDATE_DELIVERY.md b/docs/UPDATE_DELIVERY.md index d5a77286..ca81ef1a 100644 --- a/docs/UPDATE_DELIVERY.md +++ b/docs/UPDATE_DELIVERY.md @@ -23,7 +23,8 @@ 5. Publish an optional signed flat APT repository as GitHub release assets. Once configured, `.deb` users subscribe once and use their normal software updater. 6. Web builds compare their compiled build number with the deployed `release.json` - and reload only when requested. Existing centrally deployed delivery is retained. + and activate the waiting offline worker before reloading when requested. Other +open Papyrus tabs prevent activation until they are closed. Existing centrally deployed delivery is retained. ## Trust and release contract @@ -37,8 +38,7 @@ is compromised. Windows Authenticode signing remains a separate future setup. Android additionally verifies the installed package identity and signing certificate before handing the APK to the OS installer. -The Play AAB has no sideload-install permission. Build it with `--flavor play` -(the default). Build the direct APK from the `--flavor direct` AAB. The release +The Play AAB has no sideload-install permission. Build it with `--flavor play`. Build the direct APK from the `--flavor direct` AAB. The release workflow produces both from the same revision. Never redirect Play builds to the APK feed or switch keys to make an update install. diff --git a/tools/check_packaged_reader.ps1 b/tools/check_packaged_reader.ps1 index af0fcff8..d4d84313 100644 --- a/tools/check_packaged_reader.ps1 +++ b/tools/check_packaged_reader.ps1 @@ -32,6 +32,10 @@ function Install-TestUpdate([string]$Version) { if (-not (Test-Path $Report)) { throw "Updater did not relaunch the reader. See $Download/update.log" } $Result = Get-Content -LiteralPath $Report -Raw | ConvertFrom-Json if ($Result.passed -ne $true) { throw 'Updater did not preserve the seeded library' } + $Relaunched = Get-Process papyrus -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq (Join-Path $Directory 'papyrus.exe') } + foreach ($Process in $Relaunched) { + if (-not $Process.WaitForExit(30000)) { throw 'Relaunched reader did not finish its test' } + } Remove-Item Env:PAPYRUS_SMOKE_REPORT } diff --git a/tools/prepare_web_offline.py b/tools/prepare_web_offline.py index 820e951d..e6b0451c 100644 --- a/tools/prepare_web_offline.py +++ b/tools/prepare_web_offline.py @@ -8,7 +8,7 @@ def prepare(root: Path) -> dict: - required = ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "book_worker.js", "papyrus_service_worker.js") + required = ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "app_updates.js", "book_worker.js", "papyrus_service_worker.js") for name in required: if not (root / name).is_file(): diff --git a/tools/tests/test_web_offline.py b/tools/tests/test_web_offline.py index 1b84041d..b0cf9751 100644 --- a/tools/tests/test_web_offline.py +++ b/tools/tests/test_web_offline.py @@ -13,7 +13,7 @@ class OfflinePackagingTest(unittest.TestCase): def test_revision_changes_with_assets_and_worker_but_not_repeated_preparation(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - for name in ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "book_worker.js"): + for name in ("index.html", "flutter_bootstrap.js", "main.dart.js", "folder_access.js", "app_updates.js", "book_worker.js"): (root / name).write_text(name) worker = root / "papyrus_service_worker.js" worker.write_text("const applicationRevision = 'unprepared';\n") From f624e30b13e598094afa5e7e9b1dad8b7012a1b8 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 04:09:28 +0200 Subject: [PATCH 3/7] fix: stabilize app update toggle feedback and alignment --- app/lib/updates/update_controller.dart | 8 +- app/lib/updates/update_settings.dart | 15 +++- app/lib/widgets/settings/settings_row.dart | 12 ++- app/test/updates/update_controller_test.dart | 19 +++++ app/test/updates/update_settings_test.dart | 77 ++++++++++++++++++++ 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart index bb75dc2d..9ea2b362 100644 --- a/app/lib/updates/update_controller.dart +++ b/app/lib/updates/update_controller.dart @@ -54,16 +54,14 @@ class UpdateController extends ChangeNotifier { Future setAutomatic(bool value) async { await preferences.setBool('updates.automatic', value); _notify(); - - if (value) { - await _backgroundCheck(); - } } Future _backgroundCheck() async { final lastCheck = preferences.getInt('updates.lastCheck') ?? 0; - if (!automatic || DateTime.now().millisecondsSinceEpoch - lastCheck < const Duration(days: 1).inMilliseconds) { + if (viewingSettings || + !automatic || + DateTime.now().millisecondsSinceEpoch - lastCheck < const Duration(days: 1).inMilliseconds) { return; } diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index 4b28e5e4..e396036e 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -31,10 +31,17 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle children: [ Text(controller.message), const SizedBox(height: Spacing.md), - SettingsToggleRow( - label: 'Check automatically', - value: controller.automatic, - onChanged: controller.setAutomatic, + Theme( + data: Theme.of(context).copyWith( + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, + ), + child: SettingsToggleRow( + label: 'Check automatically', + value: controller.automatic, + onChanged: controller.setAutomatic, + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + ), ), if (controller.channel == UpdateChannel.apt) ...[ const SizedBox(height: Spacing.md), diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index a740ad83..f3578838 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -137,7 +137,15 @@ class SettingsToggleRow extends StatelessWidget { final ValueChanged? onChanged; /// Creates a settings toggle row widget. - const SettingsToggleRow({super.key, required this.label, required this.value, this.onChanged}); + const SettingsToggleRow({ + super.key, + required this.label, + required this.value, + this.onChanged, + this.padding = const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + }); + + final EdgeInsetsGeometry padding; @override Widget build(BuildContext context) { @@ -164,7 +172,7 @@ class SettingsToggleRow extends StatelessWidget { child: ConstrainedBox( constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + padding: padding, child: Row( children: [ Expanded( diff --git a/app/test/updates/update_controller_test.dart b/app/test/updates/update_controller_test.dart index 175a16b3..437d0851 100644 --- a/app/test/updates/update_controller_test.dart +++ b/app/test/updates/update_controller_test.dart @@ -168,6 +168,25 @@ void main() { expect(checks, 1); }); + test('changing automatic checks persists without starting a check or changing status', () async { + var checks = 0; + final updates = controller( + manifest: () async { + checks++; + throw StateError('offline'); + }, + ); + + await updates.check(); + final message = updates.message; + await updates.setAutomatic(false); + await updates.setAutomatic(true); + expect(preferences.getBool('updates.automatic'), isTrue); + expect(checks, 1); + expect(updates.message, message); + expect(updates.busy, isFalse); + }); + test('invalid release identity fails closed', () { for (final data in [ {...metadata(), 'tag': 'v0.2.0+8'}, diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index bc57fe2c..c60feb5e 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -1,3 +1,6 @@ +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; @@ -9,6 +12,66 @@ import 'package:papyrus/updates/update_settings.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + for (final eink in [false, true]) { + testWidgets('automatic toggle stays aligned and has no pressed ink, eink=$eink', (tester) async { + SharedPreferences.setMockInitialValues({'updates.automatic': false}); + var checks = 0; + final preferences = await SharedPreferences.getInstance(); + final updates = UpdateController( + preferences: preferences, + channelProvider: () async { + checks++; + return UpdateChannel.windows; + }, + ); + + addTearDown(updates.dispose); + const status = 'Could not check for updates. Check your connection and try again.'; + updates.message = status; + final boundaryKey = GlobalKey(); + + await tester.pumpWidget( + RepaintBoundary( + key: boundaryKey, + child: MaterialApp( + theme: eink ? AppTheme.eink : AppTheme.dark, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showUpdateSettings(context, updates), + child: const Text('Open updates'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open updates')); + await tester.pumpAndSettle(); + final dialog = tester.getRect(find.byType(AlertDialog)); + final label = tester.getTopLeft(find.text('Check automatically')); + expect(label.dx, tester.getTopLeft(find.text(status)).dx); + final before = await capture(tester, boundaryKey); + final gesture = await tester.startGesture(tester.getCenter(find.byType(Switch))); + await tester.pump(const Duration(milliseconds: 100)); + expect(await capture(tester, boundaryKey), before); + await gesture.up(); + await tester.pumpAndSettle(); + expect(preferences.getBool('updates.automatic'), isTrue); + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect(find.text(status), findsOneWidget); + expect(tester.getRect(find.byType(AlertDialog)), dialog); + expect(tester.getTopLeft(find.text('Check automatically')), label); + expect(checks, 0); + await tester.tap(find.text('Check automatically')); + await tester.pumpAndSettle(); + expect(preferences.getBool('updates.automatic'), isFalse); + expect(tester.getRect(find.byType(AlertDialog)), dialog); + expect(checks, 0); + }); + } + for (final scale in [1.0, 2.0]) { testWidgets('notice and dialog work on a narrow e-ink screen at text scale $scale', (tester) async { tester.view.physicalSize = const Size(360, 780); @@ -70,3 +133,17 @@ void main() { }); } } + +Future> capture(WidgetTester tester, GlobalKey key) async { + return (await tester.runAsync(() async { + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + + try { + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + return bytes!.buffer.asUint8List(); + } finally { + image.dispose(); + } + }))!; +} From d8f9a558c83b57a459d27879278fc71ee5f9adfe Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 04:17:14 +0200 Subject: [PATCH 4/7] fix: keep update status layout stable while checking --- app/lib/updates/update_controller.dart | 4 +- app/lib/updates/update_settings.dart | 14 +++- app/test/updates/update_settings_test.dart | 78 +++++++++++++++++++++- 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart index 9ea2b362..6a9e3f92 100644 --- a/app/lib/updates/update_controller.dart +++ b/app/lib/updates/update_controller.dart @@ -10,6 +10,8 @@ import 'package:papyrus/updates/update_release.dart'; import 'package:shared_preferences/shared_preferences.dart'; class UpdateController extends ChangeNotifier { + static const checkFailureMessage = 'Could not check for updates. Check your connection and try again.'; + final SharedPreferences preferences; final Future Function() appInfo; final Future Function() channelProvider; @@ -105,7 +107,7 @@ class UpdateController extends ChangeNotifier { } catch (_) { available = false; release = null; - message = 'Could not check for updates. Check your connection and try again.'; + message = checkFailureMessage; } finally { busy = false; _notify(); diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index e396036e..c1a8ccfe 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -29,7 +29,19 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(controller.message), + Stack( + children: [ + // Reserve the wrapped error height so checking and retrying do not move the dialog. + const Visibility( + visible: false, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: Text(UpdateController.checkFailureMessage), + ), + Text(controller.message), + ], + ), const SizedBox(height: Spacing.md), Theme( data: Theme.of(context).copyWith( diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index c60feb5e..02210546 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/rendering.dart'; @@ -51,7 +52,7 @@ void main() { await tester.pumpAndSettle(); final dialog = tester.getRect(find.byType(AlertDialog)); final label = tester.getTopLeft(find.text('Check automatically')); - expect(label.dx, tester.getTopLeft(find.text(status)).dx); + expect(label.dx, tester.getTopLeft(find.text(status).hitTestable()).dx); final before = await capture(tester, boundaryKey); final gesture = await tester.startGesture(tester.getCenter(find.byType(Switch))); await tester.pump(const Duration(milliseconds: 100)); @@ -60,7 +61,7 @@ void main() { await tester.pumpAndSettle(); expect(preferences.getBool('updates.automatic'), isTrue); expect(tester.widget(find.byType(Switch)).value, isTrue); - expect(find.text(status), findsOneWidget); + expect(find.text(status).hitTestable(), findsOneWidget); expect(tester.getRect(find.byType(AlertDialog)), dialog); expect(tester.getTopLeft(find.text('Check automatically')), label); expect(checks, 0); @@ -72,6 +73,79 @@ void main() { }); } + for (final (width, scale, eink) in [(800.0, 1.0, false), (360.0, 1.0, false), (360.0, 2.0, true)]) { + testWidgets('Check now keeps status and controls in place at width $width, scale $scale', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + var result = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.play, + playCheck: () => result.future, + ); + + addTearDown(updates.dispose); + await tester.pumpWidget( + MaterialApp( + theme: eink ? AppTheme.eink : AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showUpdateSettings(context, updates), + child: const Text('Open updates'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open updates')); + await tester.pumpAndSettle(); + final dialog = tester.getRect(find.byType(AlertDialog)); + final toggle = tester.getRect(find.byType(Switch)); + final check = tester.getRect(find.widgetWithText(TextButton, 'Check now')); + + void expectStableLayout() { + expect(tester.getRect(find.byType(AlertDialog)), dialog); + expect(tester.getRect(find.byType(Switch)), toggle); + expect(tester.getRect(find.widgetWithText(TextButton, 'Check now')), check); + expect(tester.takeException(), isNull); + } + + for (final fails in [true, false]) { + await tester.tap(find.text('Check now')); + await tester.pump(); + expect(find.text('Checking for updates…'), findsOneWidget); + expectStableLayout(); + await tester.pump(const Duration(milliseconds: 200)); + expectStableLayout(); + + if (fails) { + result.completeError(StateError('Offline')); + } else { + result.complete(null); + } + + await tester.pumpAndSettle(); + expect( + find.text(fails ? UpdateController.checkFailureMessage : 'Papyrus is up to date.').hitTestable(), + findsOneWidget, + ); + expectStableLayout(); + result = Completer(); + } + }); + } + for (final scale in [1.0, 2.0]) { testWidgets('notice and dialog work on a narrow e-ink screen at text scale $scale', (tester) async { tester.view.physicalSize = const Size(360, 780); From 26e742850dfeab38b767bab4f257aa1ee718ae21 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 04:20:42 +0200 Subject: [PATCH 5/7] fix: keep update dialog compact and show checking in its action --- app/lib/updates/update_controller.dart | 6 +++-- app/lib/updates/update_settings.dart | 24 +++++++------------ app/test/updates/update_settings_test.dart | 28 ++++++++++++++++------ 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart index 6a9e3f92..68681117 100644 --- a/app/lib/updates/update_controller.dart +++ b/app/lib/updates/update_controller.dart @@ -10,7 +10,7 @@ import 'package:papyrus/updates/update_release.dart'; import 'package:shared_preferences/shared_preferences.dart'; class UpdateController extends ChangeNotifier { - static const checkFailureMessage = 'Could not check for updates. Check your connection and try again.'; + static const checkFailureMessage = 'Could not check for updates. Try again.'; final SharedPreferences preferences; final Future Function() appInfo; @@ -21,6 +21,7 @@ class UpdateController extends ChangeNotifier { UpdateChannel? channel; UpdateRelease? release; bool busy = false; + bool checking = false; bool available = false; bool staged = false; bool viewingSettings = false; @@ -76,7 +77,7 @@ class UpdateController extends ChangeNotifier { } busy = true; - message = 'Checking for updates…'; + checking = true; _notify(); try { @@ -109,6 +110,7 @@ class UpdateController extends ChangeNotifier { release = null; message = checkFailureMessage; } finally { + checking = false; busy = false; _notify(); } diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index c1a8ccfe..41d3ba63 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -22,6 +22,7 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle listenable: controller, builder: (context, child) => AlertDialog( title: const Text('App updates'), + contentPadding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.sm), content: SizedBox( width: 420, child: SingleChildScrollView( @@ -29,20 +30,8 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Stack( - children: [ - // Reserve the wrapped error height so checking and retrying do not move the dialog. - const Visibility( - visible: false, - maintainSize: true, - maintainAnimation: true, - maintainState: true, - child: Text(UpdateController.checkFailureMessage), - ), - Text(controller.message), - ], - ), - const SizedBox(height: Spacing.md), + Text(controller.message), + const SizedBox(height: Spacing.sm), Theme( data: Theme.of(context).copyWith( splashFactory: NoSplash.splashFactory, @@ -95,7 +84,12 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), TextButton( onPressed: controller.busy || controller.staged ? null : controller.check, - child: const Text('Check now'), + key: const ValueKey('check-updates'), + child: IndexedStack( + index: controller.checking ? 1 : 0, + alignment: Alignment.center, + children: const [Text('Check now'), Text('Checking…')], + ), ), if (controller.available && !controller.external && !controller.staged) FilledButton( diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index 02210546..3869fd7d 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -27,7 +27,7 @@ void main() { ); addTearDown(updates.dispose); - const status = 'Could not check for updates. Check your connection and try again.'; + const status = UpdateController.checkFailureMessage; updates.message = status; final boundaryKey = GlobalKey(); @@ -110,21 +110,33 @@ void main() { await tester.tap(find.text('Open updates')); await tester.pumpAndSettle(); - final dialog = tester.getRect(find.byType(AlertDialog)); - final toggle = tester.getRect(find.byType(Switch)); - final check = tester.getRect(find.widgetWithText(TextButton, 'Check now')); + final checkButton = find.byKey(const ValueKey('check-updates')); + var dialog = tester.getRect(find.byType(AlertDialog)); + var toggle = tester.getRect(find.byType(Switch)); + var check = tester.getRect(checkButton); void expectStableLayout() { expect(tester.getRect(find.byType(AlertDialog)), dialog); expect(tester.getRect(find.byType(Switch)), toggle); - expect(tester.getRect(find.widgetWithText(TextButton, 'Check now')), check); + expect(tester.getRect(checkButton), check); expect(tester.takeException(), isNull); } for (final fails in [true, false]) { + final message = updates.message; + dialog = tester.getRect(find.byType(AlertDialog)); + toggle = tester.getRect(find.byType(Switch)); + check = tester.getRect(checkButton); + final statusBottom = tester.getBottomLeft(find.text(message)).dy; + final toggleTop = tester + .getTopLeft(find.ancestor(of: find.byType(Switch), matching: find.byType(Theme)).first) + .dy; + expect(toggleTop - statusBottom, 8); await tester.tap(find.text('Check now')); await tester.pump(); - expect(find.text('Checking for updates…'), findsOneWidget); + expect(find.text('Checking…'), findsOneWidget); + expect(find.text(message), findsOneWidget); + expect(tester.widget(checkButton).onPressed, isNull); expectStableLayout(); await tester.pump(const Duration(milliseconds: 200)); expectStableLayout(); @@ -140,7 +152,9 @@ void main() { find.text(fails ? UpdateController.checkFailureMessage : 'Papyrus is up to date.').hitTestable(), findsOneWidget, ); - expectStableLayout(); + expect(find.text('Check now'), findsOneWidget); + expect(tester.widget(checkButton).onPressed, isNotNull); + expect(tester.takeException(), isNull); result = Completer(); } }); From 01cdb3beb5a6fe8d3ac85b5671b4c5fa2c4976ae Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 04:22:43 +0200 Subject: [PATCH 6/7] fix: use filled primary action for update checks --- app/lib/updates/update_settings.dart | 2 +- app/test/updates/update_settings_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index 41d3ba63..de95f7d7 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -82,7 +82,7 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), - TextButton( + FilledButton( onPressed: controller.busy || controller.staged ? null : controller.check, key: const ValueKey('check-updates'), child: IndexedStack( diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index 3869fd7d..94d6a642 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -136,7 +136,7 @@ void main() { await tester.pump(); expect(find.text('Checking…'), findsOneWidget); expect(find.text(message), findsOneWidget); - expect(tester.widget(checkButton).onPressed, isNull); + expect(tester.widget(checkButton).onPressed, isNull); expectStableLayout(); await tester.pump(const Duration(milliseconds: 200)); expectStableLayout(); @@ -153,7 +153,7 @@ void main() { findsOneWidget, ); expect(find.text('Check now'), findsOneWidget); - expect(tester.widget(checkButton).onPressed, isNotNull); + expect(tester.widget(checkButton).onPressed, isNotNull); expect(tester.takeException(), isNull); result = Completer(); } From fec02cef696881d9ad7886fdcd9c6499c0d4b115 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Sun, 1 Nov 2026 04:46:03 +0200 Subject: [PATCH 7/7] fix: separate update prompts from update settings --- app/lib/updates/update_settings.dart | 79 ++++++++++++++++++---- app/test/updates/update_settings_test.dart | 22 +++--- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index de95f7d7..8f1faadd 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -82,19 +82,20 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle ), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), - FilledButton( - onPressed: controller.busy || controller.staged ? null : controller.check, - key: const ValueKey('check-updates'), - child: IndexedStack( - index: controller.checking ? 1 : 0, - alignment: Alignment.center, - children: const [Text('Check now'), Text('Checking…')], - ), - ), if (controller.available && !controller.external && !controller.staged) FilledButton( onPressed: controller.busy ? null : controller.install, child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), + ) + else + FilledButton( + onPressed: controller.busy || controller.staged ? null : controller.check, + key: const ValueKey('check-updates'), + child: IndexedStack( + index: controller.checking ? 1 : 0, + alignment: Alignment.center, + children: const [Text('Check now'), Text('Checking…')], + ), ), ], ), @@ -105,6 +106,38 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle } } +Future showUpdatePrompt(BuildContext context, UpdateController controller) async { + if (controller.viewingSettings) { + return false; + } + + controller.setViewingSettings(true); + + try { + return await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (context) => ListenableBuilder( + listenable: controller, + builder: (context, child) => AlertDialog( + title: const Text('App update'), + content: Text(controller.message), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Later')), + FilledButton( + onPressed: controller.busy || controller.staged ? null : controller.install, + child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), + ), + ], + ), + ), + ) ?? + false; + } finally { + controller.setViewingSettings(false); + } +} + class UpdateNotice extends StatefulWidget { final UpdateController controller; final GoRouter router; @@ -119,6 +152,26 @@ class UpdateNotice extends StatefulWidget { class _UpdateNoticeState extends State { bool _dismissed = false; + Future _viewUpdate() async { + final context = widget.router.routerDelegate.navigatorKey.currentContext!; + + if (widget.controller.external) { + await showUpdateSettings(context, widget.controller); + + if (mounted) { + setState(() => _dismissed = true); + } + + return; + } + + final dismissed = await showUpdatePrompt(context, widget.controller); + + if (mounted && dismissed) { + setState(() => _dismissed = true); + } + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -138,14 +191,10 @@ class _UpdateNoticeState extends State { SafeArea( bottom: false, child: MaterialBanner( - content: const Text('A Papyrus update is available.'), + content: const Text('Update available'), actions: [ - TextButton(onPressed: () => setState(() => _dismissed = true), child: const Text('Later')), TextButton( - onPressed: () => showUpdateSettings( - widget.router.routerDelegate.navigatorKey.currentContext!, - widget.controller, - ), + onPressed: _viewUpdate, child: const Text('View update'), ), ], diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index 94d6a642..b15bbd60 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -167,12 +167,17 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); SharedPreferences.setMockInitialValues({}); + var installs = 0; final updates = UpdateController( preferences: await SharedPreferences.getInstance(), appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), channelProvider: () async => UpdateChannel.play, playCheck: () async => 7, + installer: (_, _) async { + installs++; + return 'Follow Google Play to finish the update.'; + }, ); addTearDown(updates.dispose); final router = GoRouter( @@ -201,23 +206,24 @@ void main() { ); await updates.check(); await tester.pumpAndSettle(); - expect(find.text('A Papyrus update is available.'), findsNothing); + expect(find.text('Update available'), findsNothing); router.go('/'); await tester.pumpAndSettle(); await tester.tap(find.text('View update')); await tester.pumpAndSettle(); - expect(find.text('App updates'), findsOneWidget); + expect(find.text('App update'), findsOneWidget); expect(find.text('Update'), findsOneWidget); - await tester.ensureVisible(find.text('Check automatically')); - await tester.tap(find.text('Check automatically')); - await tester.pumpAndSettle(); - expect(updates.automatic, isFalse); + expect(find.text('Check automatically'), findsNothing); + expect(find.text('Check now'), findsNothing); + expect(updates.automatic, isTrue); expect(tester.takeException(), isNull); - await tester.tap(find.text('Close')); + await tester.tap(find.text('Update')); await tester.pumpAndSettle(); + expect(installs, 1); + expect(find.text('Follow Google Play to finish the update.'), findsOneWidget); await tester.tap(find.text('Later')); await tester.pumpAndSettle(); - expect(find.text('A Papyrus update is available.'), findsNothing); + expect(find.text('Update available'), findsNothing); }); } }