storebound/apps/desktop/src/api.ts

184 lines
17 KiB
TypeScript

// The webview's only way out: the app commands of src-tauri/src/commands.rs. No plugin API is used from here.
// Dev builds can run on fixture data instead (VITE_FIXTURES=1, src/dev/fixtures.ts): import.meta.env.DEV is false in
// release builds, so that branch and the fixture module are dropped from them (tests/webview.test.ts checks dist/).
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol';
import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol';
import type { Overrides } from './lib/identity';
import type { TraySummary } from './lib/tray';
export type {
AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
ProgressParams, ProjectSummary, ReleasePhase, ReleaseTrain, ReplyDraft, StoreRelease, ToolResult,
EditableListing, EditableResult, EditResult,
AnalyticsCompare, AsoAuditView, AsoTrendRow, ConsoleAnswer, ConsoleFillResult, ConsoleForm, DeclAsk, DeclarationsResult, ListingText, PpoStatus, PpoTest, PriceDiffRow, PriceRowData, PricesCurrent, PricesDiff, RenderImage, RenderTile, RendersResult,
ArtThumb, LiveArtResult, LiveArtSet, LiveArtTile,
} from '../../../cli/lib/desktop-protocol';
export interface AppInfo { version: string; identifier: string; platform: string; debug: boolean; home: string | null }
export type { ToolStatus } from '../../../cli/lib/tools';
export type { AndroidSdkLookup } from '../../../cli/lib/tools';
/** system.tools: the local tools and (newer sidecars) the Android SDK as `play build` finds it. */
export interface LocalTools { tools: import('../../../cli/lib/tools').ToolStatus[]; androidSdk?: import('../../../cli/lib/tools').AndroidSdkLookup }
export interface SidecarStatus { running: boolean; version: string; protocol: string; methods: Pick<MethodDescriptor, 'method' | 'title' | 'description' | 'kind' | 'network' | 'destructive' | 'json'>[]; tools: LocalTools | null }
/** An app folder (one storebound.config.ts = one app on iOS and / or Android) and the accounts it uses. */
export interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string; /** The folder is gone (moved, renamed, deleted): shown as "Folder not found". */ missing?: boolean; /** The storebound version the folder's package.json pins, when it is not this app's. */ storeboundPin?: string | null }
export type AccountKind = 'play' | 'apple';
export interface Account { id: string; label: string; kind: AccountKind; created: string; studio?: string | null; /** The machine profile that holds the key (~/.storebound, shared with the CLI); null while an old keychain key has not moved. */ profile?: string | null }
export const STUDIO_COLORS = ['indigo', 'violet', 'rose', 'amber', 'emerald', 'teal', 'sky', 'slate'] as const;
export type StudioColor = (typeof STUDIO_COLORS)[number];
/** A user-named group of store accounts (and so of apps). */
export interface Studio { id: string; name: string; color: StudioColor; logo: string | null; created: string }
export interface Settings {
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
/** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */
notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } };
channel: 'stable' | 'beta';
}
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' };
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }
export interface WriteOutcome { confirmed: boolean; stage: 'dry-run' | 'dialog' | 'done'; dryRun: ToolResult; result?: ToolResult }
export interface CacheEntry<T = unknown> { value: T; at: string }
export interface DownloadInfo { tool: 'ffmpeg' | 'chrome'; available: boolean; version: string | null; size: number | null; license: string; licenseUrl: string; source: string; installed: boolean; folder: string }
export interface DownloadEvent { job: string; tool: string; stage: 'resolve' | 'download' | 'verify' | 'unpack' | 'install' | 'done' | 'error' | 'cancelled'; received: number; total: number | null; message?: string }
export type { IconResult as Icon };
export interface UpdateInfo { current: string; channel: 'stable' | 'beta'; available: string | null; notes: string | null; date: string | null }
/** What a failed command rejects with (sidecar.rs `Fail`). */
export interface Fail { code: number; message: string; data?: unknown }
export function errorText(e: unknown): string {
if (typeof e === 'string') return e;
if (e && typeof e === 'object' && 'message' in e && typeof (e as Fail).message === 'string') return (e as Fail).message;
return 'Something went wrong';
}
/** Dev-only fixture mode (VITE_FIXTURES=1): every command answered from src/dev/fixtures.ts. */
export const FIXTURES: boolean = !!(import.meta.env.DEV && import.meta.env.VITE_FIXTURES);
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (import.meta.env.DEV && import.meta.env.VITE_FIXTURES) return (await import('./dev/fixtures')).mockInvoke<T>(cmd, args ?? {});
return tauriInvoke<T>(cmd, args);
}
async function listen<T>(event: string, cb: (payload: T) => void): Promise<UnlistenFn> {
if (import.meta.env.DEV && import.meta.env.VITE_FIXTURES) return (await import('./dev/fixtures')).mockListen(event, cb as (p: unknown) => void);
return tauriListen<T>(event, e => cb(e.payload));
}
/** The finding fields the host builds the Fix with AI prompt from. */
export interface AiFinding { rule: string; guideline: string; store: 'apple' | 'play'; title: string; evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string }
export interface AiChange { path: string; status: string }
export interface AiFixInfo { claude: boolean; prompt: string; git: boolean; gitError: string | null; dirty: AiChange[] }
export type AiFixRun = { refused: 'dirty'; dirty: AiChange[] }
| { refused?: undefined; runId: string; outcome: { code: number | null; timed_out: boolean; cancelled: boolean; summary: string }; changes: AiChange[]; diff: string };
let jobSeq = 0;
/** A fresh job id for one background run (progress events and cancel refer to it). */
export const newJob = (kind: string): string => `${kind.replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}-${++jobSeq}`;
export const api = {
appInfo: () => invoke<AppInfo>('app_info'),
sidecarStatus: () => invoke<SidecarStatus>('sidecar_status'),
/** Reads, or dry runs of writes (the host forces dryRun for writes coming from here). */
call: <T = ToolResult>(method: string, projectId?: string, params?: { args?: Record<string, unknown> }, job?: string) =>
invoke<T>('sidecar_call', { method, projectId: projectId ?? null, params: params ?? null, job: job ?? null }),
/** Last cached read (projectId "*" = the whole live portfolio); null when never read. */
cached: <T = unknown>(method: string, projectId: string, args: Record<string, unknown> = {}) =>
invoke<CacheEntry<T> | null>('cache_get', { method, projectId, args }),
clearCache: () => invoke<void>('cache_clear'),
cancel: (job: string) => invoke<boolean>('job_cancel', { job }),
/** Fix with AI (src-tauri/src/ai_fix.rs): claude installed?, the prompt (also for Copy for AI), the dirty files. */
aiFixInfo: (projectId: string, finding: AiFinding) => invoke<AiFixInfo>('ai_fix_info', { projectId, finding }),
aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke<AiFixRun>('ai_fix_run', { projectId, finding, job }),
aiFixUndo: (runId: string) => invoke<string[]>('ai_fix_undo', { runId }),
/** Dry run, native confirm dialog with that dry run, then the real run with a host-minted token. */
writeWithConfirm: (method: string, projectId: string, args: Record<string, unknown> = {}, job?: string) =>
invoke<WriteOutcome>('write_with_confirm', { method, projectId, args, job: job ?? null }),
projects: () => invoke<Project[]>('projects_list'),
addProject: () => invoke<Project | null>('project_add'),
initProject: (profile: 'app' | 'game' | 'subscription-app') => invoke<Project | null>('project_init', { profile }),
removeProject: (id: string) => invoke<void>('project_remove', { id }),
setProjectAccounts: (id: string, play: string | null, apple: string | null) => invoke<Project>('project_set_accounts', { id, play, apple }),
accounts: () => invoke<Account[]>('accounts_list'),
addPlayAccount: (label: string, studio: string | null = null) => invoke<Account | null>('account_add_play', { label, studio }),
addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke<Account | null>('account_add_apple', { label, keyId, issuerId, studio }),
removeAccount: (id: string) => invoke<void>('account_remove', { id }),
updateAccount: (id: string, label: string, studio: string | null) => invoke<Account>('account_update', { id, label, studio }),
/** Does the key work, can it see apps (live, not cached). */
checkAccount: (id: string, job?: string) => invoke<AccountCheck>('account_check', { id, job: job ?? null }),
/** Reads the apps an account can see from its store and keeps the list. */
refreshStoreApps: (id: string, job?: string) => invoke<CacheEntry<AppsListResult>>('store_apps_refresh', { id, job: job ?? null }),
storeAppsCached: () => invoke<AccountApps[]>('store_apps_cached'),
workspace: () => invoke<Workspace>('workspace_get'),
saveStudio: (id: string | null, name: string, color: StudioColor) => invoke<Studio>('studio_save', { id, name, color }),
removeStudio: (id: string) => invoke<void>('studio_remove', { id }),
studioLogo: (id: string, clear = false) => invoke<Studio | null>('studio_logo', { id, clear }),
setOverrides: (overrides: Overrides) => invoke<Overrides>('overrides_set', { overrides }),
setSettings: (settings: Settings) => invoke<Settings>('settings_set', { settings }),
cacheInfo: () => invoke<CacheInfo>('cache_info'),
revealData: () => invoke<void>('data_reveal'),
/** target: a route (JSON) the window opens on when it is next shown from the tray (the notification plugin has no click callback on desktop). */
notify: (title: string, body: string, target?: string) => invoke<void>('notify', { title, body, target: target ?? null }),
/** Pushes the tray's tooltip, attention flag and app list after a read (lib/tray.ts). */
trayUpdate: (summary: TraySummary) => invoke<void>('tray_update', { summary }),
/** Native confirmation first; true when everything was forgotten. */
reset: () => invoke<boolean>('app_reset'),
/** Offline (folders) or live; live also reads the given store-only apps (store targets). */
portfolio: (live = false, job?: string, stores: string[] = []) => invoke<PortfolioResult>('portfolio_status', { live, stores, job: job ?? null }),
openExternal: (url: string) => invoke<void>('open_external', { url }),
toolsInfo: () => invoke<DownloadInfo[]>('tools_info'),
installTool: (tool: 'ffmpeg' | 'chrome', job: string) => invoke<string>('tools_install', { tool, job }),
uninstallTool: (tool: 'ffmpeg' | 'chrome') => invoke<void>('tools_uninstall', { tool }),
/** Asks storebound.dev for a newer version on the chosen channel (installs nothing). */
updateCheck: () => invoke<UpdateInfo>('update_check'),
/** Native dialog, then download, signature check, install and restart. False = not installed. */
updateInstall: (version: string) => invoke<boolean>('update_install', { version }),
/** Native folder picker, the import plan in a native dialog, then `storebound import fastlane` and the folder is added. */
importFastlane: (profile: 'app' | 'game' | 'subscription-app' | null = null) => invoke<Project | null>('project_import_fastlane', { profile }),
/** Native save dialog (Downloads first); `personal` shows the personal-data note first. The path written (~), or null. */
exportSave: (name: string, kind: ExportKind, content: string, personal = false) => invoke<string | null>('export_save', { name, kind, content, personal }),
/** Native open dialog for a price sheet (CSV); its path, or null. */
pickPriceSheet: (projectId: string) => invoke<string | null>('price_sheet_pick', { projectId }),
/** Native open dialog for an image (PNG / JPEG) to replace one store image with; its path, or null. */
pickImage: (projectId: string) => invoke<string | null>('image_pick', { projectId }),
/** Shows a path of an app folder in the file manager, or opens a folder / the gallery page. */
reveal: (projectId: string, rel = '', open = false) => invoke<void>('project_reveal', { projectId, rel, open }),
/** "Locate…" for a folder that is gone: native folder picker; the same app with its new path, or null. */
locateProject: (id: string) => invoke<Project | null>('project_locate', { id }),
/** Native confirmation, then state.json is moved aside (a backup) and the app starts empty. The backup path (~) or null. */
resetState: () => invoke<string | null>('state_reset'),
/** One entry in <app data>/logs/webview.log (sanitized and rate-limited by the host). */
logWebview: (level: 'error' | 'warn' | 'info', message: string, detail?: string) => invoke<void>('log_webview', { level, message, detail: detail ?? null }),
};
export type ExportKind = 'csv' | 'md' | 'json' | 'txt';
/** Typed reads of the project files (offline) and the stores (network). */
export const reads = {
icon: (projectId: string) => api.call<ToolResult & { data?: IconResult }>('project.icon', projectId),
listings: (projectId: string) => api.call<ToolResult & { data?: ListingsResult }>('project.listings', projectId),
drafts: (projectId: string) => api.call<ToolResult & { data?: DraftsResult }>('reviews.drafts', projectId),
listingText: (projectId: string) => api.call<ToolResult & { data?: ListingText }>('project.listing_text', projectId),
declarations: (projectId: string) => api.call<ToolResult & { data?: DeclarationsResult }>('project.declarations', projectId),
renders: (projectId: string) => api.call<ToolResult & { data?: RendersResult }>('project.renders', projectId),
};
export interface ProgressEvent extends ProgressParams { job?: string }
export const onProgress = (cb: (p: ProgressEvent) => void): Promise<UnlistenFn> =>
listen<{ params: ProgressParams; job?: string }>('sidecar-progress', e => cb({ ...e.params, job: e.job }));
/** One progress line of a Fix with AI run. */
export const onAiFix = (cb: (e: { job: string; line: string }) => void): Promise<UnlistenFn> => listen<{ job: string; line: string }>('ai-fix', cb);
export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn> => listen<DownloadEvent>('tool-download', cb);
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
/** storebound://open?path=... selected or added an app folder (its project id). */
export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise<UnlistenFn> => listen<string>('deep-link-folder', cb);
/** A tray menu click (or the first window show after a notification) that opens a route (JSON, see parseRouteJson). */
export const onTrayRoute = (cb: (route: string) => void): Promise<UnlistenFn> => listen<string>('tray-route', cb);
/** A tray menu click that is not navigation: read the stores now, toggle mute. */
export const onTrayAction = (cb: (action: 'read' | 'mute-toggle') => void): Promise<UnlistenFn> => listen<'read' | 'mute-toggle'>('tray-action', cb);
export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb);