Server-controlled app updates, optional or forced

The gateway decides whether a TV may keep running its current build.
Clients send X-Memby-Version on every request and ask GET /v1/update on
each launch; the verdict is none, optional or mandatory.

- internal/appupdate holds the decision as pure, tested logic: below
  minimumVersion is mandatory, below latestVersion is optional.
- Admin page gains an App updates section — latest version, APK URL,
  notes, and a "Require this update" toggle that sets the forced floor.
- Client shows a dismissable prompt for optional, and a full-screen
  panel that swallows Back for mandatory. Instructions say what the
  system installer will ask before it asks.

Two safeguards: a client that cannot report its version is never forced,
and the client ignores a verdict with no download URL, so a
half-configured policy cannot produce an unblockable screen with a dead
button. An unreachable gateway shows nothing.

The verdict is deliberately not part of /v1/home: that payload is cached
per user, while this answer depends on the requesting client's version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:34:04 +12:00
co-authored by Claude Opus 5
parent 08360b75e4
commit 499e66e6ba
19 changed files with 1016 additions and 11 deletions
+11
View File
@@ -86,6 +86,17 @@ key, so a changed key forces every user to uninstall and reinstall.
URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest
use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`. use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`.
**Forced updates are server-controlled.** `server/internal/appupdate` decides `none` /
`optional` / `mandatory` from the client's `X-Memby-Version` header against an
operator-set policy (admin page → App updates). `HomeViewModel.checkForAppUpdate` runs on
every launch and `ui/UpdateScreen.kt` renders the verdict — mandatory covers the whole
home screen with `zIndex(10f)`, swallows Back and offers no dismiss. Two safeguards worth
preserving: a client with an unreadable version is never forced (it could not escape the
prompt), and the client ignores any verdict without a `downloadUrl` (`isActionable`), so a
half-configured policy cannot produce a blocking screen with a dead button. The verdict
must stay out of `/v1/home`, which is cached per user while this answer varies per client
build.
## Architecture ## Architecture
**Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore` **Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore`
@@ -7,6 +7,7 @@ import com.ponzischeme89.memby.data.model.GatewayLoginRequest
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvent import com.ponzischeme89.memby.data.model.GatewayRowEvent
import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport import com.ponzischeme89.memby.data.model.PlaybackReport
import com.ponzischeme89.memby.data.remote.EmbyApi import com.ponzischeme89.memby.data.remote.EmbyApi
@@ -330,6 +331,18 @@ class EmbyRepository(private val settings: SettingsStore) {
*/ */
suspend fun getRecommendations(): List<HomeRow> = requireGateway().recommendations().rows suspend fun getRecommendations(): List<HomeRow> = requireGateway().recommendations().rows
/**
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
* position to decide, and null on failure — an unreachable gateway must never leave
* a TV stuck behind a blocking update prompt.
*/
suspend fun checkAppUpdate(): GatewayUpdate? {
if (!ServerConfig.isGateway) return null
return runCatching { requireGateway().updateStatus() }
.getOrNull()
?.takeIf { it.isActionable }
}
/** Full item metadata, requested only after focus settles on an item. */ /** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem { suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId) if (ServerConfig.isGateway) return requireGateway().item(itemId)
@@ -57,6 +57,32 @@ data class GatewayHome(
val partial: Boolean = false, val partial: Boolean = false,
) )
/**
* The gateway's verdict on this build, from `GET /v1/update`.
*
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the
* operator has decided this version may no longer be used.
*/
@Serializable
data class GatewayUpdate(
val status: String = STATUS_NONE,
val version: String = "",
val notes: String = "",
val downloadUrl: String = "",
) {
val isMandatory: Boolean get() = status == STATUS_MANDATORY
val isOptional: Boolean get() = status == STATUS_OPTIONAL
/** Usable only if the gateway actually told us where the APK is. */
val isActionable: Boolean get() = downloadUrl.isNotBlank() && status != STATUS_NONE
companion object {
const val STATUS_NONE = "none"
const val STATUS_OPTIONAL = "optional"
const val STATUS_MANDATORY = "mandatory"
}
}
/** Response of `GET /v1/recommendations`. */ /** Response of `GET /v1/recommendations`. */
@Serializable @Serializable
data class GatewayRows( data class GatewayRows(
@@ -10,6 +10,7 @@ import com.ponzischeme89.memby.data.model.GatewayPlayback
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayRows import com.ponzischeme89.memby.data.model.GatewayRows
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.data.model.UserItemData
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -45,6 +46,13 @@ interface GatewayApi {
@GET("v1/recommendations") @GET("v1/recommendations")
suspend fun recommendations(): GatewayRows suspend fun recommendations(): GatewayRows
/**
* Whether this build should update. The version travels as a header on every request
* (see GatewayServiceFactory), so there is nothing to pass here.
*/
@GET("v1/update")
suspend fun updateStatus(): GatewayUpdate
@GET("v1/items/{id}") @GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem suspend fun item(@Path("id") itemId: String): BaseItem
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data.remote package com.ponzischeme89.memby.data.remote
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.ponzischeme89.memby.BuildConfig
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -45,7 +46,11 @@ object GatewayServiceFactory {
*/ */
private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val builder = chain.request().newBuilder().header("Accept", "application/json") val builder = chain.request().newBuilder()
.header("Accept", "application/json")
// The gateway decides whether this build needs updating, so every request
// says which build it is.
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
tokenProvider()?.takeIf { it.isNotBlank() }?.let { tokenProvider()?.takeIf { it.isNotBlank() }?.let {
builder.header("Authorization", "Bearer $it") builder.header("Authorization", "Bearer $it")
} }
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics
import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.isMaintenanceError import com.ponzischeme89.memby.data.isMaintenanceError
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.UserItemData import com.ponzischeme89.memby.data.model.UserItemData
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -48,6 +49,11 @@ data class HomeUiState(
* rows behind it would be stale and unusable anyway. * rows behind it would be stale and unusable anyway.
*/ */
val maintenanceMessage: String? = null, val maintenanceMessage: String? = null,
/**
* The gateway's update verdict. A mandatory one blocks the home screen; an optional
* one shows a prompt the viewer can dismiss for this session.
*/
val update: GatewayUpdate? = null,
) { ) {
val watchingAndNextUp: List<BaseItem> val watchingAndNextUp: List<BaseItem>
get() = (continueWatching + nextUp).distinctBy(BaseItem::id) get() = (continueWatching + nextUp).distinctBy(BaseItem::id)
@@ -91,8 +97,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Row engagement, buffered here and uploaded in batches. */ /** Row engagement, buffered here and uploaded in batches. */
private val analytics = RowAnalytics() private val analytics = RowAnalytics()
/** Optional prompt the viewer waved away; forgotten when the app restarts. */
private var dismissedUpdateVersion: String? = null
init { init {
refreshAll() refreshAll()
checkForAppUpdate()
viewModelScope.launch { viewModelScope.launch {
repository.playbackStops.collect { refreshWatching() } repository.playbackStops.collect { refreshWatching() }
} }
@@ -106,6 +116,28 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
} }
} }
/**
* Asks the gateway whether this build is still allowed. Runs on every launch, so an
* operator can retire a version without waiting for anyone to open Settings.
*/
fun checkForAppUpdate() {
viewModelScope.launch(Dispatchers.IO) {
val update = repository.checkAppUpdate() ?: return@launch
// A dismissed optional prompt stays dismissed for this session; a mandatory
// one always reasserts itself.
if (update.isOptional && dismissedUpdateVersion == update.version) return@launch
_state.update { it.copy(update = update) }
}
}
/** Dismisses an optional prompt. Mandatory updates ignore this by construction. */
fun dismissUpdatePrompt() {
val current = _state.value.update ?: return
if (current.isMandatory) return
dismissedUpdateVersion = current.version
_state.update { it.copy(update = null) }
}
fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind) fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind)
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) = fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
@@ -656,6 +656,15 @@ private fun HomeScreen(settings: Settings) {
}, },
) )
} }
// Last in the Box, so it draws over everything — including the rail and any
// overlay that happened to be open when the check came back.
homeState.update?.let { update ->
UpdateScreen(
update = update,
onDismiss = homeViewModel::dismissUpdatePrompt,
modifier = Modifier.zIndex(10f),
)
}
} }
} }
@@ -0,0 +1,304 @@
package com.ponzischeme89.memby.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch
private val UpdateAccent = Color(0xFF52B54B)
private val UpdateTitle = Color(0xFFF2F5F7)
private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFF7E888F)
/**
* The update prompt, shown over the home screen.
*
* A **mandatory** update covers everything and cannot be dismissed: Back is swallowed and
* there is one button. The operator has decided this build may no longer be used, so
* leaving a way past it would defeat the point. An **optional** one is the same panel with
* a "Later" button.
*
* The instructions matter more than the visuals here: on a TV, the system installer looks
* like a permission prompt the viewer has never seen, so the copy says what will happen
* before it happens.
*/
@Composable
fun UpdateScreen(
update: GatewayUpdate,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val checker = remember { UpdateChecker(context) }
var installing by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
val primaryFocus = remember { FocusRequester() }
LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } }
// Swallow Back entirely while an update is required. For an optional prompt, Back is
// the natural "later".
BackHandler(enabled = true) { if (!update.isMandatory) onDismiss() }
val transition = rememberInfiniteTransition(label = "update")
val drift by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(7_000, easing = LinearEasing), RepeatMode.Reverse),
label = "drift",
)
val bob by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1_800, easing = LinearEasing), RepeatMode.Reverse),
label = "bob",
)
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { entered = true }
val entrance by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(360),
label = "update-entrance",
)
Box(
modifier = modifier
.fillMaxSize()
// Opaque, not a scrim: a required update is not a dialog over usable content.
.background(Color(0xFF0B0E11))
// Consumes clicks so nothing behind can be reached.
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
) {
Canvas(Modifier.fillMaxSize()) {
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
val radius = size.minDimension * 0.62f
drawCircle(
brush = Brush.radialGradient(
colors = listOf(UpdateAccent.copy(alpha = 0.14f), Color.Transparent),
center = centre,
radius = radius,
),
radius = radius,
center = centre,
)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 60.dp)
.graphicsLayer {
alpha = entrance
translationY = (1f - entrance) * 22.dp.toPx()
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
modifier = Modifier
.size(92.dp)
.graphicsLayer { translationY = -6.dp.toPx() * bob }
.clip(CircleShape)
.background(Color(0xFF16202A))
.border(1.dp, UpdateAccent.copy(alpha = 0.4f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.ArrowDownward,
contentDescription = null,
tint = UpdateAccent,
modifier = Modifier.size(40.dp),
)
}
Spacer(Modifier.height(26.dp))
Text(
if (update.isMandatory) "Time to update Memby" else "A new version of Memby is ready",
color = UpdateTitle,
fontSize = 32.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
if (update.isMandatory) {
"Version ${update.version} is required to keep watching. It only takes a moment."
} else {
"Version ${update.version} is available."
},
color = UpdateBody,
fontSize = 17.sp,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 640.dp),
)
if (update.notes.isNotBlank()) {
Spacer(Modifier.height(14.dp))
Text(
update.notes,
color = UpdateFaint,
fontSize = 15.sp,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 560.dp),
)
}
Spacer(Modifier.height(22.dp))
Text(
// Said before it happens, because the system installer is unfamiliar and
// easy to back out of by accident.
"Choose Update now — Memby downloads the new version, then your TV asks you " +
"to confirm the install. If it asks permission to install apps, allow it and " +
"the update continues.",
color = UpdateFaint,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 600.dp),
)
Spacer(Modifier.height(30.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
UpdateButton(
label = if (installing) "Downloading…" else "Update now",
primary = true,
enabled = !installing,
onClick = {
if (installing) return@UpdateButton
installing = true
message = null
scope.launch {
val result = checker.downloadAndInstall(update.downloadUrl, token = "")
installing = false
message = result.exceptionOrNull()?.message
?: "Opening the installer…"
}
},
modifier = Modifier.focusRequester(primaryFocus),
)
if (!update.isMandatory) {
UpdateButton(label = "Later", primary = false, enabled = true, onClick = onDismiss)
}
}
message?.let {
Spacer(Modifier.height(18.dp))
Text(it, color = UpdateFaint, fontSize = 14.sp, textAlign = TextAlign.Center)
}
if (update.isMandatory) {
Spacer(Modifier.height(22.dp))
Text(
"Stuck? Ask whoever set up Memby for you.",
color = UpdateFaint.copy(alpha = 0.75f),
fontSize = 13.sp,
)
}
}
}
}
@Composable
private fun UpdateButton(
label: String,
primary: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (focused) 1.06f else 1f,
animationSpec = tween(120),
label = "update-button-scale",
)
Box(
modifier = modifier
.graphicsLayer { scaleX = scale; scaleY = scale; alpha = if (enabled) 1f else 0.6f }
.clip(RoundedCornerShape(10.dp))
.background(
when {
focused && primary -> UpdateAccent
focused -> Color(0xFF2A343F)
primary -> Color(0xFF2C6A29)
else -> Color(0xFF1E2833)
},
)
.border(
width = if (focused) 0.dp else 1.dp,
color = Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
.focusable(interactionSource = remember { MutableInteractionSource() })
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 30.dp, vertical = 13.dp),
) {
Text(
label,
color = if (focused && primary) Color(0xFF06240A) else UpdateTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
@@ -0,0 +1,77 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayUpdate
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The update verdict decides whether a viewer can use the app at all, so the client's
* reading of it needs pinning down — including the cases that must NOT block anyone.
*/
class GatewayUpdateTest {
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
@Test
fun `a mandatory verdict is actionable and blocking`() {
val update = json.decodeFromString<GatewayUpdate>(
"""{"status":"mandatory","version":"0.1.54","notes":"Security fix","downloadUrl":"https://nas/memby.apk"}""",
)
assertTrue(update.isMandatory)
assertFalse(update.isOptional)
assertTrue(update.isActionable)
assertEquals("0.1.54", update.version)
}
@Test
fun `an optional verdict is dismissable`() {
val update = json.decodeFromString<GatewayUpdate>(
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
)
assertTrue(update.isOptional)
assertFalse(update.isMandatory)
assertTrue(update.isActionable)
}
@Test
fun `status none is never shown`() {
val update = json.decodeFromString<GatewayUpdate>("""{"status":"none"}""")
assertFalse(update.isActionable)
}
@Test
fun `a verdict with no download url is not actionable`() {
// Nothing to act on: showing an unblockable prompt with no working button would
// strand the viewer completely.
val update = json.decodeFromString<GatewayUpdate>(
"""{"status":"mandatory","version":"0.1.54","downloadUrl":""}""",
)
assertTrue(update.isMandatory)
assertFalse(update.isActionable)
}
@Test
fun `an empty response decodes to nothing to do`() {
val update = json.decodeFromString<GatewayUpdate>("{}")
assertEquals(GatewayUpdate.STATUS_NONE, update.status)
assertFalse(update.isActionable)
}
@Test
fun `an unrecognised status from a newer gateway is treated as nothing`() {
val update = json.decodeFromString<GatewayUpdate>(
"""{"status":"nag","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
)
assertFalse(update.isMandatory)
assertFalse(update.isOptional)
}
}
+32
View File
@@ -48,6 +48,7 @@ headers attached.
| GET | `/v1/auth/session` | Confirm a stored token is still valid | | GET | `/v1/auth/session` | Confirm a stored token is still valid |
| GET | `/v1/home?limit=` | **Every launcher row in one response** | | GET | `/v1/home?limit=` | **Every launcher row in one response** |
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild | | GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
| GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request | | GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request |
| GET | `/v1/search?q=&limit=` | Library search | | GET | `/v1/search?q=&limit=` | Library search |
| GET | `/v1/items/{id}` | Full metadata for one item | | GET | `/v1/items/{id}` | Full metadata for one item |
@@ -131,6 +132,7 @@ gateway is reachable from outside the LAN.
| GET | `/admin/api/status` | Library counts, sync history, maintenance state | | GET | `/admin/api/status` | Library counts, sync history, maintenance state |
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` | | POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
| POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` | | POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` |
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
| GET | `/admin/api/analytics?days=7` | Row engagement | | GET | `/admin/api/analytics?days=7` | Row engagement |
## Library import ## Library import
@@ -172,6 +174,36 @@ The switch lives in Postgres, not memory, so a restart cannot quietly bring the
up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it
directly in the database works too. directly in the database works too.
## App update policy
The gateway decides whether a TV may keep running its current build. Clients send
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch; the verdict is
`none`, `optional` or `mandatory`.
Set it on the admin page: **latest version**, **APK URL** (normally the same file the
landing page serves), release notes, and a **Require this update** toggle.
- *Optional* — a dismissable prompt. Dismissal lasts for that session only.
- *Required* — a full-screen panel over the home screen with no way past it. Back is
swallowed and there is one button. Use it when a build is genuinely unusable, not for
ordinary releases.
"Required" sets `minimumVersion = latestVersion`; anything below that floor is forced.
`minimumVersion` can also be set directly for a staged rollout where the forced floor is
older than the newest build.
Two deliberate safeguards, both tested in `internal/appupdate`:
- A client that cannot report a version is **never** forced. It would otherwise be stuck
behind a prompt it may have no way to satisfy.
- The client only shows a verdict carrying a download URL, so a misconfigured policy
cannot produce an unblockable screen with a dead button. An unreachable gateway shows
nothing at all.
The verdict is deliberately *not* part of `/v1/home`: that payload is cached per user,
while this answer depends on the requesting client's version, so sharing a cache entry
would hand one TV's answer to another.
## Row analytics ## Row analytics
The TV reports three signals per row — `impression` (drawn), `focus` (the remote landed The TV reports three signals per row — `impression` (drawn), `focus` (the remote landed
+5
View File
@@ -108,6 +108,11 @@ func run(log *slog.Logger) error {
} }
go server.WatchMaintenance(ctx, 30*time.Second) go server.WatchMaintenance(ctx, 30*time.Second)
if err := server.LoadUpdatePolicy(ctx); err != nil {
return err
}
go server.WatchUpdatePolicy(ctx, 60*time.Second)
go syncer.Schedule(ctx, cfg.SyncInterval) go syncer.Schedule(ctx, cfg.SyncInterval)
if cfg.SyncOnStart { if cfg.SyncOnStart {
go func() { go func() {
+73 -10
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/library" "github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/store" "github.com/ponzischeme89/memby/server/internal/store"
) )
@@ -27,6 +28,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
return mux return mux
} }
@@ -60,11 +62,12 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
} }
type adminStatus struct { type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"` Maintenance store.Maintenance `json:"maintenance"`
Library store.LibraryStats `json:"library"` UpdatePolicy appupdate.Policy `json:"updatePolicy"`
SyncRunning bool `json:"syncRunning"` Library store.LibraryStats `json:"library"`
Runs []store.SyncRun `json:"runs"` SyncRunning bool `json:"syncRunning"`
SyncEvery string `json:"syncEvery"` Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
} }
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
@@ -84,14 +87,74 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, adminStatus{ writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(), Maintenance: s.maintenance.get(),
Library: stats, UpdatePolicy: s.updatePolicy.get(),
SyncRunning: s.syncer.Running(), Library: stats,
Runs: runs, SyncRunning: s.syncer.Running(),
SyncEvery: s.cfg.SyncInterval.String(), Runs: runs,
SyncEvery: s.cfg.SyncInterval.String(),
}) })
} }
type updatePolicyRequest struct {
Enabled bool `json:"enabled"`
LatestVersion string `json:"latestVersion"`
DownloadURL string `json:"downloadUrl"`
Notes string `json:"notes"`
// Required makes this release mandatory for everyone below it. The page offers a
// toggle rather than exposing "minimum version" directly, because "force this
// update" is the decision an operator actually wants to make.
Required bool `json:"required"`
// MinimumVersion is honoured when set explicitly, for staged rollouts where the
// forced floor is older than the latest build.
MinimumVersion string `json:"minimumVersion"`
}
func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) {
var req updatePolicyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
policy := appupdate.Policy{
Enabled: req.Enabled,
LatestVersion: strings.TrimSpace(req.LatestVersion),
MinimumVersion: strings.TrimSpace(req.MinimumVersion),
DownloadURL: strings.TrimSpace(req.DownloadURL),
Notes: strings.TrimSpace(req.Notes),
}
if req.Required {
// Forcing means "nobody below the current build", so the floor is the latest.
policy.MinimumVersion = policy.LatestVersion
} else if policy.MinimumVersion == policy.LatestVersion {
// Un-ticking the box must actually release the floor.
policy.MinimumVersion = ""
}
if policy.Enabled && policy.LatestVersion == "" {
writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
return
}
if policy.Enabled && policy.DownloadURL == "" {
writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
return
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.log.Error("update policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save the update policy")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Warn("update policy reload failed", "error", err)
}
s.log.Info("update policy changed",
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
writeJSON(w, http.StatusOK, s.updatePolicy.get())
}
type syncRequest struct { type syncRequest struct {
Kind string `json:"kind"` Kind string `json:"kind"`
} }
+65
View File
@@ -85,6 +85,29 @@
</div> </div>
</section> </section>
<section>
<h2>App updates</h2>
<p class="muted" style="margin-top:0">
TVs check on every launch. <strong>Optional</strong> shows a dismissable prompt;
<strong>required</strong> blocks the home screen until the viewer updates.
</p>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-version" placeholder="Latest version, e.g. 0.1.54" style="min-width:220px">
<input type="text" id="update-url" placeholder="APK URL, e.g. https://nas/memby/memby-0.1.54.apk" style="min-width:380px">
</div>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-notes" placeholder="What's new (shown on the TV)" style="min-width:480px">
</div>
<div class="row">
<label class="muted" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="update-required"> Require this update
</label>
<button id="update-save">Save policy</button>
<button id="update-disable" class="secondary">Turn off prompts</button>
<span id="update-state" class="pill"></span>
</div>
</section>
<section> <section>
<h2>Row engagement</h2> <h2>Row engagement</h2>
<div class="row" style="margin-bottom:12px"> <div class="row" style="margin-bottom:12px">
@@ -195,6 +218,29 @@ function renderStatus(status) {
const messageField = document.getElementById('maintenance-message'); const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || ''; if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
const policy = status.updatePolicy || {};
const policyState = document.getElementById('update-state');
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
if (!policy.enabled) {
policyState.textContent = 'off';
policyState.className = 'pill muted';
} else {
policyState.textContent = required ? 'REQUIRED ' + policy.latestVersion : 'optional ' + policy.latestVersion;
policyState.className = 'pill ' + (required ? 'warn' : 'ok');
}
// Do not fight the operator for the field they are typing in.
const fields = {
'update-version': policy.latestVersion || '',
'update-url': policy.downloadUrl || '',
'update-notes': policy.notes || '',
};
for (const [id, value] of Object.entries(fields)) {
const el = document.getElementById(id);
if (document.activeElement !== el) el.value = value;
}
const requiredBox = document.getElementById('update-required');
if (document.activeElement !== requiredBox) requiredBox.checked = required;
document.getElementById('runs').innerHTML = (status.runs || []).length document.getElementById('runs').innerHTML = (status.runs || []).length
? status.runs.map((run) => { ? status.runs.map((run) => {
const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad'; const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
@@ -279,6 +325,25 @@ document.getElementById('maintenance-off').addEventListener('click', () =>
body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }), body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }),
}))); })));
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
latestVersion: document.getElementById('update-version').value.trim(),
downloadUrl: document.getElementById('update-url').value.trim(),
notes: document.getElementById('update-notes').value.trim(),
required: document.getElementById('update-required').checked,
});
}
document.getElementById('update-save').addEventListener('click', () => {
if (document.getElementById('update-required').checked &&
!confirm('Required updates block the home screen on every TV below this version. Continue?')) return;
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(true) }));
});
document.getElementById('update-disable').addEventListener('click', () =>
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(false) })));
document.getElementById('days').addEventListener('change', refresh); document.getElementById('days').addEventListener('change', refresh);
refresh(); refresh();
+2
View File
@@ -38,6 +38,7 @@ type Server struct {
recommendationBuilds recommendationBuilds recommendationBuilds recommendationBuilds
maintenance maintenanceState maintenance maintenanceState
updatePolicy updatePolicyCache
} }
// Deps are the collaborators the API needs. A struct rather than positional arguments: // Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -76,6 +77,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver)) v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch)) v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations)) v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("GET /v1/update", s.authed(s.handleUpdate))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem)) v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite)) v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
+4
View File
@@ -43,6 +43,10 @@ type homeResponse struct {
// Partial is true when at least one row failed upstream. The TV shows what arrived // Partial is true when at least one row failed upstream. The TV shows what arrived
// and flags a refresh error rather than blanking the screen. // and flags a refresh error rather than blanking the screen.
Partial bool `json:"partial"` Partial bool `json:"partial"`
// Deliberately no update verdict here: this payload is cached per user, and the
// verdict depends on the *client's* version, so a cached body would hand one TV's
// answer to another running a different build. The client asks /v1/update instead.
} }
// handleHome answers the entire launcher in one round trip. // handleHome answers the entire launcher in one round trip.
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"context"
"net/http"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/store"
)
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
type updatePolicyCache struct {
mu sync.RWMutex
value appupdate.Policy
}
func (c *updatePolicyCache) get() appupdate.Policy {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
func (c *updatePolicyCache) set(value appupdate.Policy) {
c.mu.Lock()
defer c.mu.Unlock()
c.value = value
}
// LoadUpdatePolicy primes the cached policy. Called at boot and after every change.
func (s *Server) LoadUpdatePolicy(ctx context.Context) error {
policy, err := s.store.UpdatePolicy(ctx)
if err != nil {
return err
}
s.updatePolicy.set(policy)
return nil
}
// WatchUpdatePolicy re-reads the policy periodically, so a change made directly in the
// database is picked up without a restart.
func (s *Server) WatchUpdatePolicy(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.LoadUpdatePolicy(ctx); err != nil {
s.log.Warn("update policy refresh failed", "error", err)
}
}
}
}
// clientVersion reads the version a TV reports. Absent means an older build that predates
// the header — [appupdate.Decide] treats that as "say nothing".
func clientVersion(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Version"))
}
// handleUpdate answers the client's version check.
//
// Its own endpoint rather than a field on /v1/home: the home payload is cached per user,
// while this answer depends on the requesting client's version, so the two cannot share a
// cache entry. It costs nothing — the policy is held in memory.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, _ store.Session) {
decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
writeJSON(w, http.StatusOK, decision)
}
+119
View File
@@ -0,0 +1,119 @@
// Package appupdate decides whether a TV should be told to update, and how firmly.
//
// The gateway is the authority: the client reports the version it is running and renders
// whatever verdict comes back. That keeps "everyone must move to 0.1.54 now" a one-field
// change on the admin page rather than a release.
package appupdate
import (
"strconv"
"strings"
"time"
)
// Status is how hard the TV should push the update.
const (
// StatusNone — nothing to say; the client is current enough.
StatusNone = "none"
// StatusOptional — a newer build exists and the viewer may dismiss the prompt.
StatusOptional = "optional"
// StatusMandatory — the client is below the minimum supported version and must
// update before it can be used.
StatusMandatory = "mandatory"
)
// Policy is the operator-controlled setting, stored in app_settings.
type Policy struct {
// Enabled turns the whole mechanism off without losing the values.
Enabled bool `json:"enabled"`
// LatestVersion is what clients below it are *offered*.
LatestVersion string `json:"latestVersion"`
// MinimumVersion is what clients below it are *forced* to. Leave blank (or equal to
// an old release) to keep updates optional; set it to LatestVersion to make the
// current release mandatory for everyone.
MinimumVersion string `json:"minimumVersion"`
// DownloadURL points at the APK — normally the same file the landing page serves.
DownloadURL string `json:"downloadUrl"`
Notes string `json:"notes"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Decision is what the client receives.
type Decision struct {
Status string `json:"status"`
Version string `json:"version"`
Notes string `json:"notes"`
DownloadURL string `json:"downloadUrl"`
}
// Decide compares the version a client reported against the policy.
//
// An unreadable or absent client version yields StatusNone on purpose: a client that
// cannot say what it is would otherwise be locked out by a blocking prompt it may not
// even know how to satisfy. Those builds still have the manual check in Settings.
func Decide(policy Policy, clientVersion string) Decision {
none := Decision{Status: StatusNone}
if !policy.Enabled || strings.TrimSpace(policy.LatestVersion) == "" {
return none
}
client := parseVersion(clientVersion)
if len(client) == 0 {
return none
}
offer := Decision{
Status: StatusOptional,
Version: normalize(policy.LatestVersion),
Notes: strings.TrimSpace(policy.Notes),
DownloadURL: strings.TrimSpace(policy.DownloadURL),
}
if minimum := parseVersion(policy.MinimumVersion); len(minimum) > 0 && compare(client, minimum) < 0 {
offer.Status = StatusMandatory
return offer
}
if compare(client, parseVersion(policy.LatestVersion)) < 0 {
return offer
}
return none
}
// compare returns -1, 0 or 1. Missing components count as zero, so 0.1 == 0.1.0.
func compare(a, b []int) int {
for i := 0; i < len(a) || i < len(b); i++ {
av, bv := 0, 0
if i < len(a) {
av = a[i]
}
if i < len(b) {
bv = b[i]
}
if av != bv {
if av < bv {
return -1
}
return 1
}
}
return 0
}
// parseVersion mirrors the client's own parsing, so both sides agree on ordering.
func parseVersion(v string) []int {
parts := strings.FieldsFunc(normalize(v), func(r rune) bool {
return r == '.' || r == '-' || r == '+' || r == ' '
})
out := make([]int, 0, len(parts))
for _, part := range parts {
n, err := strconv.Atoi(part)
if err != nil {
// Stop at the first non-numeric component ("0.1.54-beta" -> 0.1.54).
break
}
out = append(out, n)
}
return out
}
func normalize(v string) string { return strings.TrimLeft(strings.TrimSpace(v), "vV") }
+118
View File
@@ -0,0 +1,118 @@
package appupdate
import "testing"
func policy() Policy {
return Policy{
Enabled: true,
LatestVersion: "0.1.54",
MinimumVersion: "0.1.50",
DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk",
Notes: "Faster home screen",
}
}
func TestUpToDateClientIsLeftAlone(t *testing.T) {
for _, version := range []string{"0.1.54", "0.1.55", "0.2.0", "1.0.0"} {
if got := Decide(policy(), version).Status; got != StatusNone {
t.Fatalf("client %s: got %s, want none", version, got)
}
}
}
func TestBehindLatestIsOptional(t *testing.T) {
decision := Decide(policy(), "0.1.53")
if decision.Status != StatusOptional {
t.Fatalf("got %s, want optional", decision.Status)
}
if decision.Version != "0.1.54" || decision.DownloadURL == "" {
t.Fatalf("decision is missing what the TV needs to act: %+v", decision)
}
}
func TestBelowMinimumIsMandatory(t *testing.T) {
if got := Decide(policy(), "0.1.49").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
}
// Setting minimum = latest is how an operator forces everyone onto the current build.
func TestMinimumEqualToLatestForcesEveryOldClient(t *testing.T) {
p := policy()
p.MinimumVersion = p.LatestVersion
if got := Decide(p, "0.1.53").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
if got := Decide(p, "0.1.54").Status; got != StatusNone {
t.Fatalf("a current client should still be left alone, got %s", got)
}
}
func TestBlankMinimumKeepsUpdatesOptional(t *testing.T) {
p := policy()
p.MinimumVersion = ""
if got := Decide(p, "0.0.1").Status; got != StatusOptional {
t.Fatalf("got %s, want optional", got)
}
}
func TestDisabledPolicySaysNothing(t *testing.T) {
p := policy()
p.Enabled = false
if got := Decide(p, "0.0.1").Status; got != StatusNone {
t.Fatalf("got %s, want none", got)
}
}
func TestPolicyWithNoLatestVersionSaysNothing(t *testing.T) {
p := policy()
p.LatestVersion = " "
if got := Decide(p, "0.0.1").Status; got != StatusNone {
t.Fatalf("got %s, want none", got)
}
}
// A client that cannot report its version must not be hard-blocked: it would be stuck
// behind a prompt it may have no way to satisfy.
func TestUnknownClientVersionIsNeverForced(t *testing.T) {
p := policy()
p.MinimumVersion = p.LatestVersion
for _, version := range []string{"", " ", "?", "unknown"} {
if got := Decide(p, version).Status; got != StatusNone {
t.Fatalf("client %q: got %s, want none", version, got)
}
}
}
func TestVersionParsingIsForgiving(t *testing.T) {
p := policy()
// Leading "v", pre-release suffixes and short versions all compare sensibly.
if got := Decide(p, "v0.1.53").Status; got != StatusOptional {
t.Fatalf("v-prefixed: got %s, want optional", got)
}
if got := Decide(p, "0.1.54-beta").Status; got != StatusNone {
t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got)
}
p.LatestVersion = "0.2"
if got := Decide(p, "0.2.0").Status; got != StatusNone {
t.Fatalf("0.2.0 should equal 0.2, got %s", got)
}
}
func TestMandatoryOutranksOptional(t *testing.T) {
p := policy()
p.MinimumVersion = "0.1.53"
// Below both thresholds: the stronger verdict must win.
if got := Decide(p, "0.1.52").Status; got != StatusMandatory {
t.Fatalf("got %s, want mandatory", got)
}
}
+38
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/ponzischeme89/memby/server/internal/appupdate"
) )
// MaintenanceKey is the app_settings row backing maintenance mode. // MaintenanceKey is the app_settings row backing maintenance mode.
@@ -60,6 +61,43 @@ func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error {
return nil return nil
} }
// UpdatePolicyKey is the app_settings row backing the client update policy.
const UpdatePolicyKey = "update_policy"
func (s *Store) UpdatePolicy(ctx context.Context) (appupdate.Policy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, UpdatePolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return appupdate.Policy{}, nil
}
if err != nil {
return appupdate.Policy{}, fmt.Errorf("store: read update policy: %w", err)
}
var policy appupdate.Policy
if err := json.Unmarshal(raw, &policy); err != nil {
return appupdate.Policy{}, fmt.Errorf("store: decode update policy: %w", err)
}
return policy, nil
}
func (s *Store) SetUpdatePolicy(ctx context.Context, policy appupdate.Policy) error {
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
UpdatePolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write update policy: %w", err)
}
return nil
}
// NewestSession is the fallback credential for the library import: whichever TV signed // NewestSession is the fallback credential for the library import: whichever TV signed
// in most recently. It means a fresh deployment can import without configuring a // in most recently. It means a fresh deployment can import without configuring a
// service account, at the cost of the import stopping if that user is ever removed. // service account, at the cost of the import stopping if that user is ever removed.