diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index abe917dc..d8a72b6b 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -51,6 +51,10 @@ jobs:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # With the signing key set, tauri-action signs updater artifacts and
+ # publishes latest.json on the release — the in-app updater feed.
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
projectPath: desktop
tagName: ${{ github.ref_name }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 98bddeca..b51555ae 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -77,6 +77,9 @@ jobs:
- name: Build desktop bundles
working-directory: desktop
+ env:
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: cargo tauri build
- name: Publish bundles to GitHub release
@@ -92,8 +95,9 @@ jobs:
done
gh release view "$TAG" >/dev/null
FILES=$(find desktop/target/release/bundle -type f \
- \( -name '*.deb' -o -name '*.AppImage' -o -name '*.msi' \
- -o -name '*.exe' -o -name '*.dmg' -o -name '*.app.tar.gz' \))
+ \( -name '*.deb' -o -name '*.AppImage' -o -name '*.AppImage.tar.gz' \
+ -o -name '*.msi' -o -name '*.exe' -o -name '*.dmg' \
+ -o -name '*.app.tar.gz' -o -name '*.sig' \))
[ -n "$FILES" ]
echo "$FILES" | while read -r f; do
name="cloudreve-desktop-$(basename "$f")"
@@ -101,6 +105,23 @@ jobs:
gh release upload "$TAG" "$name" --clobber
done
+ updater-manifest:
+ needs: desktop
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build and publish latest.json
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ TAG: ${{ github.ref_name }}
+ run: |
+ set -e
+ mkdir -p sigs
+ gh release download "$TAG" -p '*.sig' -D sigs --clobber
+ python3 desktop/scripts/gen_latest_json.py "$TAG" sigs latest.json
+ gh release upload "$TAG" latest.json --clobber
+
android:
runs-on: ubuntu-latest
defaults:
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index e0d60583..464cd00a 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -7,14 +7,14 @@ plugins {
android {
namespace = "org.cloudreve.android"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
applicationId = "com.tdvorak.cloudreve"
minSdk = 28
- targetSdk = 35
- versionCode = 1
- versionName = "1.0.0"
+ targetSdk = 36
+ versionCode = 2
+ versionName = "1.1.0"
}
signingConfigs {
@@ -94,4 +94,9 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("io.coil-kt:coil-compose:2.7.0")
+
+ // Play in-app updates — no-op on sideloaded/non-Play installs
+ implementation("com.google.android.play:app-update-ktx:2.1.0")
+ // ActivityResult API (update flow launcher) needs fragment >= 1.3.0
+ implementation("androidx.fragment:fragment-ktx:1.8.5")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5a4dba39..2b086538 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,7 +4,7 @@
+ android:targetSdkVersion="36" />
diff --git a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt
index 0d8b1b73..dad53090 100644
--- a/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt
+++ b/android/app/src/main/java/org/cloudreve/android/CloudreveApp.kt
@@ -1,6 +1,7 @@
package org.cloudreve.android
import android.app.Application
+import com.google.android.play.core.appupdate.AppUpdateInfo
import kotlinx.coroutines.flow.MutableStateFlow
import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.data.CameraUploadSettings
@@ -16,6 +17,9 @@ class CloudreveApp : Application() {
data class OAuthCallback(val code: String, val state: String)
val oauthCallback = MutableStateFlow(null)
+ /** Pending Play in-app update, set by MainActivity's update check. */
+ val playUpdateAvailable = MutableStateFlow(null)
+
lateinit var sessionManager: SessionManager
private set
lateinit var apiClient: ApiClient
diff --git a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
index e1e156b3..8cd8d5c4 100644
--- a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
+++ b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt
@@ -6,6 +6,11 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+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 androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -18,6 +23,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import kotlinx.coroutines.launch
import org.cloudreve.android.api.RefreshTokenRequest
import org.cloudreve.android.ui.files.FilesScreen
@@ -30,6 +38,9 @@ import org.cloudreve.android.work.UploadWorker
class MainActivity : ComponentActivity() {
+ private val updateLauncher =
+ registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -37,6 +48,39 @@ class MainActivity : ComponentActivity() {
setContent { App() }
}
+ override fun onResume() {
+ super.onResume()
+ checkForPlayUpdate()
+ }
+
+ // Ask Play for an update; if one is available and this install came from
+ // Play, surface the in-app prompt once per version, then hand off to the
+ // official IMMEDIATE update sheet.
+ private fun checkForPlayUpdate() {
+ val manager = AppUpdateManagerFactory.create(this)
+ manager.appUpdateInfo.addOnSuccessListener { info ->
+ if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE ||
+ !info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
+ ) return@addOnSuccessListener
+ val version = info.availableVersionCode()
+ val prefs = getSharedPreferences("updates", MODE_PRIVATE)
+ if (prefs.getInt("prompted_version", -1) == version) return@addOnSuccessListener
+ prefs.edit().putInt("prompted_version", version).apply()
+ (application as CloudreveApp).playUpdateAvailable.value = info
+ }
+ }
+
+ fun launchPlayUpdate() {
+ val info = (application as CloudreveApp).playUpdateAvailable.value ?: return
+ runCatching {
+ AppUpdateManagerFactory.create(this).startUpdateFlowForResult(
+ info,
+ updateLauncher,
+ AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
+ )
+ }
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleShareIntent(intent)
@@ -103,6 +147,25 @@ fun App() {
}
CloudreveTheme {
+ val updateInfo by app.playUpdateAvailable.collectAsState()
+ if (updateInfo != null) {
+ AlertDialog(
+ onDismissRequest = { app.playUpdateAvailable.value = null },
+ title = { Text("Update available") },
+ text = { Text("A new version of Cloudreve Mobile is available on Google Play.") },
+ confirmButton = {
+ TextButton(onClick = {
+ app.playUpdateAvailable.value = null
+ (context as? MainActivity)?.launchPlayUpdate()
+ }) { Text("Update") }
+ },
+ dismissButton = {
+ TextButton(onClick = { app.playUpdateAvailable.value = null }) {
+ Text("Later")
+ }
+ },
+ )
+ }
when (loggedIn) {
null -> Unit // session store still loading; keep frame empty
false -> NavHost(navController = nav, startDestination = "login") {
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 70dfb636..e795862a 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id("com.android.application") version "8.7.3" apply false
+ id("com.android.application") version "8.9.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
diff --git a/desktop/crates/cloudreve-sync/src/config.rs b/desktop/crates/cloudreve-sync/src/config.rs
index 9fbf4fa1..37ea40e8 100644
--- a/desktop/crates/cloudreve-sync/src/config.rs
+++ b/desktop/crates/cloudreve-sync/src/config.rs
@@ -66,6 +66,9 @@ pub struct AppConfig {
pub hide_tray_icon: bool,
/// Language/locale setting (e.g., "en-US", "zh-CN"). None means use system default.
pub language: Option,
+ /// Last app-update version the user was already prompted about. A new
+ /// version still prompts once; dismissing snoozes until the next release.
+ pub prompted_update_version: Option,
}
impl Default for AppConfig {
@@ -81,6 +84,7 @@ impl Default for AppConfig {
sync_delay_seconds: 0,
hide_tray_icon: false,
language: None,
+ prompted_update_version: None,
}
}
}
diff --git a/desktop/scripts/gen_latest_json.py b/desktop/scripts/gen_latest_json.py
new file mode 100644
index 00000000..3aa79024
--- /dev/null
+++ b/desktop/scripts/gen_latest_json.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""Generate the Tauri updater manifest (latest.json) from release .sig assets.
+
+Usage: gen_latest_json.py
+
+Scans for `*.sig` files downloaded from the GitHub release, maps each
+to its updater platform, and emits the manifest the tauri-plugin-updater
+expects. Asset URLs point at the release's renamed `cloudreve-desktop-*`
+assets. The version is read from desktop/src-tauri/tauri.conf.json — desktop
+has its own semver, independent of the repo's server release tags.
+"""
+import json
+import os
+import sys
+
+PLATFORM_MAP = [
+ ("_x64-setup.exe", "windows-x86_64"),
+ ("_arm64-setup.exe", "windows-aarch64"),
+ ("_aarch64.app.tar.gz", "darwin-aarch64"),
+ ("_x64.app.tar.gz", "darwin-x86_64"),
+ ("amd64.AppImage.tar.gz", "linux-x86_64"),
+ ("aarch64.AppImage.tar.gz", "linux-aarch64"),
+]
+
+REPO = "Dvorinka/cloudreve"
+
+
+def main() -> int:
+ tag, sig_dir, out_path = sys.argv[1], sys.argv[2], sys.argv[3]
+
+ conf = json.load(open("desktop/src-tauri/tauri.conf.json"))
+ version = conf["version"]
+
+ platforms = {}
+ for fname in sorted(os.listdir(sig_dir)):
+ if not fname.endswith(".sig"):
+ continue
+ # Release assets are renamed to `cloudreve-desktop-` at
+ # upload time, so the downloaded .sig name already is the asset name.
+ asset = fname[: -len(".sig")]
+ for marker, platform in PLATFORM_MAP:
+ if marker in asset:
+ signature = open(os.path.join(sig_dir, fname)).read().strip()
+ if not signature:
+ print(f"warning: empty signature in {fname}, skipping")
+ break
+ platforms[platform] = {
+ "signature": signature,
+ "url": (
+ f"https://github.com/{REPO}/releases/download/{tag}/"
+ f"{asset}"
+ ),
+ }
+ break
+
+ if not platforms:
+ print("error: no updater artifacts found", file=sys.stderr)
+ return 1
+
+ manifest = {
+ "version": version,
+ "notes": f"Cloudreve Desktop {version}",
+ "pub_date": "",
+ "platforms": platforms,
+ }
+ with open(out_path, "w") as f:
+ json.dump(manifest, f, indent=2)
+ print(f"latest.json: version={version}, platforms={sorted(platforms)}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 4787d51d..10f5ae27 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cloudreve-desktop"
-version = "0.2.0"
+version = "0.3.0"
description = "Cloudreve Desktop Sync Client"
authors = ["cloudreve"]
license = ""
@@ -44,6 +44,7 @@ tauri-plugin-deep-link = "2.4.9"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-prevent-default = "4"
tauri-plugin-os = "2"
+tauri-plugin-updater = "2"
tauri-plugin-frame = "1.1.6"
[target.'cfg(windows)'.dependencies.windows]
diff --git a/desktop/src-tauri/src/commands.rs b/desktop/src-tauri/src/commands.rs
index dfdfe0c6..bb489d6d 100644
--- a/desktop/src-tauri/src/commands.rs
+++ b/desktop/src-tauri/src/commands.rs
@@ -1471,3 +1471,161 @@ pub async fn open_log_folder() -> CommandResult<()> {
showfile::show_path_in_file_manager(format!("{}\\", log_dir.display()));
Ok(())
}
+
+/// Metadata about an available app update, returned to the frontend.
+#[derive(serde::Serialize)]
+pub struct UpdateInfo {
+ pub version: String,
+ pub current_version: String,
+ pub notes: Option,
+ pub date: Option,
+}
+
+/// Progress payload emitted on `update-progress` while an update downloads.
+#[derive(serde::Serialize, Clone)]
+struct UpdateProgress {
+ downloaded: u64,
+ total: Option,
+}
+
+/// Check the configured update endpoint (GitHub releases `latest.json`) for a
+/// newer desktop build. Returns `None` when already up to date or when no
+/// signed updater artifact exists for this platform.
+#[tauri::command]
+pub async fn check_update(app: AppHandle) -> CommandResult