mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-31 07:57:28 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -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 } : {});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user