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:
co-authored by
Claude Opus 5
parent
08360b75e4
commit
499e66e6ba
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ 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"`
|
||||||
|
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||||
Library store.LibraryStats `json:"library"`
|
Library store.LibraryStats `json:"library"`
|
||||||
SyncRunning bool `json:"syncRunning"`
|
SyncRunning bool `json:"syncRunning"`
|
||||||
Runs []store.SyncRun `json:"runs"`
|
Runs []store.SyncRun `json:"runs"`
|
||||||
@@ -85,6 +88,7 @@ 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(),
|
||||||
|
UpdatePolicy: s.updatePolicy.get(),
|
||||||
Library: stats,
|
Library: stats,
|
||||||
SyncRunning: s.syncer.Running(),
|
SyncRunning: s.syncer.Running(),
|
||||||
Runs: runs,
|
Runs: runs,
|
||||||
@@ -92,6 +96,65 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user