release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:56:50 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187059 additions and 14005 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* AI Intel API client — frontend functions for interacting with
* the /api/ai/* endpoints.
*/
import { API_BASE } from '@/lib/api';
import type {
AIIntelPin,
AIIntelLayer,
AIIntelStatus,
AIIntelGeoJSON,
SatelliteScene,
NewsNearResult,
PinCategory,
EntityAttachment,
AIIntelPinComment,
} from '@/types/aiIntel';
const AI_API = `${API_BASE}/api/ai`;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function aiGet<T>(path: string, params?: Record<string, string | number>): Promise<T> {
const url = new URL(`${AI_API}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v !== '' && v !== undefined) url.searchParams.set(k, String(v));
});
}
const resp = await fetch(url.toString());
if (!resp.ok) throw new Error(`AI Intel API error: ${resp.status}`);
return resp.json();
}
async function aiPost<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${AI_API}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`AI Intel API error: ${resp.status}`);
return resp.json();
}
async function aiPatch<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${AI_API}${path}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`AI Intel API error: ${resp.status}`);
return resp.json();
}
async function aiDelete<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${AI_API}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([k, v]) => {
if (v) url.searchParams.set(k, v);
});
}
const resp = await fetch(url.toString(), { method: 'DELETE' });
if (!resp.ok) throw new Error(`AI Intel API error: ${resp.status}`);
return resp.json();
}
// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------
export async function fetchAIIntelStatus(): Promise<AIIntelStatus> {
return aiGet('/status');
}
// ---------------------------------------------------------------------------
// Layers
// ---------------------------------------------------------------------------
export async function fetchLayers(): Promise<{ ok: boolean; count: number; layers: AIIntelLayer[] }> {
return aiGet('/layers');
}
export async function createLayer(layer: {
name: string;
description?: string;
source?: string;
color?: string;
feed_url?: string;
feed_interval?: number;
}): Promise<{ ok: boolean; layer: AIIntelLayer }> {
return aiPost('/layers', layer);
}
export async function updateLayer(
layerId: string,
updates: Partial<Pick<AIIntelLayer, 'name' | 'description' | 'visible' | 'color'>>,
): Promise<{ ok: boolean; layer: AIIntelLayer }> {
return aiPatch(`/layers/${layerId}`, updates);
}
export async function deleteLayer(
layerId: string,
): Promise<{ ok: boolean; layer_id: string; pins_removed: number }> {
return aiDelete(`/layers/${layerId}`);
}
export async function refreshLayerFeed(
layerId: string,
): Promise<{ ok: boolean; layer: AIIntelLayer }> {
return aiPost(`/layers/${layerId}/refresh`, {});
}
// ---------------------------------------------------------------------------
// Pins
// ---------------------------------------------------------------------------
export async function fetchAIIntelPins(
category?: string,
source?: string,
layer_id?: string,
limit?: number,
): Promise<{ ok: boolean; count: number; pins: AIIntelPin[] }> {
return aiGet('/pins', {
...(category ? { category } : {}),
...(source ? { source } : {}),
...(layer_id ? { layer_id } : {}),
...(limit ? { limit } : {}),
});
}
export async function fetchAIIntelGeoJSON(layer_id?: string): Promise<AIIntelGeoJSON> {
return aiGet('/pins/geojson', layer_id ? { layer_id } : {});
}
export async function createAIIntelPin(pin: {
lat: number;
lng: number;
label: string;
category?: PinCategory;
layer_id?: string;
color?: string;
description?: string;
source?: string;
entity_attachment?: EntityAttachment;
}): Promise<{ ok: boolean; pin: AIIntelPin }> {
return aiPost('/pins', pin);
}
export async function createAIIntelPinsBatch(
pins: Array<{
lat: number;
lng: number;
label: string;
category?: PinCategory;
description?: string;
layer_id?: string;
entity_attachment?: EntityAttachment;
}>,
layer_id?: string,
): Promise<{ ok: boolean; created: number; pins: AIIntelPin[] }> {
return aiPost('/pins/batch', { pins, layer_id: layer_id || '' });
}
export async function deleteAIIntelPin(
pinId: string,
): Promise<{ ok: boolean; deleted: string }> {
return aiDelete(`/pins/${pinId}`);
}
export async function fetchAIIntelPin(
pinId: string,
): Promise<{ ok: boolean; pin: AIIntelPin }> {
return aiGet(`/pins/${pinId}`);
}
export async function updateAIIntelPin(
pinId: string,
updates: Partial<Pick<AIIntelPin, 'label' | 'description' | 'category' | 'color'>>,
): Promise<{ ok: boolean; pin: AIIntelPin }> {
return aiPatch(`/pins/${pinId}`, updates);
}
export async function addAIIntelPinComment(
pinId: string,
comment: {
text: string;
author?: 'user' | 'agent' | 'openclaw';
author_label?: string;
reply_to?: string;
},
): Promise<{ ok: boolean; pin: AIIntelPin }> {
return aiPost(`/pins/${pinId}/comments`, comment);
}
export async function deleteAIIntelPinComment(
pinId: string,
commentId: string,
): Promise<{ ok: boolean; deleted: string }> {
return aiDelete(`/pins/${pinId}/comments/${commentId}`);
}
// Re-export for convenience (some consumers may want the type).
export type { AIIntelPinComment };
export async function clearAIIntelPins(
category?: string,
source?: string,
): Promise<{ ok: boolean; removed: number }> {
return aiDelete('/pins', {
...(category ? { category } : {}),
...(source ? { source } : {}),
});
}
// ---------------------------------------------------------------------------
// Satellite Imagery
// ---------------------------------------------------------------------------
export async function fetchSatelliteImages(
lat: number,
lng: number,
count: number = 3,
): Promise<{
ok: boolean;
lat: number;
lng: number;
scenes: SatelliteScene[];
count: number;
source: string;
}> {
return aiGet('/satellite-images', { lat, lng, count });
}
// ---------------------------------------------------------------------------
// News Near
// ---------------------------------------------------------------------------
export async function fetchNewsNear(
lat: number,
lng: number,
radius: number = 500,
): Promise<NewsNearResult> {
return aiGet('/news-near', { lat, lng, radius });
}
// ---------------------------------------------------------------------------
// Data Injection
// ---------------------------------------------------------------------------
export async function injectData(
layer: string,
items: Record<string, unknown>[],
mode: 'append' | 'replace' = 'append',
): Promise<{ ok: boolean; layer: string; injected: number; total: number }> {
return aiPost('/inject', { layer, items, mode });
}
export async function clearInjectedData(
layer?: string,
): Promise<{ ok: boolean; removed: number; layer: string }> {
return aiDelete('/inject', layer ? { layer } : {});
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Runtime-resolved backend API endpoint for display and external-tool
* configuration (e.g. "Connect OpenClaw" modals).
*
* All frontend modes (dev browser, packaged desktop, browser companion)
* proxy `/api/*` through the current origin. External tools should connect
* to `getBackendEndpoint()` + `/api/...` paths — the same origin the user
* is viewing the app from.
*
* This replaces the previous hardcoded `${window.location.hostname}:8000`
* pattern, which assumed the raw backend was always on port 8000 on the
* same host. That assumption breaks in packaged desktop mode where the
* page is served from a random loopback port.
*/
/**
* Returns the user-visible API base URL for external tools.
*
* - Browser dev mode: `http://localhost:3000`
* - Packaged desktop: `http://127.0.0.1:<loopback-port>`
* - Browser companion: `http://127.0.0.1:<loopback-port>`
*
* All of these proxy `/api/*` to the backend.
*/
export function getBackendEndpoint(): string {
if (typeof window === 'undefined') return 'http://localhost:8000';
return window.location.origin;
}
+6
View File
@@ -0,0 +1,6 @@
/** Proxy external CCTV URLs through the backend to bypass CORS. */
export function buildCctvProxyUrl(rawUrl: string): string {
return rawUrl.startsWith('http')
? `/api/cctv/media?url=${encodeURIComponent(rawUrl)}`
: rawUrl;
}
+1 -1
View File
@@ -18,4 +18,4 @@ export const INTERP_TICK_MS = 2000;
// ─── News/Alert Layout ──────────────────────────────────────────────────────
export const ALERT_BOX_WIDTH_PX = 280;
export const ALERT_MAX_OFFSET_PX = 500;
export const ALERT_MAX_OFFSET_PX = 350;
+62
View File
@@ -0,0 +1,62 @@
/**
* Desktop companion mode helpers.
*
* Wraps the Tauri companion commands behind a clean async API.
* Returns `null` from all functions when the native Tauri runtime is not
* available (i.e. running in a normal browser), so callers can gate UI
* visibility without try/catch.
*/
export interface CompanionStatus {
enabled: boolean;
url: string | null;
warning: string;
}
// ---------------------------------------------------------------------------
// Runtime detection
// ---------------------------------------------------------------------------
function getTauriInvoke(): ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null {
if (typeof window === 'undefined') return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tauri = (window as any).__TAURI__;
return tauri?.core?.invoke ?? null;
}
/** Returns `true` when running inside the Tauri native desktop shell. */
export function isNativeDesktop(): boolean {
return getTauriInvoke() !== null;
}
// ---------------------------------------------------------------------------
// Companion commands
// ---------------------------------------------------------------------------
/** Query current companion status. Returns `null` if not in desktop mode. */
export async function companionStatus(): Promise<CompanionStatus | null> {
const invoke = getTauriInvoke();
if (!invoke) return null;
return (await invoke('companion_status')) as CompanionStatus;
}
/** Enable companion mode. Returns updated status, or `null` if not in desktop mode. */
export async function companionEnable(): Promise<CompanionStatus | null> {
const invoke = getTauriInvoke();
if (!invoke) return null;
return (await invoke('companion_enable')) as CompanionStatus;
}
/** Disable companion mode. Returns updated status, or `null` if not in desktop mode. */
export async function companionDisable(): Promise<CompanionStatus | null> {
const invoke = getTauriInvoke();
if (!invoke) return null;
return (await invoke('companion_disable')) as CompanionStatus;
}
/** Open the companion URL in the system browser. Only works when enabled. */
export async function companionOpenBrowser(): Promise<CompanionStatus | null> {
const invoke = getTauriInvoke();
if (!invoke) return null;
return (await invoke('companion_open_browser')) as CompanionStatus;
}
+27 -14
View File
@@ -11,6 +11,7 @@ export const DESKTOP_CONTROL_COMMANDS = [
'wormhole.gate.persona.clear',
'wormhole.gate.key.get',
'wormhole.gate.key.rotate',
'wormhole.gate.state.resync',
'wormhole.gate.proof',
'wormhole.gate.message.compose',
'wormhole.gate.message.post',
@@ -21,7 +22,6 @@ export const DESKTOP_CONTROL_COMMANDS = [
'settings.privacy.get',
'settings.privacy.set',
'settings.api_keys.get',
'settings.api_keys.set',
'settings.news.get',
'settings.news.set',
'settings.news.reset',
@@ -80,6 +80,8 @@ export interface DesktopGateRotatePayload {
export interface DesktopGateComposePayload {
gate_id: string;
plaintext: string;
reply_to?: string;
compat_plaintext?: boolean;
}
export interface DesktopGateDecryptPayload {
@@ -98,11 +100,6 @@ export interface DesktopPrivacySettingsPayload {
profile: string;
}
export interface DesktopApiKeyPayload {
env_key: string;
value: string;
}
export interface DesktopNewsFeedPayload {
name: string;
url: string;
@@ -122,6 +119,7 @@ export interface DesktopControlPayloadMap {
'wormhole.gate.persona.clear': DesktopGateRequestPayload;
'wormhole.gate.key.get': DesktopGateRequestPayload;
'wormhole.gate.key.rotate': DesktopGateRotatePayload;
'wormhole.gate.state.resync': DesktopGateRequestPayload;
'wormhole.gate.proof': DesktopGateRequestPayload;
'wormhole.gate.message.compose': DesktopGateComposePayload;
'wormhole.gate.message.post': DesktopGateComposePayload;
@@ -132,7 +130,6 @@ export interface DesktopControlPayloadMap {
'settings.privacy.get': undefined;
'settings.privacy.set': DesktopPrivacySettingsPayload;
'settings.api_keys.get': undefined;
'settings.api_keys.set': DesktopApiKeyPayload;
'settings.news.get': undefined;
'settings.news.set': DesktopNewsFeedPayload[];
'settings.news.reset': undefined;
@@ -161,6 +158,7 @@ export function controlCommandCapability(
return 'wormhole_gate_persona';
case 'wormhole.gate.key.get':
case 'wormhole.gate.key.rotate':
case 'wormhole.gate.state.resync':
return 'wormhole_gate_key';
case 'wormhole.gate.proof':
case 'wormhole.gate.message.compose':
@@ -173,7 +171,6 @@ export function controlCommandCapability(
case 'settings.privacy.get':
case 'settings.privacy.set':
case 'settings.api_keys.get':
case 'settings.api_keys.set':
case 'settings.news.get':
case 'settings.news.set':
case 'settings.news.reset':
@@ -248,12 +245,7 @@ export function isDesktopControlCommand(value: string): value is DesktopControlC
}
export function describeNativeControlError(err: unknown): string | null {
const msg =
typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message || '')
: typeof err === 'string'
? err
: '';
const msg = getNativeControlErrorMessage(err);
if (msg.includes('native_control_profile_mismatch')) {
return 'Denied — current native session profile does not include the required access';
}
@@ -266,9 +258,29 @@ export function describeNativeControlError(err: unknown): string | null {
if (msg.includes('desktop_runtime_shim_enforcement_inactive')) {
return 'Denied — this command requires a native runtime with session-profile enforcement';
}
if (msg.includes('native_gate_state_resync_required:')) {
return 'Gate state changed on another path. Run a gate resync before retrying.';
}
return null;
}
export function extractNativeGateResyncTarget(err: unknown): string | null {
const msg = getNativeControlErrorMessage(err);
const marker = 'native_gate_state_resync_required:';
const idx = msg.indexOf(marker);
if (idx < 0) return null;
const value = msg.slice(idx + marker.length).trim();
return value || null;
}
function getNativeControlErrorMessage(err: unknown): string {
return typeof err === 'object' && err !== null && 'message' in err
? String((err as { message?: string }).message || '')
: typeof err === 'string'
? err
: '';
}
export function extractGateTargetRef(
command: DesktopControlCommand,
payload: unknown,
@@ -285,6 +297,7 @@ export function extractGateTargetRef(
case 'wormhole.gate.persona.clear':
case 'wormhole.gate.key.get':
case 'wormhole.gate.key.rotate':
case 'wormhole.gate.state.resync':
case 'wormhole.gate.proof':
case 'wormhole.gate.message.compose':
case 'wormhole.gate.message.post':
+9 -7
View File
@@ -1,5 +1,4 @@
import type {
DesktopApiKeyPayload,
DesktopControlCommand,
DesktopGateComposePayload,
DesktopGateDecryptBatchPayload,
@@ -20,7 +19,6 @@ export type DesktopControlHttpRequest = {
payload?:
| DesktopWormholeSettingsPayload
| DesktopPrivacySettingsPayload
| DesktopApiKeyPayload
| DesktopNewsFeedPayload[]
| DesktopGateRequestPayload
| DesktopGatePersonaCreatePayload
@@ -99,6 +97,12 @@ export function commandToHttpRequest(
method: 'POST',
payload: payload as DesktopGateRotatePayload,
};
case 'wormhole.gate.state.resync':
return {
path: '/api/wormhole/gate/state/export',
method: 'POST',
payload: payload as DesktopGateRequestPayload,
};
case 'wormhole.gate.proof':
return {
path: '/api/wormhole/gate/proof',
@@ -143,8 +147,6 @@ export function commandToHttpRequest(
};
case 'settings.api_keys.get':
return { path: '/api/settings/api-keys', method: 'GET' };
case 'settings.api_keys.set':
return { path: '/api/settings/api-keys', method: 'PUT', payload: payload as DesktopApiKeyPayload };
case 'settings.news.get':
return { path: '/api/settings/news-feeds', method: 'GET' };
case 'settings.news.set':
@@ -205,6 +207,9 @@ export function httpRequestToInvokeRequest(
if (upperMethod === 'POST' && path === '/api/wormhole/gate/key/rotate') {
return { command: 'wormhole.gate.key.rotate', payload: payload as DesktopGateRotatePayload };
}
if (upperMethod === 'POST' && path === '/api/wormhole/gate/state/export') {
return { command: 'wormhole.gate.state.resync', payload: payload as DesktopGateRequestPayload };
}
if (upperMethod === 'POST' && path === '/api/wormhole/gate/proof') {
return { command: 'wormhole.gate.proof', payload: payload as DesktopGateRequestPayload };
}
@@ -238,9 +243,6 @@ export function httpRequestToInvokeRequest(
if (upperMethod === 'GET' && path === '/api/settings/api-keys') {
return { command: 'settings.api_keys.get', payload: undefined };
}
if (upperMethod === 'PUT' && path === '/api/settings/api-keys') {
return { command: 'settings.api_keys.set', payload: payload as DesktopApiKeyPayload };
}
if (upperMethod === 'GET' && path === '/api/settings/news-feeds') {
return { command: 'settings.news.get', payload: undefined };
}
+95
View File
@@ -0,0 +1,95 @@
/**
* P5C: Jittered DM poll scheduling.
*
* Removes exact fixed-interval DM polling cadence to reduce timing
* fingerprinting. Privacy profile controls jitter width: high-privacy
* mode uses a wider band so recurring polls are less distinguishable
* from random network noise.
*
* Also provides bounded catch-up scheduling for `has_more` backlog
* recovery — short jittered delays, capped to avoid burst-drain.
*/
import { getPrivacyProfilePreference } from './privacyBrowserStorage';
/** Jitter multiplier ranges keyed by privacy profile. */
const JITTER_BANDS: Record<string, { min: number; max: number }> = {
default: { min: 0.8, max: 1.4 },
high: { min: 0.5, max: 2.0 },
};
/** Catch-up delay ranges (ms) for has_more backlog recovery. */
const CATCHUP_BANDS: Record<string, { min: number; max: number }> = {
default: { min: 2_000, max: 5_000 },
high: { min: 3_000, max: 8_000 },
};
/** Maximum consecutive catch-up polls before falling back to normal cadence. */
export const MAX_CATCHUP_POLLS = 3;
/**
* Return a jittered delay (ms) for normal recurring DM poll/count activity.
*
* @param baseMs - The nominal interval (e.g. 12_000 or 15_000).
* @param opts.profile - Override privacy profile (default: read from browser storage).
* @param opts.random - Override random source (default: Math.random); useful for tests.
*/
export function jitteredPollDelay(
baseMs: number,
opts?: { profile?: string; random?: number },
): number {
const profile = opts?.profile ?? getPrivacyProfilePreference();
const band = JITTER_BANDS[profile] || JITTER_BANDS.default;
const r = opts?.random ?? Math.random();
const factor = band.min + r * (band.max - band.min);
return Math.round(baseMs * factor);
}
/**
* Return a jittered catch-up delay (ms) for bounded has_more follow-up.
*
* @param opts.profile - Override privacy profile.
* @param opts.random - Override random source.
*/
export function catchUpDelay(
opts?: { profile?: string; random?: number },
): number {
const profile = opts?.profile ?? getPrivacyProfilePreference();
const band = CATCHUP_BANDS[profile] || CATCHUP_BANDS.default;
const r = opts?.random ?? Math.random();
return Math.round(band.min + r * (band.max - band.min));
}
export type TickClassification = {
delay: number;
refreshCount: boolean;
newBudget: number;
};
/**
* Classify the next tick: determine delay, whether to refresh count,
* and the updated catch-up budget.
*
* Catch-up ticks (has_more + budget remaining) use a shorter delay and
* skip the count endpoint to avoid accelerating coarse-count cadence.
* Normal ticks refresh both messages and count.
*/
export function classifyTick(
hasMore: boolean,
catchUpBudget: number,
baseMs: number,
opts?: { profile?: string; random?: number },
): TickClassification {
if (hasMore && catchUpBudget > 0) {
return {
delay: catchUpDelay(opts),
refreshCount: false,
newBudget: catchUpBudget - 1,
};
}
return {
delay: jitteredPollDelay(baseMs, opts),
refreshCount: true,
newBudget: MAX_CATCHUP_POLLS,
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Pure policy predicates extracted from MeshChat controller logic.
* Used by useMeshChatController and tested independently.
*/
/** Returns true when DM sends should be queued (delayed) rather than fired immediately. */
export function shouldQueueDmSend(privacyProfile: 'default' | 'high'): boolean {
return privacyProfile === 'high';
}
/** Returns true when gate send should be blocked because access is still syncing. */
export function isGateSendBlocked(
activeTab: string,
hasSelectedGate: boolean,
selectedGateAccessReady: boolean,
): boolean {
return activeTab === 'infonet' && hasSelectedGate && !selectedGateAccessReady;
}
/** Returns true when DM polling should skip real fetches (wormhole not ready or anonymous blocked). */
export function isDmPollBlocked(
wormholeEnabled: boolean,
wormholeReadyState: boolean,
anonymousDmBlocked: boolean,
): boolean {
return (wormholeEnabled && !wormholeReadyState) || anonymousDmBlocked;
}
+2 -2
View File
@@ -10,13 +10,13 @@ export function getMeshTerminalWriteLockReason(state: MeshTerminalSecurityState)
if (!state.anonymousModeReady) {
return 'Mesh Terminal write commands are disabled until Wormhole hidden transport is ready for Anonymous Infonet mode.';
}
return 'Mesh Terminal write commands are disabled while Anonymous Infonet mode is active. Use MeshChat for hardened public and private actions.';
return 'Mesh Terminal write commands are disabled while Anonymous Infonet mode is active. Use MeshChat for gate chat (transitional lane) or Dead Drop (stronger private lane).';
}
if (state.wormholeRequired) {
if (!state.wormholeReady) {
return 'Mesh Terminal write commands are disabled until Wormhole secure mode is ready.';
}
return 'Mesh Terminal write commands are disabled while Wormhole secure mode is active. Use MeshChat for hardened private actions.';
return 'Mesh Terminal write commands are disabled while Wormhole secure mode is active. Use MeshChat for gate chat (transitional lane) or Dead Drop (stronger private lane).';
}
return '';
}
@@ -0,0 +1,31 @@
/**
* Native desktop protected-settings readiness detection.
*
* In the native Tauri desktop window, protected settings requests (api-keys,
* news-feeds, wormhole, privacy) are handled through the Rust IPC control
* boundary, which owns the admin key natively. The browser admin-session
* cookie flow (`/api/admin/session`) is unnecessary and unavailable in
* packaged mode — the loopback server intentionally does not implement it.
*
* This helper detects when the native bridge can handle protected settings
* so the UI can bypass browser admin-session gating and treat those surfaces
* as immediately available.
*
* Returns false in browser mode and browser companion mode, preserving the
* existing admin-session gating for those environments.
*/
import { hasLocalControlBridge } from '@/lib/localControlTransport';
/**
* Returns `true` when the native desktop control bridge is present and can
* handle protected settings requests through Rust IPC with native admin-key
* ownership.
*
* When this returns `true`, browser admin-session gating (`/api/admin/session`)
* should be bypassed for settings surfaces that are already mapped to native
* IPC commands (api-keys, news-feeds, wormhole, privacy, system update).
*/
export function isNativeProtectedSettingsReady(): boolean {
return hasLocalControlBridge();
}
+167
View File
@@ -0,0 +1,167 @@
export type NativeDesktopUpdateMode = 'dev' | 'packaged';
export type DesktopPlatform = 'windows' | 'macos' | 'linux' | 'unknown';
export type UpdateRuntimeKind = 'browser' | 'desktop_dev' | 'desktop_packaged';
export type UpdateActionKind = 'auto_apply' | 'manual_download' | 'desktop_updater';
export interface DesktopUpdateContext {
mode: NativeDesktopUpdateMode;
platform: DesktopPlatform;
is_packaged_build: boolean;
backend_mode?: 'managed' | 'external';
owns_local_backend?: boolean;
}
export interface GitHubReleaseAsset {
name?: string;
browser_download_url?: string;
content_type?: string;
}
export interface GitHubLatestRelease {
tag_name?: string;
name?: string;
html_url?: string;
assets?: GitHubReleaseAsset[];
}
export interface DesktopUpdaterUpdateInfo {
version: string;
currentVersion: string;
notes: string;
date: string;
}
type TauriUpdate = {
version?: string;
currentVersion?: string;
body?: string;
date?: string;
downloadAndInstall?: () => Promise<void>;
};
let pendingDesktopUpdate: TauriUpdate | null = null;
function getTauriInvoke(): ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null {
if (typeof window === 'undefined') return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tauri = (window as any).__TAURI__;
return tauri?.core?.invoke ?? null;
}
export async function getDesktopUpdateContext(): Promise<DesktopUpdateContext | null> {
const invoke = getTauriInvoke();
if (!invoke) return null;
try {
return (await invoke('desktop_update_context')) as DesktopUpdateContext;
} catch {
return null;
}
}
export function classifyUpdateRuntime(
context: DesktopUpdateContext | null,
): UpdateRuntimeKind {
if (!context) return 'browser';
return context.mode === 'packaged' ? 'desktop_packaged' : 'desktop_dev';
}
export function getUpdateAction(runtime: UpdateRuntimeKind): UpdateActionKind {
return runtime === 'desktop_packaged' ? 'manual_download' : 'auto_apply';
}
async function loadTauriUpdater(): Promise<{
check?: () => Promise<TauriUpdate | null>;
} | null> {
if (typeof window === 'undefined') return null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!(window as any).__TAURI__) return null;
try {
return (await import('@tauri-apps/plugin-updater')) as {
check?: () => Promise<TauriUpdate | null>;
};
} catch {
return null;
}
}
export async function checkDesktopUpdaterUpdate(): Promise<DesktopUpdaterUpdateInfo | null> {
const updater = await loadTauriUpdater();
if (!updater?.check) return null;
const update = await updater.check();
if (!update) {
pendingDesktopUpdate = null;
return null;
}
pendingDesktopUpdate = update;
return {
version: String(update.version || ''),
currentVersion: String(update.currentVersion || ''),
notes: String(update.body || ''),
date: String(update.date || ''),
};
}
export async function installDesktopUpdaterUpdate(): Promise<void> {
let update = pendingDesktopUpdate;
if (!update) {
const updater = await loadTauriUpdater();
update = updater?.check ? await updater.check() : null;
pendingDesktopUpdate = update;
}
if (!update?.downloadAndInstall) {
throw new Error('desktop_updater_no_update_available');
}
await update.downloadAndInstall();
try {
const process = (await import('@tauri-apps/plugin-process')) as {
relaunch?: () => Promise<void>;
};
await process.relaunch?.();
} catch {
throw new Error('desktop_update_installed_restart_required');
}
}
function normalizeAssetUrl(asset: GitHubReleaseAsset): string {
return String(asset.browser_download_url || '').trim();
}
function findAssetUrl(release: GitHubLatestRelease, matchers: RegExp[]): string | null {
const assets = Array.isArray(release.assets) ? release.assets : [];
for (const matcher of matchers) {
const asset = assets.find((entry) => matcher.test(String(entry.name || '')));
const url = asset ? normalizeAssetUrl(asset) : '';
if (url) return url;
}
return null;
}
export function pickDesktopInstallerUrl(
release: GitHubLatestRelease,
platform: DesktopPlatform,
): string | null {
if (platform === 'windows') {
return findAssetUrl(release, [/\.msi$/i, /setup\.exe$/i, /\.exe$/i]);
}
if (platform === 'macos') {
return findAssetUrl(release, [/\.dmg$/i, /\.app\.tar\.gz$/i, /\.pkg$/i]);
}
if (platform === 'linux') {
return findAssetUrl(release, [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i]);
}
return null;
}
export function getPreferredManualUpdateUrl(
release: GitHubLatestRelease,
runtime: UpdateRuntimeKind,
platform: DesktopPlatform,
): string {
const releaseUrl =
typeof release.html_url === 'string' && release.html_url.trim().length > 0
? release.html_url
: 'https://github.com/BigBodyCobain/Shadowbroker/releases/latest';
if (runtime !== 'desktop_packaged') return releaseUrl;
return pickDesktopInstallerUrl(release, platform) || releaseUrl;
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Wormhole teardown logic extracted from InfonetTerminal close handler.
* Shuts down Wormhole when the terminal closes so it doesn't stay running.
*/
export async function teardownWormholeOnClose(
fetchState: (force: boolean) => Promise<{ ready?: boolean; running?: boolean } | null>,
leave: () => Promise<unknown>,
): Promise<void> {
try {
const s = await fetchState(false);
if (s?.ready || s?.running) {
await leave();
}
} catch {
/* ignore — best-effort teardown */
}
}