Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,21 @@
"compat-verdict-compatible-detail": "This bundle can be delivered over-the-air to devices running {bundle}.",
"compat-verdict-incompatible": "Not compatible ({count})",
"compat-verdict-incompatible-detail": "Some packages require an app store update, so this bundle cannot be delivered over-the-air.",
"compat-fix-explanation": "Capgo delivers your web and JavaScript code instantly, but it can't change native code. When a bundle's native dependencies don't match the native build a device already has, that update can crash or misbehave until your users install a new native build from the app stores.",
"compat-fix-how-title": "How to fix it",
"compat-fix-learn-more": "Learn more in the docs",
"compat-fix-manage-channels": "Manage channels",
"compat-fix-rebuild-cta": "Open Capgo Builder",
"compat-fix-rebuild-detail": "Build for iOS and Android in the cloud, then submit to the stores — no local Xcode or Android Studio needed.",
"compat-fix-rebuild-title": "Ship a new native build",
"compat-fix-rollback-confirm": "This will set {changes} and devices on the affected channel(s) will receive that bundle again. Continue?",
"compat-fix-rollback-cta": "Roll back for me",
"compat-fix-rollback-done": "Channel rolled back — the event will resolve in a moment.",
"compat-fix-rollback-detail": "Point the affected channel back to its last compatible bundle so users stay safe until the new build is live.",
"compat-fix-rollback-title": "Roll back the channel",
"compat-fix-title": "A new native build is needed",
"compat-fix-why-detail": "Over-the-air updates can only replace the web layer — JavaScript, HTML and CSS. Native plugins are compiled into the app binary, which only the App Store and Play Store can update. When a bundle's native packages differ from the build installed on a device, its JavaScript can call native APIs that aren't there — which is why it can crash.",
"compat-fix-why-title": "Why native changes need an app-store update",
"dependencies": "Dependencies",
"dependencies-added-packages": "Added",
"dependencies-changed-packages": "Changed",
Expand Down
228 changes: 227 additions & 1 deletion src/pages/app/[app].compatibility.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import { toast } from 'vue-sonner'
import IconAlertCircle from '~icons/lucide/alert-circle'
import IconArrowRight from '~icons/lucide/arrow-right'
import IconCheckCircle from '~icons/lucide/check-circle'
import IconChevronRight from '~icons/lucide/chevron-right'
import IconExternalLink from '~icons/lucide/external-link'
import { dependencyDiffPath, groupCompatibilityEvents, platformLabel } from '~/services/compatibilityEvents'
import { formatLocalDateTime } from '~/services/date'
import { checkPermissions } from '~/services/permissions'
import { pushEvent } from '~/services/posthog'
import { createSignedImageUrl } from '~/services/storage'
import { useSupabase } from '~/services/supabase'
import { getLocalConfig, useSupabase } from '~/services/supabase'
import { useDialogV2Store } from '~/stores/dialogv2'
import { useDisplayStore } from '~/stores/display'

Expand Down Expand Up @@ -54,6 +57,126 @@ const visibleGroups = computed<CompatibilityEventGroup[]>(() => {
return groupedEvents.value
})

// Whether the app currently has any live incompatibility, which is when the
// "what this means / how to fix it" guidance + Capgo Builder CTA are relevant.
const hasUnresolved = computed(() => groupedEvents.value.some(group => !group.resolved))

const config = getLocalConfig()
// Docs that explain why native changes can't ship over-the-air.
const compatDocsUrl = 'https://capgo.app/docs/live-updates/compatibility/'

