// 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'; 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[]; 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 }; notify: { reviewState: boolean; lowReviews: boolean; crash: boolean }; 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, lowReviews: true, crash: true }, channel: 'stable' }; export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings } export interface AccountApps { account: string; entry: CacheEntry | 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 { 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(cmd: string, args?: Record): Promise { if (import.meta.env.DEV && import.meta.env.VITE_FIXTURES) return (await import('./dev/fixtures')).mockInvoke(cmd, args ?? {}); return tauriInvoke(cmd, args); } async function listen(event: string, cb: (payload: T) => void): Promise { if (import.meta.env.DEV && import.meta.env.VITE_FIXTURES) return (await import('./dev/fixtures')).mockListen(event, cb as (p: unknown) => void); return tauriListen(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('app_info'), sidecarStatus: () => invoke('sidecar_status'), /** Reads, or dry runs of writes (the host forces dryRun for writes coming from here). */ call: (method: string, projectId?: string, params?: { args?: Record }, job?: string) => invoke('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: (method: string, projectId: string, args: Record = {}) => invoke | null>('cache_get', { method, projectId, args }), clearCache: () => invoke('cache_clear'), cancel: (job: string) => invoke('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('ai_fix_info', { projectId, finding }), aiFixRun: (projectId: string, finding: AiFinding, job: string) => invoke('ai_fix_run', { projectId, finding, job }), aiFixUndo: (runId: string) => invoke('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 = {}, job?: string) => invoke('write_with_confirm', { method, projectId, args, job: job ?? null }), projects: () => invoke('projects_list'), addProject: () => invoke('project_add'), initProject: (profile: 'app' | 'game' | 'subscription-app') => invoke('project_init', { profile }), removeProject: (id: string) => invoke('project_remove', { id }), setProjectAccounts: (id: string, play: string | null, apple: string | null) => invoke('project_set_accounts', { id, play, apple }), accounts: () => invoke('accounts_list'), addPlayAccount: (label: string, studio: string | null = null) => invoke('account_add_play', { label, studio }), addAppleAccount: (label: string, keyId: string, issuerId: string, studio: string | null = null) => invoke('account_add_apple', { label, keyId, issuerId, studio }), removeAccount: (id: string) => invoke('account_remove', { id }), updateAccount: (id: string, label: string, studio: string | null) => invoke('account_update', { id, label, studio }), /** Does the key work, can it see apps (live, not cached). */ checkAccount: (id: string, job?: string) => invoke('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>('store_apps_refresh', { id, job: job ?? null }), storeAppsCached: () => invoke('store_apps_cached'), workspace: () => invoke('workspace_get'), saveStudio: (id: string | null, name: string, color: StudioColor) => invoke('studio_save', { id, name, color }), removeStudio: (id: string) => invoke('studio_remove', { id }), studioLogo: (id: string, clear = false) => invoke('studio_logo', { id, clear }), setOverrides: (overrides: Overrides) => invoke('overrides_set', { overrides }), setSettings: (settings: Settings) => invoke('settings_set', { settings }), cacheInfo: () => invoke('cache_info'), revealData: () => invoke('data_reveal'), notify: (title: string, body: string) => invoke('notify', { title, body }), /** Native confirmation first; true when everything was forgotten. */ reset: () => invoke('app_reset'), /** Offline (folders) or live; live also reads the given store-only apps (store targets). */ portfolio: (live = false, job?: string, stores: string[] = []) => invoke('portfolio_status', { live, stores, job: job ?? null }), openExternal: (url: string) => invoke('open_external', { url }), toolsInfo: () => invoke('tools_info'), installTool: (tool: 'ffmpeg' | 'chrome', job: string) => invoke('tools_install', { tool, job }), uninstallTool: (tool: 'ffmpeg' | 'chrome') => invoke('tools_uninstall', { tool }), /** Asks storebound.dev for a newer version on the chosen channel (installs nothing). */ updateCheck: () => invoke('update_check'), /** Native dialog, then download, signature check, install and restart. False = not installed. */ updateInstall: (version: string) => invoke('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_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('export_save', { name, kind, content, personal }), /** Native open dialog for a price sheet (CSV); its path, or null. */ pickPriceSheet: (projectId: string) => invoke('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('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('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_locate', { id }), /** Native confirmation, then state.json is moved aside (a backup) and the app starts empty. The backup path (~) or null. */ resetState: () => invoke('state_reset'), /** One entry in /logs/webview.log (sanitized and rate-limited by the host). */ logWebview: (level: 'error' | 'warn' | 'info', message: string, detail?: string) => invoke('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('project.icon', projectId), listings: (projectId: string) => api.call('project.listings', projectId), drafts: (projectId: string) => api.call('reviews.drafts', projectId), listingText: (projectId: string) => api.call('project.listing_text', projectId), declarations: (projectId: string) => api.call('project.declarations', projectId), renders: (projectId: string) => api.call('project.renders', projectId), }; export interface ProgressEvent extends ProgressParams { job?: string } export const onProgress = (cb: (p: ProgressEvent) => void): Promise => 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 => listen<{ job: string; line: string }>('ai-fix', cb); export const onDownload = (cb: (e: DownloadEvent) => void): Promise => listen('tool-download', cb); export const onDeepLink = (cb: (screen: string) => void): Promise => listen('deep-link', cb); /** storebound://open?path=... selected or added an app folder (its project id). */ export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise => listen('deep-link-folder', cb); export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise => listen('update-progress', cb);