feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUpdate, type UpdateStatus } from '@/composables/useUpdate'
import { useSessionStore } from '@/stores/session'
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
const DEFAULT_RETRY_SECONDS = 60
function readDismissedVersion() {
try {
return localStorage.getItem(DISMISSED_VERSION_KEY)
} catch {
return null
}
}
function writeDismissedVersion(version: string) {
try {
localStorage.setItem(DISMISSED_VERSION_KEY, version)
} catch {
return
}
}
export const useUpdateStore = defineStore('update', () => {
const session = useSessionStore()
const status = ref<UpdateStatus | null>(null)
const loadState = ref<UpdateLoadState>('idle')
const phase = ref<UpdateCheckPhase>('idle')
const retryAfter = ref(0)
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const available = computed(() => status.value?.available === true)
const showCue = computed(() => isOwner.value && available.value)
const bannerVisible = computed(
() => showCue.value && status.value?.latest !== dismissedVersion.value,
)
function load() {
if (pendingLoad) {
return pendingLoad
}
const { getUpdateStatus } = useUpdate()
loadState.value = 'loading'
pendingLoad = Promise.resolve()
.then(getUpdateStatus)
.then((result) => {
status.value = result
loadState.value = 'ready'
})
.catch(() => {
loadState.value = status.value === null ? 'error' : 'ready'
})
.finally(() => {
pendingLoad = null
})
return pendingLoad
}
async function check(): Promise<UpdateCheckResult> {
if (phase.value === 'checking') {
return 'busy'
}
const { checkForUpdate } = useUpdate()
phase.value = 'checking'
try {
status.value = await checkForUpdate()
loadState.value = 'ready'
phase.value = 'idle'
return 'updated'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
phase.value = 'limited'
return 'limited'
}
if (error instanceof ApiError && error.status === 403) {
phase.value = 'idle'
return 'forbidden'
}
phase.value = 'failed'
return 'failed'
}
}
function dismissBanner() {
if (status.value) {
dismissedVersion.value = status.value.latest
writeDismissedVersion(status.value.latest)
}
}
return {
status,
loadState,
phase,
retryAfter,
isOwner,
available,
showCue,
bannerVisible,
load,
check,
dismissBanner,
}
})