287 lines
7.3 KiB
TypeScript
287 lines
7.3 KiB
TypeScript
import {
|
|
AppError,
|
|
mapBackendErrorCode,
|
|
normalizeAppError,
|
|
type AppErrorCode,
|
|
} from './error-model';
|
|
|
|
export { AppError } from './error-model';
|
|
|
|
type UnifiedRequest = {
|
|
id: string;
|
|
module: string;
|
|
action: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type UnifiedResponse = {
|
|
id?: string;
|
|
ok: boolean;
|
|
data?: unknown;
|
|
error?: {
|
|
code?: string;
|
|
message?: string;
|
|
details?: unknown;
|
|
};
|
|
};
|
|
|
|
const UNIFIED_CHANNELS = new Set<string>([
|
|
'app:version',
|
|
'app:name',
|
|
'app:platform',
|
|
'opencode:status',
|
|
'settings:getAll',
|
|
'settings:get',
|
|
'settings:set',
|
|
'settings:setMany',
|
|
'settings:reset',
|
|
'provider:list',
|
|
'provider:get',
|
|
'provider:getDefault',
|
|
'provider:hasApiKey',
|
|
'provider:getApiKey',
|
|
'provider:validateKey',
|
|
'provider:save',
|
|
'provider:delete',
|
|
'provider:setApiKey',
|
|
'provider:updateWithKey',
|
|
'provider:deleteApiKey',
|
|
'provider:setDefault',
|
|
'update:status',
|
|
'update:version',
|
|
'update:check',
|
|
'update:download',
|
|
'update:install',
|
|
'update:setChannel',
|
|
'update:setAutoDownload',
|
|
'update:cancelAutoInstall',
|
|
]);
|
|
|
|
function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest {
|
|
const splitIndex = channel.indexOf(':');
|
|
return {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
module: channel.slice(0, splitIndex),
|
|
action: channel.slice(splitIndex + 1),
|
|
payload: args.length <= 1 ? args[0] : args,
|
|
};
|
|
}
|
|
|
|
function mapUnifiedError(response: UnifiedResponse): AppError {
|
|
const message = response.error?.message || 'Unified IPC request failed';
|
|
return new AppError(mapBackendErrorCode(response.error?.code), message, response.error?.details);
|
|
}
|
|
|
|
async function invokeViaIpc<T>(channel: string, args: unknown[]): Promise<T> {
|
|
if (channel !== 'app:request' && UNIFIED_CHANNELS.has(channel)) {
|
|
const request = toUnifiedRequest(channel, args);
|
|
|
|
try {
|
|
const response = await window.electron.ipcRenderer.invoke('app:request', request) as UnifiedResponse;
|
|
if (!response?.ok) {
|
|
const message = response?.error?.message || 'Unified IPC request failed';
|
|
if (message.includes('APP_REQUEST_UNSUPPORTED:')) {
|
|
throw new Error(message);
|
|
}
|
|
throw mapUnifiedError(response);
|
|
}
|
|
return response.data as T;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (
|
|
!message.includes('APP_REQUEST_UNSUPPORTED:')
|
|
&& !message.includes('Invalid IPC channel: app:request')
|
|
) {
|
|
throw normalizeAppError(error, { transport: 'ipc', channel, source: 'app:request' });
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await window.electron.ipcRenderer.invoke(channel, ...args) as T;
|
|
} catch (error) {
|
|
throw normalizeAppError(error, { transport: 'ipc', channel, source: 'legacy-ipc' });
|
|
}
|
|
}
|
|
|
|
export async function invokeApi<T>(channel: string, ...args: unknown[]): Promise<T> {
|
|
return invokeViaIpc<T>(channel, args);
|
|
}
|
|
|
|
export async function invokeIpc<T>(channel: string, ...args: unknown[]): Promise<T> {
|
|
return invokeApi<T>(channel, ...args);
|
|
}
|
|
|
|
export async function invokeIpcWithRetry<T>(
|
|
channel: string,
|
|
args: unknown[] = [],
|
|
retries = 1,
|
|
retryable: AppErrorCode[] = ['TIMEOUT', 'NETWORK'],
|
|
): Promise<T> {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
try {
|
|
return await invokeApi<T>(channel, ...args);
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!(error instanceof AppError) || !retryable.includes(error.code) || attempt === retries) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw normalizeAppError(lastError);
|
|
}
|
|
|
|
export function initializeDefaultTransports(): void {
|
|
// IPC is the only renderer-to-main transport in the opencode-based app.
|
|
}
|
|
|
|
export function toUserMessage(error: unknown): string {
|
|
const appError = error instanceof AppError ? error : normalizeAppError(error);
|
|
|
|
switch (appError.code) {
|
|
case 'AUTH_INVALID':
|
|
return 'Authentication failed. Check API key or login session and retry.';
|
|
case 'TIMEOUT':
|
|
return 'Request timed out. Please retry.';
|
|
case 'RATE_LIMIT':
|
|
return 'Too many requests. Please wait and try again.';
|
|
case 'PERMISSION':
|
|
return 'Permission denied. Check your configuration and retry.';
|
|
case 'CHANNEL_UNAVAILABLE':
|
|
return 'Service channel unavailable. Retry after restarting Makelore.';
|
|
case 'NETWORK':
|
|
return 'Network error. Please verify connectivity and retry.';
|
|
case 'CONFIG':
|
|
return 'Configuration is invalid. Please review settings.';
|
|
case 'RUNTIME':
|
|
return 'Runtime is unavailable. Start or restart it and retry.';
|
|
default:
|
|
return appError.message || 'Unexpected error occurred.';
|
|
}
|
|
}
|
|
|
|
export type FilePreviewError =
|
|
| 'outsideSandbox'
|
|
| 'readOnlyRoot'
|
|
| 'tooLarge'
|
|
| 'binary'
|
|
| 'notFound'
|
|
| 'notDirectory'
|
|
| 'invalidContent'
|
|
| string;
|
|
|
|
export interface ReadTextFileResult {
|
|
ok: boolean;
|
|
content?: string;
|
|
mimeType?: string;
|
|
size?: number;
|
|
readOnly?: boolean;
|
|
error?: FilePreviewError;
|
|
}
|
|
|
|
export interface ReadBinaryFileResult {
|
|
ok: boolean;
|
|
data?: Uint8Array;
|
|
mimeType?: string;
|
|
size?: number;
|
|
readOnly?: boolean;
|
|
error?: FilePreviewError;
|
|
}
|
|
|
|
export interface ReadBinaryFileOptions {
|
|
maxBytes?: number;
|
|
}
|
|
|
|
export interface WriteTextFileResult {
|
|
ok: boolean;
|
|
error?: FilePreviewError;
|
|
}
|
|
|
|
export interface StatFileResult {
|
|
ok: boolean;
|
|
size?: number;
|
|
mtime?: number;
|
|
isFile?: boolean;
|
|
isDir?: boolean;
|
|
readOnly?: boolean;
|
|
error?: FilePreviewError;
|
|
}
|
|
|
|
export interface ListDirEntry {
|
|
name: string;
|
|
path: string;
|
|
isDir: boolean;
|
|
size: number;
|
|
}
|
|
|
|
export interface ListDirResult {
|
|
ok: boolean;
|
|
entries?: ListDirEntry[];
|
|
error?: FilePreviewError;
|
|
}
|
|
|
|
export interface TreeNode {
|
|
name: string;
|
|
relPath: string;
|
|
absPath: string;
|
|
isDir: boolean;
|
|
size?: number;
|
|
mtime?: number;
|
|
children?: TreeNode[];
|
|
}
|
|
|
|
export interface ListTreeOptions {
|
|
maxDepth?: number;
|
|
maxNodes?: number;
|
|
includeHidden?: boolean;
|
|
}
|
|
|
|
export interface ListTreeResult {
|
|
ok: boolean;
|
|
root?: TreeNode;
|
|
truncated?: boolean;
|
|
error?: FilePreviewError;
|
|
}
|
|
|
|
export const readTextFile = (path: string): Promise<ReadTextFileResult> =>
|
|
invokeIpc<ReadTextFileResult>('file:readText', path);
|
|
|
|
export const readBinaryFile = (
|
|
path: string,
|
|
opts?: ReadBinaryFileOptions,
|
|
): Promise<ReadBinaryFileResult> =>
|
|
invokeIpc<ReadBinaryFileResult>('file:readBinary', path, opts);
|
|
|
|
export const writeTextFile = (path: string, content: string): Promise<WriteTextFileResult> =>
|
|
invokeIpc<WriteTextFileResult>('file:writeText', path, content);
|
|
|
|
interface SaveTranscriptResult {
|
|
status: 'saved' | 'cancelled';
|
|
}
|
|
|
|
export async function saveMarkdownTranscript(
|
|
defaultPath: string,
|
|
markdown: string,
|
|
): Promise<'saved' | 'cancelled'> {
|
|
const result = await invokeIpc<SaveTranscriptResult>('transcript:save', {
|
|
defaultPath,
|
|
markdown,
|
|
});
|
|
if (result.status !== 'saved' && result.status !== 'cancelled') {
|
|
throw new Error('Invalid transcript save result');
|
|
}
|
|
return result.status;
|
|
}
|
|
|
|
export const statFile = (path: string): Promise<StatFileResult> =>
|
|
invokeIpc<StatFileResult>('file:stat', path);
|
|
|
|
export const listDir = (path: string): Promise<ListDirResult> =>
|
|
invokeIpc<ListDirResult>('file:listDir', path);
|
|
|
|
export const listTree = (path: string, opts?: ListTreeOptions): Promise<ListTreeResult> =>
|
|
invokeIpc<ListTreeResult>('file:listTree', path, opts);
|