162 lines
14 KiB
TypeScript
162 lines
14 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';
|
|
|
|
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 };
|
|
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<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));
|
|
}
|
|
|
|
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 }),
|
|
/** 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'),
|
|
notify: (title: string, body: string) => invoke<void>('notify', { title, body }),
|
|
/** 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 }));
|
|
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);
|
|
export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb);
|