Files
openchamber/packages/ui/src/components/update/openCodeUpdateDedup.ts
T
𝖎𝖚𝖑𝖎𝖎𝖆andBohdan Triapitsyn c88dd16d2a fix: prevent bundled OpenCode self-upgrades (#2525)
* fix: prevent bundled OpenCode self-upgrades

* feat(vscode): support OpenCode upgrades

* fix: refresh OpenCode update status on runtime switch

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-07-29 19:59:41 +03:00

101 lines
3.9 KiB
TypeScript

/**
* Pure decision helpers for the OpenCode update toast and PWA install toast.
*
* Extracted from `OpenCodeUpdateToast.tsx` and `usePwaInstallPrompt.ts` so the
* dedup decisions can be unit-tested without a DOM, storage, or React. The
* React surfaces remain the sole owners of side effects (storage writes,
* `toast.info`, event listeners). This module only answers the question
* "given these inputs, should we show the toast?".
*
* Exposed for unit testing. Not part of the stable consumer surface.
*/
export interface PwaInstallToastDecisionInput {
/** Persistent localStorage entry: `'true'` when the user dismissed once. */
readonly dismissed: string | null;
/** Session-scoped sessionStorage flag set the first time the toast is shown in this tab. */
readonly sessionShown: string | null;
/** Whether the current React effect already holds a toast id. */
readonly hasActiveToast: boolean;
}
/**
* Returns `true` if the PWA install prompt toast should be shown for the
* incoming `beforeinstallprompt` event.
*
* The decision composes three gates (any failure short-circuits):
* 1. Persistent dismissal wins for all future visits.
* 2. Per-tab dedup avoids re-showing inside the same browsing session.
* 3. Re-entrancy guard prevents stacking when the effect already owns one.
*/
export const shouldShowPwaInstallToast = (input: PwaInstallToastDecisionInput): boolean => {
if (input.dismissed === 'true') return false;
if (input.sessionShown === 'true') return false;
if (input.hasActiveToast) return false;
return true;
};
export interface OpenCodeUpdateToastDecisionInput {
/** Version string reported by the server (already trimmed by the caller). */
readonly version: string;
/** Most recent version the user explicitly dismissed, or `null` if none. */
readonly dismissedVersion: string | null;
/** Set of versions already surfaced in this tab session. */
readonly seenVersions: ReadonlySet<string>;
}
/**
* Returns `true` if the OpenCode update toast should be shown for `version`.
*
* Empty/whitespace-only versions short-circuit to `false`. A non-null
* `dismissedVersion` matching the incoming version also short-circuits; a
* different `dismissedVersion` means a newer release has appeared since the
* last dismissal and the toast surfaces again.
*/
export const shouldShowOpenCodeUpdateToast = (
input: OpenCodeUpdateToastDecisionInput,
): boolean => {
if (!input.version) return false;
if (input.seenVersions.has(input.version)) return false;
if (input.dismissedVersion !== null && input.dismissedVersion === input.version) return false;
return true;
};
/**
* Coerces the `detail.version` carried by an `openchamber:opencode-update-available`
* CustomEvent into a trimmed string, or returns `''` when the payload is
* missing or shaped unexpectedly.
*
* Only `string` is accepted; numeric or boolean payloads are rejected because
* downstream callers compare versions by literal equality.
*/
export const resolveOpenCodeUpdateVersion = (detail: unknown): string => {
if (detail === null || typeof detail !== 'object') return '';
const candidate = (detail as { version?: unknown }).version;
if (typeof candidate !== 'string') return '';
return candidate.trim();
};
export interface OpenCodeUpgradeStatusLike {
readonly available?: boolean | null;
readonly latestVersion?: string | null;
readonly upgrade?: {
readonly supported?: boolean | null;
} | null;
}
/**
* Pulls the candidate version out of an `/api/opencode/upgrade-status` JSON
* payload. Returns `''` when the payload is missing the field, has the wrong
* type, or reports `available !== true`.
*/
export const resolveOpenCodeUpgradeStatusVersion = (
status: OpenCodeUpgradeStatusLike | null | undefined,
): string => {
if (!status) return '';
if (status.upgrade?.supported !== true) return '';
if (status.available !== true) return '';
if (typeof status.latestVersion !== 'string') return '';
return status.latestVersion.trim();
};