// Send the user to the in-app native build flow (Builds tab), tracking the click
// so this Builder entry point can be compared with the banner / upload CTAs.
function openBuilder() {
pushEvent('builder_cta_compatibility_clicked', config.supaHost, { app_id: id.value })
router.push(`/app/${encodeURIComponent(id.value)}/builds`)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
interface RollbackTarget {
channelId: number
channelName: string
versionId: number
versionName: string
}

// One rollback per affected channel: the most recent unresolved event per channel
// (events are loaded newest-first) tells us the last compatible bundle. Channels
// or previous bundles that no longer exist (deleted) cannot be rolled back.
const rollbackTargets = computed<RollbackTarget[]>(() => {
const seen = new Set<number>()
const targets: RollbackTarget[] = []
for (const group of groupedEvents.value) {
if (group.resolved)
continue
const event = group.representative
if (event.channel_id === null || seen.has(event.channel_id))
continue
seen.add(event.channel_id)
if (!existingChannelIds.value.has(event.channel_id))
continue
if (event.previous_version_id === null || !existingVersionIds.value.has(event.previous_version_id))
continue
targets.push({
channelId: event.channel_id,
channelName: event.channel_name ?? t('unknown'),
versionId: event.previous_version_id,
versionName: bundleLabel(event.previous_version_name),
})
}
return targets
})

async function rollbackChannels() {
const targets = rollbackTargets.value
if (targets.length === 0)
return
for (const target of targets) {
const allowed = await checkPermissions('channel.promote_bundle', { channelId: target.channelId })
if (!allowed) {
toast.error(t('no-permission'))
return
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let failed = false
for (const target of targets) {
const { error } = await supabase
.from('channels')
.update({ version: target.versionId })
.eq('id', target.channelId)
if (error) {
console.error('[Compatibility] Error rolling back channel', target.channelName, error)
failed = true
}
}
if (failed) {
toast.error(t('error-update-channel'))
}
else {
toast.success(t('compat-fix-rollback-done'))
}
// The compatibility queue resolves the event a few seconds after the channel
// write, so refresh now and again shortly after to catch the resolution.
await refreshData()
setTimeout(() => refreshData(), 4000)
setTimeout(() => refreshData(), 10000)
}

// Rolling channels back to older bundles is outward-facing (devices start
// receiving them again), so always confirm first.
function openRollbackDialog() {
const changes = rollbackTargets.value
.map(target => `${target.channelName} → ${target.versionName}`)
.join(', ')
dialogStore.openDialog({
title: t('compat-fix-rollback-title'),
description: t('compat-fix-rollback-confirm', { changes }),
buttons: [
{
text: t('button-cancel'),
role: 'cancel',
},
{
text: t('compat-fix-rollback-cta'),
role: 'primary',
handler: async () => {
await rollbackChannels()
},
},
],
})
}

// The guidance panel is collapsible and remembers the user's choice across
// visits (default expanded; only collapsed if they hid it before).
const guidanceCollapseKey = 'capgo-compat-guidance-collapsed'
const guidanceOpen = ref(typeof localStorage === 'undefined' || localStorage.getItem(guidanceCollapseKey) !== '1')

function toggleGuidance() {
guidanceOpen.value = !guidanceOpen.value
if (typeof localStorage !== 'undefined')
localStorage.setItem(guidanceCollapseKey, guidanceOpen.value ? '0' : '1')
}

async function loadAppInfo() {
try {
const { data: dataApp } = await supabase
Expand Down Expand Up @@ -349,6 +472,109 @@ watchEffect(async () => {
</label>
</div>

<!-- Fix guidance + Capgo Builder CTA, shown while the app has live incompatibilities -->
<section
v-if="hasUnresolved"
data-test="compatibility-fix-guidance"
class="overflow-hidden border rounded-xl border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
>
<button
type="button"
data-test="compatibility-fix-guidance-toggle"
class="flex items-center w-full gap-3 px-5 py-4 text-left transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40"
:aria-expanded="guidanceOpen"
@click="toggleGuidance"
>
<IconAlertCircle class="w-5 h-5 shrink-0 text-amber-500 dark:text-amber-400" />
<span class="flex-1 text-base font-semibold text-slate-900 dark:text-white">
{{ t('compat-fix-title') }}
</span>
<IconChevronRight
class="w-4 h-4 transition-transform shrink-0 text-slate-400"
:class="guidanceOpen ? 'rotate-90' : ''"
/>
</button>

<div v-show="guidanceOpen" class="px-5 pb-5 border-t border-slate-100 dark:border-slate-800">
<p class="pt-4 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{{ t('compat-fix-explanation') }}
</p>

<h3 class="mt-4 text-xs font-semibold tracking-wide uppercase text-slate-400 dark:text-slate-500">
{{ t('compat-fix-how-title') }}
</h3>
<div class="grid gap-3 mt-3 sm:grid-cols-2">
<div class="flex flex-col p-4 border rounded-lg border-slate-200 dark:border-slate-700">
<h4 class="text-sm font-semibold text-slate-900 dark:text-white">
{{ t('compat-fix-rebuild-title') }}
</h4>
<p class="flex-1 mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{{ t('compat-fix-rebuild-detail') }}
</p>
<button
type="button"
data-test="compatibility-rebuild-cta"
class="inline-flex items-center self-start gap-1.5 px-4 py-2 mt-4 text-sm font-semibold text-white transition-colors rounded-lg bg-blue-600 hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
@click="openBuilder"
>
{{ t('compat-fix-rebuild-cta') }}
<IconArrowRight class="w-4 h-4" />
</button>
</div>
<div class="flex flex-col p-4 border rounded-lg border-slate-200 dark:border-slate-700">
<h4 class="text-sm font-semibold text-slate-900 dark:text-white">
{{ t('compat-fix-rollback-title') }}
</h4>
<p class="flex-1 mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{{ t('compat-fix-rollback-detail') }}
</p>
<div class="flex flex-wrap items-center gap-2 mt-4">
<button
v-if="rollbackTargets.length > 0"
type="button"
data-test="compatibility-rollback-cta"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold text-white transition-colors rounded-lg bg-blue-600 hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 dark:focus:ring-offset-slate-900"
@click="openRollbackDialog"
>
{{ t('compat-fix-rollback-cta') }}
<IconArrowRight class="w-4 h-4" />
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-semibold transition-colors border rounded-lg border-slate-300 text-slate-700 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
@click="router.push(`/app/${encodeURIComponent(id)}/channels`)"
>
{{ t('compat-fix-manage-channels') }}
<IconArrowRight class="w-4 h-4" />
</button>
</div>
</div>
</div>

<div class="pt-4 mt-4 border-t border-slate-100 dark:border-slate-800">
<details class="group">
<summary class="inline-flex items-center gap-1 text-sm font-medium cursor-pointer list-none text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">
<IconChevronRight class="w-4 h-4 transition-transform group-open:rotate-90" />
{{ t('compat-fix-why-title') }}
</summary>
<p class="mt-2 pl-5 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{{ t('compat-fix-why-detail') }}
</p>
</details>
<a
:href="compatDocsUrl"
target="_blank"
rel="noopener noreferrer"
data-test="compatibility-docs-link"
class="inline-flex items-center gap-1 mt-3 text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ t('compat-fix-learn-more') }}
<IconExternalLink class="h-3.5 w-3.5" />
</a>
</div>
</div>
</section>

<!-- Empty state -->
<div
v-if="!isLoading && visibleGroups.length === 0"
Expand Down
25 changes: 24 additions & 1 deletion supabase/functions/_backend/triggers/compatibility_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export interface DecideCompatibilityEventsInput {
* transition carries a new one and inserts a fresh, unresolved row.
*/
changeOccurredAt: string
/**
* Unresolved events already on file for this channel, used to recognize a
* REVERT: when the new default bundle is the `previous_version` of an
* unresolved event, users are being returned to the baseline they are
* already on, so no new (mirror) event should be raised — otherwise the
* recommended remediation (roll the channel back) would itself raise a fresh
* unresolved event, forever.
*/
unresolvedEvents?: readonly UnresolvedCompatibilityEvent[]
}

/**
Expand Down Expand Up @@ -117,7 +126,7 @@ function hasNativePackages(bundle: CompatibilityBundle | null | undefined): bund
* run it). The handler only excludes when the metadata is genuinely unavailable.
*/
export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput): CompatibilityEventInsert[] {
const { newChannel, currentBundle, previousDefaults, changeOccurredAt } = input
const { newChannel, currentBundle, previousDefaults, changeOccurredAt, unresolvedEvents = [] } = input

// The new channel must be a default (public) for any platform to matter.
if (!newChannel.public)
Expand Down Expand Up @@ -147,6 +156,19 @@ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput)
if (previous.bundle.id === currentBundle.id)
continue

// REVERT: the new default is a baseline an unresolved event already says
// this channel's users are on. Returning them to it is the remediation the
// event recommends, not a new incompatibility — raising a mirror event here
// would loop forever (each rollback raising the next event). The matching
// unresolved event is auto-resolved by decideAutoResolves on this same pass.
const isRevertToKnownBaseline = unresolvedEvents.some(event =>
event.channel_id === newChannel.id
&& event.platform === previous.platform
&& event.previous_version_id === currentBundle.id,
)
if (isRevertToKnownBaseline)
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const summary: CompatibilitySummary = summarizeBundleCompatibility(
compareNativePackages(currentBundle.nativePackages, previous.bundle.nativePackages),
)
Expand Down Expand Up @@ -177,6 +199,7 @@ export function decideCompatibilityEvents(input: DecideCompatibilityEventsInput)
export interface UnresolvedCompatibilityEvent {
id: number
platform: CompatibilityPlatform
channel_id: number | null
previous_version_id: number | null
previous_version_name: string
current_version_id: number | null
Expand Down
23 changes: 22 additions & 1 deletion supabase/functions/_backend/triggers/on_channel_update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,32 @@ async function persistCompatibilityEvents(
}
}

// Unresolved events for this channel, so the decision layer can recognize a
// revert to a known baseline and skip raising a mirror event for it.
const { data: channelUnresolved, error: channelUnresolvedError } = await supabaseAdmin(c)
.from('compatibility_events')
.select('id, platform, channel_id, previous_version_id, previous_version_name, current_version_id')
.eq('app_id', record.app_id)
.eq('channel_id', record.id)
.is('resolved_at', null)
.order('id', { ascending: false })
.limit(COMPATIBILITY_AUTO_RESOLVE_SCAN_LIMIT)
if (channelUnresolvedError) {
cloudlogErr({
requestId: c.get('requestId'),
message: 'Failed to load unresolved compatibility events for revert detection',
error: channelUnresolvedError,
app_id: record.app_id,
channel_id: record.id,
})
}

const events = decideCompatibilityEvents({
newChannel: record,
currentBundle,
previousDefaults,
changeOccurredAt: record.updated_at ?? new Date().toISOString(),
unresolvedEvents: (channelUnresolved ?? []) as UnresolvedCompatibilityEvent[],
})

for (const event of events) {
Expand Down Expand Up @@ -326,7 +347,7 @@ async function autoResolveCompatibilityEvents(

const { data: unresolved, error } = await supabaseAdmin(c)
.from('compatibility_events')
.select('id, platform, previous_version_id, previous_version_name, current_version_id')
.select('id, platform, channel_id, previous_version_id, previous_version_name, current_version_id')
.eq('app_id', record.app_id)
.is('resolved_at', null)
.order('id', { ascending: false })
Expand Down
Loading
Loading