OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / d8746fd

android: self-update from the forge -- step 5
Checks release-index on start and every 6 h, offers the newest complete android-v<semver> release above every screen, downloads it to the cache, checks it against the published .sha256 and hands it to Android's installer through a FileProvider limited to cache/updates/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d8746fd9188f4e68c2842e7496f5f78c041a0b2e
by plankatron <fleeky@gmail.com> · 2026-10-02T17:04:46.000Z

Changed files (9) +347 -5

android/android/app/src/main/AndroidManifest.xml +9 -0
……@@ -3,6 +3,8 @@
33 <!-- INTERNET: TCP to the lamps on the LAN. ACCESS_NETWORK_STATE: NsdManager (step 3). -->
44 <uses-permission android:name="android.permission.INTERNET" />
55 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6 <!-- Self-update: hand a downloaded APK to the system installer (UpdaterModule.kt). -->
7 <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
68
79 <application
810 android:name=".MainApplication"
……@@ -20,5 +22,12 @@
2022 <category android:name="android.intent.category.LAUNCHER" />
2123 </intent-filter>
2224 </activity>
25 <provider
26 android:name="androidx.core.content.FileProvider"
27 android:authorities="${applicationId}.updates"
28 android:exported="false"
29 android:grantUriPermissions="true">
30 <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/update_paths" />
31 </provider>
2332 </application>
2433</manifest>
android/android/app/src/main/java/org/circusautomatic/cthuloid/CthuloidPackage.kt +1 -1
……@@ -8,7 +8,7 @@ import com.facebook.react.uimanager.ViewManager
88/** The app's own native modules. Autolinking cannot find these: they are not a node_modules package. */
99class CthuloidPackage : ReactPackage {
1010 override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
11 listOf(LampFinderModule(reactContext))
11 listOf(LampFinderModule(reactContext), UpdaterModule(reactContext))
1212
1313 override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
1414 emptyList()
android/android/app/src/main/java/org/circusautomatic/cthuloid/UpdaterModule.kt +77 -0
……@@ -0,0 +1,77 @@
1package org.circusautomatic.cthuloid
2
3import android.content.Intent
4import android.net.Uri
5import android.provider.Settings
6import androidx.core.content.FileProvider
7import com.facebook.react.bridge.Promise
8import com.facebook.react.bridge.ReactApplicationContext
9import com.facebook.react.bridge.ReactContextBaseJavaModule
10import com.facebook.react.bridge.ReactMethod
11import java.io.File
12
13/**
14 * The native half of the self-update (DESIGN-android.md §6). JS finds, downloads and checks the
15 * APK (app/updater.js); this module only says which version is installed and hands a downloaded
16 * file to Android's package installer. The user always confirms the install, and Android refuses
17 * an APK signed with any other key than this app's.
18 *
19 * Files are shared through FileProvider (res/xml/update_paths.xml): only the cache's updates/ dir.
20 */
21class UpdaterModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {
22
23 companion object {
24 const val NAME = "CthuloidUpdater"
25 const val DIR = "updates"
26 }
27
28 override fun getName(): String = NAME
29
30 override fun getConstants(): Map<String, Any> {
31 val info = ctx.packageManager.getPackageInfo(ctx.packageName, 0)
32 return mapOf(
33 "versionName" to (info.versionName ?: ""),
34 "updatesDir" to File(ctx.cacheDir, DIR).absolutePath
35 )
36 }
37
38 /** Has the user allowed "install unknown apps" for this app? */
39 @ReactMethod
40 fun canInstall(promise: Promise) {
41 promise.resolve(ctx.packageManager.canRequestPackageInstalls())
42 }
43
44 /** Open this app's "install unknown apps" switch in system settings. */
45 @ReactMethod
46 fun openInstallSettings(promise: Promise) {
47 try {
48 val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + ctx.packageName))
49 .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
50 ctx.startActivity(intent)
51 promise.resolve(null)
52 } catch (e: Exception) {
53 promise.reject("E_SETTINGS", e.message, e)
54 }
55 }
56
57 /** Hand a checked APK under the updates dir to the system installer. */
58 @ReactMethod
59 fun install(path: String, promise: Promise) {
60 try {
61 val dir = File(ctx.cacheDir, DIR).canonicalFile
62 val file = File(path).canonicalFile
63 if (file.parentFile != dir || !file.isFile) {
64 promise.reject("E_PATH", "not a downloaded update: $path")
65 return
66 }
67 val uri = FileProvider.getUriForFile(ctx, ctx.packageName + ".updates", file)
68 val intent = Intent(Intent.ACTION_VIEW)
69 .setDataAndType(uri, "application/vnd.android.package-archive")
70 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
71 ctx.startActivity(intent)
72 promise.resolve(null)
73 } catch (e: Exception) {
74 promise.reject("E_INSTALL", e.message, e)
75 }
76 }
77}
android/android/app/src/main/res/xml/update_paths.xml +6 -0
……@@ -0,0 +1,6 @@
1<?xml version="1.0" encoding="utf-8"?>
2<!-- Self-update: the installer may read downloaded APKs from the cache's updates/ dir, nothing else
3 (UpdaterModule.kt). -->
4<paths>
5 <cache-path name="updates" path="updates/" />
6</paths>
android/app/App.jsx +79 -3
……@@ -5,6 +5,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react'
55import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable, AppState, DeviceEventEmitter, NativeModules } from 'react-native'
66import { WebView } from 'react-native-webview'
77import { startWorklet } from './worklet-host'
8import * as updater from './updater'
89
910// The lamp finder (LampFinderModule.kt, NsdManager) -> the worklet's discovery.
1011// Browses only while the app is on screen: in the background the worklet is
……@@ -27,6 +28,79 @@ function Center ({ children }) {
2728}
2829
2930export default function App () {
31 return (
32 <SafeAreaView style={s.fill}>
33 <UpdateBanner />
34 <Main />
35 </SafeAreaView>
36 )
37}
38
39// Self-update (DESIGN-android.md §6): checks the forge on start and every 6 h;
40// shows a strip only when a newer complete release exists. Above every screen,
41// so a build whose controller will not start can still update itself.
42const CHECK_EVERY = 6 * 60 * 60 * 1000
43
44function UpdateBanner () {
45 const [u, setU] = useState(null) // { version, apkUrl, shaUrl }
46 const [phase, setPhase] = useState('available')
47 const [note, setNote] = useState('')
48 const busy = useRef(false)
49 const path = useRef(null) // checked APK on disk, for u.version only
50
51 useEffect(() => {
52 const run = () => {
53 if (busy.current) return
54 updater.check()
55 .then((next) => {
56 if (busy.current) return
57 setU((prev) => {
58 if (!prev || !next || prev.version !== next.version) { path.current = null; setPhase('available'); setNote('') }
59 return next
60 })
61 })
62 .catch((e) => console.warn('update check failed: ' + e.message)) // not 'up to date'
63 }
64 run()
65 const t = setInterval(run, CHECK_EVERY)
66 return () => clearInterval(t)
67 }, [])
68
69 const go = useCallback(async () => {
70 if (busy.current) return
71 busy.current = true
72 try {
73 if (!path.current) {
74 setPhase('downloading'); setNote('0 %')
75 path.current = await updater.download(u, (f) => setNote(Math.round(f * 100) + ' %'))
76 }
77 const opened = await updater.install(path.current)
78 setPhase(opened ? 'installing' : 'permission')
79 setNote('')
80 } catch (e) {
81 path.current = null
82 setPhase('error'); setNote(e.message)
83 } finally {
84 busy.current = false
85 }
86 }, [u])
87
88 if (!u) return null
89 const label = {
90 available: 'Update ' + u.version + ' available — Install',
91 downloading: 'Downloading ' + u.version + '… ' + note,
92 permission: 'Allow "Install unknown apps" for Cthuloid Lights, then tap here',
93 installing: 'Installing ' + u.version + ' — tap if the installer closed',
94 error: 'Update failed: ' + note + ' — tap to retry'
95 }[phase]
96 return (
97 <Pressable style={s.banner} onPress={go} disabled={phase === 'downloading'}>
98 <Text style={s.bannerText}>{label}</Text>
99 </Pressable>
100 )
101}
102
103function Main () {
30104 const [url, setUrl] = useState(null)
31105 const [error, setError] = useState(null)
32106 const [stage, setStage] = useState('starting…')
……@@ -87,7 +161,7 @@ function Page ({ url }) {
87161 }
88162
89163 return (
90 <SafeAreaView style={s.fill}>
164 <View style={s.fill}>
91165 <WebView
92166 ref={ref}
93167 source={{ uri: url }}
……@@ -100,7 +174,7 @@ function Page ({ url }) {
100174 domStorageEnabled
101175 style={s.fill}
102176 />
103 </SafeAreaView>
177 </View>
104178 )
105179}
106180
……@@ -109,5 +183,7 @@ const s = StyleSheet.create({
109183 center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
110184 dim: { opacity: 0.6, marginTop: 12, textAlign: 'center' },
111185 err: { color: '#c33', textAlign: 'center' },
112 retry: { marginTop: 16, textDecorationLine: 'underline' }
186 retry: { marginTop: 16, textDecorationLine: 'underline' },
187 banner: { backgroundColor: '#2d5fd0', paddingVertical: 10, paddingHorizontal: 16 },
188 bannerText: { color: '#fff', textAlign: 'center' }
113189})
android/app/update-pick.js +51 -0
……@@ -0,0 +1,51 @@
1// Which update to offer, from the forge's release index (DESIGN-android.md §6).
2// Pure, no React Native: tested under Bare (test/update-pick.test.js).
3
4const TAG = /^android-v(\d+\.\d+\.\d+)$/
5const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/
6const APK = 'dist/cthuloid-lights.apk'
7const SHA = APK + '.sha256'
8
9export function compareSemver (a, b) {
10 const x = SEMVER.exec(a)
11 const y = SEMVER.exec(b)
12 for (let i = 1; i <= 3; i++) {
13 const d = Number(x[i]) - Number(y[i])
14 if (d !== 0) return d
15 }
16 return 0
17}
18
19// files: release-index rows { repo, version, path }; path is
20// '<repo>/versions/<version>/<file>', served at <base>/releases/<repo>/<version>/<file>.
21// Returns { version, apkUrl, shaUrl } for the newest COMPLETE release newer than
22// `installed`, else null. Complete = both the APK and its .sha256 are listed: the
23// forge backends replicate file by file, and a half-copied release must not be offered.
24export function pickUpdate (files, installed, base) {
25 if (typeof installed !== 'string' || !SEMVER.test(installed)) return null
26 const seen = new Map() // semver -> Set of files
27 for (const f of files) {
28 if (!f || f.repo !== 'cthuloid') continue
29 const m = TAG.exec(f.version)
30 if (!m) continue
31 const prefix = 'cthuloid/versions/' + f.version + '/'
32 if (typeof f.path !== 'string' || !f.path.startsWith(prefix)) continue
33 if (!seen.has(m[1])) seen.set(m[1], new Set())
34 seen.get(m[1]).add(f.path.slice(prefix.length))
35 }
36 let best = null
37 for (const [v, have] of seen) {
38 if (!have.has(APK) || !have.has(SHA)) continue
39 if (compareSemver(v, installed) <= 0) continue
40 if (best === null || compareSemver(v, best) > 0) best = v
41 }
42 if (best === null) return null
43 const dir = base + '/releases/cthuloid/android-v' + best + '/'
44 return { version: best, apkUrl: dir + APK, shaUrl: dir + SHA }
45}
46
47// The .sha256 file is `sha256sum` output: '<hex> <name>\n'.
48export function parseSha256 (text) {
49 const m = /^([0-9a-fA-F]{64})(\s|$)/.exec(String(text || '').trim())
50 return m ? m[1].toLowerCase() : null
51}
android/app/updater.js +66 -0
……@@ -0,0 +1,66 @@
1// Self-update from the forge (DESIGN-android.md §6). Android's own HTTPS (fetch,
2// react-native-fs), never the worklet: Bare on Android has no DNS.
3// check() -> { version, apkUrl, shaUrl } | null
4// download(u, p) -> path of an APK whose SHA-256 matched the published .sha256
5// install(path) -> asks for "install unknown apps" first if needed; Android asks the user
6import { NativeModules } from 'react-native'
7import RNFS from 'react-native-fs'
8import { pickUpdate, parseSha256 } from './update-pick'
9
10export const FORGE = 'https://7rnx.net/git'
11const native = NativeModules.CthuloidUpdater
12
13export const installedVersion = native ? native.versionName : null
14
15export async function check () {
16 if (!native) return null
17 // release-index is the anonymous-allowed listing; the other one answers 403 to a phone.
18 const res = await fetch(FORGE + '/rpc/release-index', {
19 method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}'
20 })
21 if (!res.ok) throw new Error('release index: HTTP ' + res.status)
22 const body = await res.json()
23 const files = (body && body.result && body.result.files) || []
24 return pickUpdate(files, installedVersion, FORGE)
25}
26
27export async function download (u, onProgress) {
28 const dir = native.updatesDir
29 await RNFS.mkdir(dir)
30 // One update on disk at a time: earlier downloads (finished or partial) go.
31 for (const f of await RNFS.readDir(dir)) await RNFS.unlink(f.path).catch(() => {})
32
33 const shaRes = await fetch(u.shaUrl)
34 if (!shaRes.ok) throw new Error('checksum: HTTP ' + shaRes.status)
35 const want = parseSha256(await shaRes.text())
36 if (!want) throw new Error('checksum file is not a SHA-256')
37
38 const path = dir + '/cthuloid-lights-' + u.version + '.apk'
39 const job = RNFS.downloadFile({
40 fromUrl: u.apkUrl,
41 toFile: path,
42 progressDivider: 2,
43 progress: (p) => { if (onProgress && p.contentLength > 0) onProgress(p.bytesWritten / p.contentLength) }
44 })
45 const r = await job.promise
46 if (r.statusCode !== 200) {
47 await RNFS.unlink(path).catch(() => {})
48 throw new Error('download: HTTP ' + r.statusCode)
49 }
50 const got = (await RNFS.hash(path, 'sha256')).toLowerCase()
51 if (got !== want) {
52 await RNFS.unlink(path).catch(() => {})
53 throw new Error('download corrupted (checksum mismatch), try again')
54 }
55 return path
56}
57
58// true = the installer is open; false = sent to settings to allow installs first.
59export async function install (path) {
60 if (!(await native.canInstall())) {
61 await native.openInstallSettings()
62 return false
63 }
64 await native.install(path)
65 return true
66}
android/test/update-pick.test.js +57 -0
……@@ -0,0 +1,57 @@
1import test from 'brittle'
2import { pickUpdate, compareSemver, parseSha256 } from '../app/update-pick.js'
3
4const BASE = 'https://7rnx.net/git'
5const row = (version, file, repo = 'cthuloid') => ({ repo, version, path: repo + '/versions/' + version + '/' + file })
6const release = (v) => [row('android-v' + v, 'dist/cthuloid-lights.apk'), row('android-v' + v, 'dist/cthuloid-lights.apk.sha256')]
7
8test('newest complete release by semver, not by list position', (t) => {
9 // The forge lists newest first; 0.10.0 > 0.9.0 numerically, not as text.
10 const files = [...release('0.9.0'), ...release('0.10.0'), ...release('0.2.0')]
11 const u = pickUpdate(files, '0.1.0', BASE)
12 t.is(u.version, '0.10.0')
13 t.is(u.apkUrl, BASE + '/releases/cthuloid/android-v0.10.0/dist/cthuloid-lights.apk')
14 t.is(u.shaUrl, BASE + '/releases/cthuloid/android-v0.10.0/dist/cthuloid-lights.apk.sha256')
15})
16
17test('a release missing its .sha256 (half replicated) is skipped', (t) => {
18 const files = [...release('0.1.1'), row('android-v0.1.2', 'dist/cthuloid-lights.apk')]
19 t.is(pickUpdate(files, '0.1.0', BASE).version, '0.1.1')
20})
21
22test('nothing newer than installed -> null', (t) => {
23 t.is(pickUpdate([...release('0.1.2'), ...release('0.1.1')], '0.1.2', BASE), null)
24 t.is(pickUpdate(release('0.1.1'), '0.1.2', BASE), null)
25 t.is(pickUpdate([], '0.1.0', BASE), null)
26})
27
28test('other repos, branch builds and non-semver tags are ignored', (t) => {
29 const files = [
30 ...release('0.1.1'),
31 row('android-v9.0.0', 'dist/cthuloid-lights.apk', 'other'), row('android-v9.0.0', 'dist/cthuloid-lights.apk.sha256', 'other'),
32 row('master-d85fb4c', 'dist/skipped.txt'),
33 row('android-v2.0', 'dist/cthuloid-lights.apk'), row('android-v2.0', 'dist/cthuloid-lights.apk.sha256'),
34 row('android-v3.0.0-rc1', 'dist/cthuloid-lights.apk'), row('android-v3.0.0-rc1', 'dist/cthuloid-lights.apk.sha256')
35 ]
36 t.is(pickUpdate(files, '0.1.0', BASE).version, '0.1.1')
37})
38
39test('an unparseable installed version never offers an update', (t) => {
40 t.is(pickUpdate(release('0.1.1'), 'dev', BASE), null)
41 t.is(pickUpdate(release('0.1.1'), null, BASE), null)
42})
43
44test('compareSemver', (t) => {
45 t.ok(compareSemver('0.1.10', '0.1.9') > 0)
46 t.ok(compareSemver('1.0.0', '0.99.99') > 0)
47 t.is(compareSemver('0.1.2', '0.1.2'), 0)
48 t.ok(compareSemver('0.1.2', '0.2.0') < 0)
49})
50
51test('parseSha256 reads sha256sum output', (t) => {
52 const hex = '9d541c3029794698fc1f0dd25a8c33cc397214b537d324078dda6dc3a5ce002f'
53 t.is(parseSha256(hex + ' cthuloid-lights.apk\n'), hex)
54 t.is(parseSha256(hex.toUpperCase()), hex)
55 t.is(parseSha256('not a hash'), null)
56 t.is(parseSha256(''), null)
57})
docs/DESIGN-android.md +1 -1
……@@ -127,7 +127,7 @@ does not apply:
1271273. Lamp finder (NsdManager) + remembered addresses.
1281284. CI (tag-only) + keystore + first published APK; on-phone e2e.
1291295. Self-update: check, banner, download + checksum, installer hand-off; verified by
130 publishing `android-v0.1.1` and updating a phone that runs 0.1.0.
130 publishing `android-v0.1.4` and updating a phone that runs 0.1.3 (the first build with the updater).
131131
132132## 9. Decisions for the user
133133