diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index c654dc72..eb2d2283 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -68,7 +68,9 @@ The package manifest requires Swift tools 6.3. CI and release builds use Swift 6 ## Install and update from a release Verify the macOS DMG against `SHA256SUMS`, open it, and drag `Talkies.app` to -`/Applications`. For a manual update, quit Talkies, open the newer DMG, and replace the existing +`/Applications`. In **Settings → General → About**, choose **Check for Updates** to compare the +installed version with the latest stable release, then **Download Update** to open its release page. +For a manual update, quit Talkies, open the newer DMG, and replace the existing `/Applications/Talkies.app` with the new copy. Model weights and preferences are stored outside the app bundle, so replacing the app does not delete them. ZIP downloads contain the same app bundle if you prefer to install without mounting a DMG. diff --git a/docs/platforms/mobile.md b/docs/platforms/mobile.md index 1e65e9d4..2e3d45d6 100644 --- a/docs/platforms/mobile.md +++ b/docs/platforms/mobile.md @@ -3,6 +3,8 @@ For model setup and offline first-run checks, see the [first-run and troubleshooting guide](first-run-and-troubleshooting.md). Download the signed Android APK from the [latest Talkies release](https://github.com/4cecoder/talkies/releases/download/latest/Talkies-Android-latest.apk). +Download the signed Android APK from the [latest Talkies release](https://github.com/4cecoder/talkies/releases/download/latest/Talkies-Android-latest.apk). Use **App updates → Check for updates** in the app to compare with the latest stable release. When one is available, **Download update** opens its GitHub release page; download and open the APK to let Android confirm the install. Android retains app data when the APK has the same signing key. + ## Android (primary): native Kotlin with local Phonon-2 and Whisper Talkies Android is built natively in [`mobile/android/`](../../mobile/android/), using Kotlin and Jetpack Compose. Stable transcript and cleanup contracts, the `LocalSpeechRuntime` recognizer interface, and the speech-engine selection and fallback policy (`SpeechEngineSelection`, `SpeechRecognizerChain`) live in the JVM-only `:core` module. The separate Android `:inference` library owns verified model stores, JNI, CrispASR, whisper.cpp, llama.cpp, and local inference. The `:app` module owns Compose UI, the speech-engine setting, microphone capture, and input-method lifecycle. It records 16 kHz mono audio in memory for at most five minutes per dictation, runs a downloaded speech model through JNI, and presents an editable transcript with explicit copy feedback. At the recording limit, it transcribes the captured audio automatically. It does not use Android's vendor `SpeechRecognizer` or send audio to a recognition service. diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index 0cdf515a..07f83037 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -6,6 +6,8 @@ Talkies is a free, MIT-licensed Windows dictation app. It records audio locally, Download the current packages from [Talkies releases](https://github.com/4cecoder/talkies/releases). The rolling `latest` release is a prerelease, and public Windows packages are currently unsigned. Check the release notes before installing. +In the app, use **Check for updates** to compare the installed version with the latest stable release. If an update is available, **Download update** opens the release page; quit Talkies and run the newer setup installer to apply it. Settings and downloaded models remain in place. + To verify the setup executable, download `SHA256SUMS` from the same release and compare the Windows setup entry: ```powershell diff --git a/mac/Sources/Talkies/ReleaseUpdates.swift b/mac/Sources/Talkies/ReleaseUpdates.swift new file mode 100644 index 00000000..ba94d3de --- /dev/null +++ b/mac/Sources/Talkies/ReleaseUpdates.swift @@ -0,0 +1,44 @@ +import Foundation + +struct ReleaseUpdate: Sendable { + let version: String + let url: URL +} + +enum ReleaseUpdates { + static let latestURL = URL(string: "https://github.com/4cecoder/talkies/releases/latest")! + private static let apiURL = URL(string: "https://api.github.com/repos/4cecoder/talkies/releases/latest")! + + static func check(currentVersion: String) async throws -> ReleaseUpdate? { + var request = URLRequest(url: apiURL) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("Talkies-macOS", forHTTPHeaderField: "User-Agent") + request.timeoutInterval = 8 + let (data, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let metadata = try JSONSerialization.jsonObject(with: data) as? [String: Any], + metadata["draft"] as? Bool != true, + metadata["prerelease"] as? Bool != true, + let tag = metadata["tag_name"] as? String, + let page = metadata["html_url"] as? String, + let url = URL(string: page), url.host == "github.com" else { + throw URLError(.badServerResponse) + } + let version = String(tag.drop(while: { $0 == "v" })) + return compare(version, currentVersion) > 0 ? ReleaseUpdate(version: version, url: url) : nil + } + + static func compare(_ lhs: String, _ rhs: String) -> Int { + let a = lhs.split(separator: ".").map { Int($0) ?? 0 } + let b = rhs.split(separator: ".").map { Int($0) ?? 0 } + for index in 0.. Int { self == other ? 0 : (self < other ? -1 : 1) } +} diff --git a/mac/Sources/Talkies/SettingsView.swift b/mac/Sources/Talkies/SettingsView.swift index 5d2077c0..2ac5bc78 100644 --- a/mac/Sources/Talkies/SettingsView.swift +++ b/mac/Sources/Talkies/SettingsView.swift @@ -179,6 +179,10 @@ struct SettingsView: View { struct GeneralSettingsView: View { @ObservedObject private var settingsService = SettingsService.shared @State private var confirmClearHistory = false + @State private var updateCheckBusy = false + @State private var updateStatus = "Check for the latest Talkies release." + @State private var availableUpdate: ReleaseUpdate? + @Environment(\.openURL) private var openURL private var threshold: Binding { Binding( @@ -422,10 +426,31 @@ struct GeneralSettingsView: View { HStack { Text("Version:") Spacer() - Text("1.0.0") + Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") .foregroundColor(.secondary) } + Text(updateStatus).font(.caption).foregroundStyle(.secondary) + HStack { + Button(updateCheckBusy ? "Checking…" : "Check for Updates") { + updateCheckBusy = true + Task { + let installed = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + do { + availableUpdate = try await ReleaseUpdates.check(currentVersion: installed) + updateStatus = availableUpdate.map { "Talkies \($0.version) is ready to download." } ?? "Talkies is up to date." + } catch { + updateStatus = "Could not check for updates. Check your connection and try again." + } + updateCheckBusy = false + } + } + .disabled(updateCheckBusy) + if let availableUpdate { + Button("Download Update") { openURL(availableUpdate.url) } + } + } + HStack { Text("Transcription:") Spacer() diff --git a/mac/Tests/TalkiesAppTests/ReleaseUpdatesTests.swift b/mac/Tests/TalkiesAppTests/ReleaseUpdatesTests.swift new file mode 100644 index 00000000..72359b4b --- /dev/null +++ b/mac/Tests/TalkiesAppTests/ReleaseUpdatesTests.swift @@ -0,0 +1,10 @@ +import XCTest +@testable import Talkies + +final class ReleaseUpdatesTests: XCTestCase { + func testComparesNumericVersionComponents() { + XCTAssertEqual(ReleaseUpdates.compare("0.1.10", "0.1.9"), 1) + XCTAssertEqual(ReleaseUpdates.compare("1.0.0", "1.0.1"), -1) + XCTAssertEqual(ReleaseUpdates.compare("1.2", "1.2.0"), 0) + } +} diff --git a/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt b/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt index bbc33bc8..e79f5116 100644 --- a/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt +++ b/mobile/android/app/src/main/java/com/talkies/android/MainActivity.kt @@ -106,6 +106,9 @@ class MainActivity : ComponentActivity() { private var recording by mutableStateOf(false) private var transcribing by mutableStateOf(false) private var historyEnabled by mutableStateOf(true) + private var updateCheckBusy by mutableStateOf(false) + private var updateStatus by mutableStateOf("Check for the latest Talkies release.") + private var availableRelease by mutableStateOf(null) private var showHistory by mutableStateOf(false) private var showRemoteCompanion by mutableStateOf(false) private var confirmClearHistory by mutableStateOf(false) @@ -450,6 +453,35 @@ class MainActivity : ComponentActivity() { OutlinedButton(onClick = { showHistory = true }) { Text("View history") } OutlinedButton(onClick = { confirmClearHistory = true }) { Text("Clear history") } } + Text("App updates", style = MaterialTheme.typography.titleMedium) + Text(updateStatus, style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + enabled = !updateCheckBusy, + onClick = { + updateCheckBusy = true + lifecycleScope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + ReleaseUpdates.check(packageManager.getPackageInfo(packageName, 0).versionName ?: "0.0.0") + } + } + availableRelease = result.getOrNull() + updateStatus = when { + result.isFailure -> "Could not check for updates. Check your connection and try again." + availableRelease != null -> "Talkies ${availableRelease!!.version} is ready to download." + else -> "Talkies is up to date." + } + updateCheckBusy = false + } + } + ) { Text(if (updateCheckBusy) "Checking…" else "Check for updates") } + if (availableRelease != null) { + Button(onClick = { + startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(availableRelease!!.url))) + }) { Text("Download update") } + } + } if (confirmClearHistory) { AlertDialog( onDismissRequest = { confirmClearHistory = false }, diff --git a/mobile/android/app/src/main/java/com/talkies/android/ReleaseUpdates.kt b/mobile/android/app/src/main/java/com/talkies/android/ReleaseUpdates.kt new file mode 100644 index 00000000..3d86bc29 --- /dev/null +++ b/mobile/android/app/src/main/java/com/talkies/android/ReleaseUpdates.kt @@ -0,0 +1,44 @@ +package com.talkies.android + +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +internal data class ReleaseUpdate(val version: String, val url: String) + +internal object ReleaseUpdates { + const val latestReleaseUrl = "https://github.com/4cecoder/talkies/releases/latest" + private const val apiUrl = "https://api.github.com/repos/4cecoder/talkies/releases/latest" + + fun check(currentVersion: String): ReleaseUpdate? { + val connection = URL(apiUrl).openConnection() as HttpURLConnection + try { + connection.connectTimeout = 5_000 + connection.readTimeout = 5_000 + connection.setRequestProperty("Accept", "application/vnd.github+json") + connection.setRequestProperty("User-Agent", "Talkies-Android") + if (connection.responseCode !in 200..299) error("Release check failed (${connection.responseCode})") + val release = JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) + if (release.optBoolean("draft", false) || release.optBoolean("prerelease", false)) return null + val version = release.optString("tag_name").removePrefix("v") + val releaseUrl = release.optString("html_url", latestReleaseUrl) + require(version.isNotBlank() && releaseUrl.startsWith("https://github.com/4cecoder/talkies/releases/")) { + "Invalid release metadata" + } + return if (compareVersions(version, currentVersion) > 0) ReleaseUpdate(version, releaseUrl) else null + } finally { + connection.disconnect() + } + } + + internal fun compareVersions(left: String, right: String): Int { + fun parts(value: String) = value.split('.').map { it.toIntOrNull() ?: 0 } + val a = parts(left) + val b = parts(right) + for (index in 0 until maxOf(a.size, b.size)) { + val comparison = (a.getOrElse(index) { 0 }).compareTo(b.getOrElse(index) { 0 }) + if (comparison != 0) return comparison + } + return 0 + } +} diff --git a/mobile/android/app/src/test/java/com/talkies/android/ReleaseUpdatesTest.kt b/mobile/android/app/src/test/java/com/talkies/android/ReleaseUpdatesTest.kt new file mode 100644 index 00000000..5fb2ad11 --- /dev/null +++ b/mobile/android/app/src/test/java/com/talkies/android/ReleaseUpdatesTest.kt @@ -0,0 +1,12 @@ +package com.talkies.android + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReleaseUpdatesTest { + @Test fun comparesNumericVersionsRatherThanStrings() { + assertEquals(1, ReleaseUpdates.compareVersions("0.1.10", "0.1.9")) + assertEquals(-1, ReleaseUpdates.compareVersions("1.0.0", "1.0.1")) + assertEquals(0, ReleaseUpdates.compareVersions("1.2", "1.2.0")) + } +} diff --git a/windows/Talkies.Windows.Tests/Services/ReleaseUpdatesTests.cs b/windows/Talkies.Windows.Tests/Services/ReleaseUpdatesTests.cs new file mode 100644 index 00000000..9bd0dc81 --- /dev/null +++ b/windows/Talkies.Windows.Tests/Services/ReleaseUpdatesTests.cs @@ -0,0 +1,14 @@ +using Talkies.Windows.Services; +using Xunit; + +namespace Talkies.Windows.Tests.Services; + +public class ReleaseUpdatesTests +{ + [Theory] + [InlineData("0.1.10", "0.1.9", 1)] + [InlineData("1.0.0", "1.0.1", -1)] + [InlineData("1.2", "1.2.0", 0)] + public void Compare_UsesNumericComponents(string left, string right, int expected) => + Assert.Equal(expected, ReleaseUpdates.Compare(left, right)); +} diff --git a/windows/Talkies.Windows/MainWindow.xaml b/windows/Talkies.Windows/MainWindow.xaml index f26896e7..724808df 100644 --- a/windows/Talkies.Windows/MainWindow.xaml +++ b/windows/Talkies.Windows/MainWindow.xaml @@ -24,6 +24,9 @@ +