Implement RPC contract validation baseline
This commit is contained in:
@@ -1,5 +1,19 @@
|
||||
{
|
||||
"name": "@mosaiciq/desktop",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:bundle": "tsdown --watch",
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaiciq/contracts": "workspace:*",
|
||||
"@mosaiciq/shared": "workspace:*",
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"tsdown": "^0.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
192
apps/desktop/src/dataRefresh.ts
Normal file
192
apps/desktop/src/dataRefresh.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Background data refresh service
|
||||
* Updates prices, filings, and earnings data on a schedule
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from "electron";
|
||||
import type { Db } from "@mosaiciq/shared/db";
|
||||
import { fetchQuote, fetchFilings } from "@mosaiciq/shared/data";
|
||||
import { upsertCompany } from "@mosaiciq/shared/db";
|
||||
import { listFilings } from "@mosaiciq/shared/db";
|
||||
|
||||
export interface DataRefreshOptions {
|
||||
priceInterval?: number; // minutes
|
||||
filingInterval?: number; // minutes
|
||||
earningsInterval?: number; // minutes
|
||||
}
|
||||
|
||||
export function startDataRefresh(
|
||||
db: Db,
|
||||
mainWindow: BrowserWindow,
|
||||
options: DataRefreshOptions = {}
|
||||
): () => void {
|
||||
const {
|
||||
priceInterval = 5,
|
||||
filingInterval = 60,
|
||||
earningsInterval = 1440, // daily
|
||||
} = options;
|
||||
|
||||
let priceTimeout: NodeJS.Timeout | null = null;
|
||||
let filingTimeout: NodeJS.Timeout | null = null;
|
||||
let earningsTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Get all portfolio holdings
|
||||
function getPortfolioTickers(): string[] {
|
||||
const stmt = db.prepare(`
|
||||
SELECT DISTINCT ticker FROM holdings
|
||||
UNION
|
||||
SELECT DISTINCT ticker FROM companies
|
||||
`);
|
||||
const rows = stmt.all() as Array<{ ticker: string }>;
|
||||
return rows.map((r) => r.ticker);
|
||||
}
|
||||
|
||||
// Refresh prices for all tickers
|
||||
async function refreshPrices() {
|
||||
try {
|
||||
const tickers = getPortfolioTickers();
|
||||
if (tickers.length === 0) return;
|
||||
|
||||
console.log(`[DataRefresh] Refreshing prices for ${tickers.length} tickers`);
|
||||
|
||||
for (const ticker of tickers) {
|
||||
const quote = await fetchQuote(ticker);
|
||||
if (quote) {
|
||||
// Update company price in database
|
||||
const company = db.prepare("SELECT * FROM companies WHERE ticker = ?").get(ticker) as any;
|
||||
if (company) {
|
||||
db.prepare(`
|
||||
UPDATE companies
|
||||
SET price = ?, change_pct = ?, updated_at = datetime('now')
|
||||
WHERE ticker = ?
|
||||
`).run(quote.price, quote.changePercent, ticker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify UI of price updates
|
||||
mainWindow?.webContents.send("data:prices-updated", {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log("[DataRefresh] Price refresh complete");
|
||||
} catch (error) {
|
||||
console.error("[DataRefresh] Error refreshing prices:", error);
|
||||
}
|
||||
|
||||
// Schedule next refresh
|
||||
priceTimeout = setTimeout(refreshPrices, priceInterval * 60 * 1000);
|
||||
}
|
||||
|
||||
// Check for new filings
|
||||
async function refreshFilings() {
|
||||
try {
|
||||
const tickers = getPortfolioTickers();
|
||||
if (tickers.length === 0) return;
|
||||
|
||||
console.log(`[DataRefresh] Checking for new filings for ${tickers.length} tickers`);
|
||||
|
||||
for (const ticker of tickers) {
|
||||
// Get most recent filing date from database
|
||||
const existingFilings = listFilings(db, ticker);
|
||||
const since = existingFilings.length > 0
|
||||
? existingFilings[0].filedDate
|
||||
: undefined;
|
||||
|
||||
const newFilings = await fetchFilings(ticker, { limit: 10, since });
|
||||
|
||||
for (const filing of newFilings) {
|
||||
// Check if filing already exists
|
||||
const exists = db.prepare(`
|
||||
SELECT id FROM filings WHERE company_id = ? AND filed_date = ? AND form_type = ?
|
||||
`).get(ticker, filing.filedDate, filing.formType);
|
||||
|
||||
if (!exists) {
|
||||
db.prepare(`
|
||||
INSERT INTO filings (id, company_id, form_type, filed_date, title)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`${ticker}-${filing.formType}-${filing.filedDate}`,
|
||||
ticker,
|
||||
filing.formType,
|
||||
filing.filedDate,
|
||||
filing.title
|
||||
);
|
||||
|
||||
// Notify UI of new filing
|
||||
mainWindow?.webContents.send("alert:new-filing", {
|
||||
ticker,
|
||||
formType: filing.formType,
|
||||
title: filing.title,
|
||||
filedDate: filing.filedDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[DataRefresh] Filing refresh complete");
|
||||
} catch (error) {
|
||||
console.error("[DataRefresh] Error refreshing filings:", error);
|
||||
}
|
||||
|
||||
// Schedule next refresh
|
||||
filingTimeout = setTimeout(refreshFilings, filingInterval * 60 * 1000);
|
||||
}
|
||||
|
||||
// Refresh earnings dates
|
||||
async function refreshEarnings() {
|
||||
try {
|
||||
const tickers = getPortfolioTickers();
|
||||
if (tickers.length === 0) return;
|
||||
|
||||
console.log(`[DataRefresh] Updating earnings dates for ${tickers.length} tickers`);
|
||||
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { getEarningsDate, getQuarterString } = await import("@mosaiciq/shared/data");
|
||||
|
||||
for (const ticker of tickers) {
|
||||
const earningsDate = await getEarningsDate(ticker);
|
||||
if (earningsDate) {
|
||||
// Check if earnings schedule exists
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM earnings_schedules WHERE company_id = ? AND expected_date = ?
|
||||
`).get(ticker, earningsDate.toISOString());
|
||||
|
||||
if (!existing) {
|
||||
const quarter = getQuarterString(earningsDate);
|
||||
db.prepare(`
|
||||
INSERT INTO earnings_schedules (id, company_id, quarter, expected_date)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(
|
||||
`earnings-${ticker}-${Date.now()}`,
|
||||
ticker,
|
||||
quarter,
|
||||
earningsDate.toISOString()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[DataRefresh] Earnings refresh complete");
|
||||
} catch (error) {
|
||||
console.error("[DataRefresh] Error refreshing earnings:", error);
|
||||
}
|
||||
|
||||
// Schedule next refresh
|
||||
earningsTimeout = setTimeout(refreshEarnings, earningsInterval * 60 * 1000);
|
||||
}
|
||||
|
||||
// Start all refresh cycles
|
||||
console.log("[DataRefresh] Starting data refresh service");
|
||||
priceTimeout = setTimeout(refreshPrices, 1000); // Start immediately
|
||||
filingTimeout = setTimeout(refreshFilings, 5000); // Start after 5s
|
||||
earningsTimeout = setTimeout(refreshEarnings, 10000); // Start after 10s
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (priceTimeout) clearTimeout(priceTimeout);
|
||||
if (filingTimeout) clearTimeout(filingTimeout);
|
||||
if (earningsTimeout) clearTimeout(earningsTimeout);
|
||||
console.log("[DataRefresh] Stopped data refresh service");
|
||||
};
|
||||
}
|
||||
165
apps/desktop/src/fileHandler.ts
Normal file
165
apps/desktop/src/fileHandler.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* File download handler for Electron
|
||||
* Manages save dialogs and file downloads
|
||||
*/
|
||||
|
||||
import { dialog, shell } from "electron";
|
||||
import type { BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { z } from "zod";
|
||||
import { registerIpcMethod } from "./ipc.js";
|
||||
|
||||
export interface FileDownloadOptions {
|
||||
defaultName: string;
|
||||
filters: Array<{ name: string; extensions: string[] }>;
|
||||
data: Buffer;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file download with save dialog
|
||||
*/
|
||||
export async function handleFileDownload(
|
||||
mainWindow: BrowserWindow,
|
||||
options: FileDownloadOptions
|
||||
): Promise<{ saved: boolean; filePath?: string; error?: string }> {
|
||||
const { defaultName, filters, data, mimeType } = options;
|
||||
|
||||
try {
|
||||
// Show save dialog
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
defaultPath: defaultName,
|
||||
filters,
|
||||
properties: ["createDirectory"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { saved: false };
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(result.filePath);
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
|
||||
// Write file
|
||||
writeFileSync(result.filePath, data);
|
||||
|
||||
// Open file with default application
|
||||
await shell.openPath(result.filePath);
|
||||
|
||||
return { saved: true, filePath: result.filePath };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[FileHandler] Error saving file:", errorMsg);
|
||||
return { saved: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file to temp directory and return path
|
||||
*/
|
||||
export function saveToTemp(
|
||||
filename: string,
|
||||
data: Buffer
|
||||
): { filePath: string; error?: string } {
|
||||
try {
|
||||
const tempDir = path.join(tmpdir(), "mosaiciq");
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(tempDir, filename);
|
||||
writeFileSync(filePath, data);
|
||||
|
||||
return { filePath };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error("[FileHandler] Error saving to temp:", errorMsg);
|
||||
return { filePath: "", error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open file with default application
|
||||
*/
|
||||
export async function openFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await shell.openPath(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[FileHandler] Error opening file:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show file in default file manager
|
||||
*/
|
||||
export async function showInFolder(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await shell.showItemInFolder(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[FileHandler] Error showing in folder:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filters for file type
|
||||
*/
|
||||
export function getFileFilters(type: "pdf" | "excel" | "ppt"): Array<{ name: string; extensions: string[] }> {
|
||||
const filters: Record<string, Array<{ name: string; extensions: string[] }>> = {
|
||||
pdf: [{ name: "PDF Files", extensions: ["pdf"] }],
|
||||
excel: [{ name: "Excel Files", extensions: ["xlsx", "xls"] }],
|
||||
ppt: [{ name: "PowerPoint Files", extensions: ["pptx", "ppt"] }],
|
||||
};
|
||||
|
||||
return filters[type] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for file operations
|
||||
*/
|
||||
export function registerFileHandlers(mainWindow: BrowserWindow) {
|
||||
const FileDownloadOptionsSchema: z.ZodType<FileDownloadOptions> = z.object({
|
||||
defaultName: z.string().trim().min(1),
|
||||
filters: z.array(z.object({
|
||||
name: z.string().trim().min(1),
|
||||
extensions: z.array(z.string().trim().min(1)),
|
||||
})),
|
||||
data: z.custom<Buffer>((value) => Buffer.isBuffer(value), { message: "Expected Buffer" }),
|
||||
mimeType: z.string().optional(),
|
||||
});
|
||||
|
||||
const FileSaveResultSchema = z.object({
|
||||
saved: z.boolean(),
|
||||
filePath: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
registerIpcMethod({
|
||||
channel: "file:save",
|
||||
input: FileDownloadOptionsSchema,
|
||||
output: FileSaveResultSchema,
|
||||
handler: (options) => handleFileDownload(mainWindow, options),
|
||||
});
|
||||
|
||||
registerIpcMethod({
|
||||
channel: "file:open",
|
||||
input: z.string().trim().min(1),
|
||||
output: z.boolean(),
|
||||
handler: openFile,
|
||||
});
|
||||
|
||||
registerIpcMethod({
|
||||
channel: "file:showInFolder",
|
||||
input: z.string().trim().min(1),
|
||||
output: z.boolean(),
|
||||
handler: showInFolder,
|
||||
});
|
||||
}
|
||||
63
apps/desktop/src/ipc.test.ts
Normal file
63
apps/desktop/src/ipc.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const handlers = new Map<string, (event: unknown, input: unknown) => Promise<unknown>>();
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, input: unknown) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("registerIpcMethod", () => {
|
||||
beforeEach(() => {
|
||||
handlers.clear();
|
||||
});
|
||||
|
||||
it("calls handler for valid payloads", async () => {
|
||||
const { registerIpcMethod } = await import("./ipc.js");
|
||||
const handler = vi.fn((input: { name: string }) => ({ greeting: `hello ${input.name}` }));
|
||||
registerIpcMethod({
|
||||
channel: "test:valid",
|
||||
input: z.object({ name: z.string().min(1) }),
|
||||
output: z.object({ greeting: z.string() }),
|
||||
handler,
|
||||
});
|
||||
|
||||
await expect(handlers.get("test:valid")?.({}, { name: "Ada" })).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { greeting: "hello Ada" },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not call handler for invalid payloads", async () => {
|
||||
const { registerIpcMethod } = await import("./ipc.js");
|
||||
const handler = vi.fn();
|
||||
registerIpcMethod({
|
||||
channel: "test:invalid-input",
|
||||
input: z.object({ name: z.string().min(1) }),
|
||||
output: z.object({ greeting: z.string() }),
|
||||
handler,
|
||||
});
|
||||
|
||||
const result = await handlers.get("test:invalid-input")?.({}, { name: "" });
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "VALIDATION_ERROR" } });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a controlled error for invalid handler output", async () => {
|
||||
const { registerIpcMethod } = await import("./ipc.js");
|
||||
registerIpcMethod({
|
||||
channel: "test:invalid-output",
|
||||
input: z.object({ name: z.string() }),
|
||||
output: z.object({ greeting: z.string() }),
|
||||
handler: () => ({ greeting: 123 }) as never,
|
||||
});
|
||||
|
||||
const result = await handlers.get("test:invalid-output")?.({}, { name: "Ada" });
|
||||
expect(result).toMatchObject({ ok: false, error: { code: "INTERNAL_ERROR" } });
|
||||
});
|
||||
});
|
||||
61
apps/desktop/src/ipc.ts
Normal file
61
apps/desktop/src/ipc.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { z } from "zod";
|
||||
|
||||
export type IpcErrorCode = "VALIDATION_ERROR" | "INTERNAL_ERROR";
|
||||
|
||||
export type IpcResult<Output> =
|
||||
| { ok: true; data: Output }
|
||||
| { ok: false; error: { code: IpcErrorCode; message: string; detail?: unknown } };
|
||||
|
||||
export type IpcMethod<Input, Output> = {
|
||||
channel: string;
|
||||
input: z.ZodType<Input>;
|
||||
output: z.ZodType<Output>;
|
||||
handler: (input: Input) => Promise<Output> | Output;
|
||||
};
|
||||
|
||||
function validationDetail(error: z.ZodError): unknown {
|
||||
return error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
export function registerIpcMethod<Input, Output>(method: IpcMethod<Input, Output>): void {
|
||||
ipcMain.handle(method.channel, async (_event, rawInput): Promise<IpcResult<Output>> => {
|
||||
const parsedInput = method.input.safeParse(rawInput);
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid IPC payload.",
|
||||
detail: validationDetail(parsedInput.error),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await method.handler(parsedInput.data);
|
||||
const parsedOutput = method.output.safeParse(output);
|
||||
if (!parsedOutput.success) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Invalid IPC response.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, data: parsedOutput.data };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error instanceof Error ? error.message : "Unhandled IPC failure.",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, globalShortcut } from "electron";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { handleRpc } from "./rpc.js";
|
||||
import type { RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc.js";
|
||||
import { startDataRefresh } from "./dataRefresh.js";
|
||||
import { startAutoSnapshot } from "./snapshotService.js";
|
||||
import { registerFileHandlers } from "./fileHandler.js";
|
||||
import type { ClientSettings, RpcMethod, RpcResult } from "@mosaiciq/contracts/rpc";
|
||||
import { isRpcMethod, parseRpcRequest, parseRpcResponse } from "@mosaiciq/contracts/rpcSchemas";
|
||||
import { getDatabase, seedDatabase, getDataDir, updateClientSettings, getClientSettings } from "@mosaiciq/shared/db";
|
||||
import { eventEmitter } from "@mosaiciq/shared/agents";
|
||||
import { z } from "zod";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const isDev = process.env.VITE_DEV_SERVER_URL || !app.isPackaged;
|
||||
|
||||
async function createWindow() {
|
||||
async function createWindow(): Promise<BrowserWindow> {
|
||||
const win = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 960,
|
||||
@@ -16,7 +24,7 @@ async function createWindow() {
|
||||
title: "MosaicIQ",
|
||||
backgroundColor: "#f8f5ed",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
preload: path.join(__dirname, "preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false
|
||||
@@ -27,15 +35,177 @@ async function createWindow() {
|
||||
await win.loadURL(process.env.VITE_DEV_SERVER_URL ?? "http://127.0.0.1:5173");
|
||||
win.webContents.openDevTools({ mode: "detach" });
|
||||
} else {
|
||||
await win.loadFile(path.join(__dirname, "../../../../apps/web/dist/index.html"));
|
||||
await win.loadFile(path.join(__dirname, "../../web/dist/index.html"));
|
||||
}
|
||||
return win;
|
||||
}
|
||||
|
||||
ipcMain.handle("rpc:call", (_event, method: RpcMethod, payload: RpcRequestMap[RpcMethod]) => {
|
||||
return handleRpc(method, payload);
|
||||
function rpcValidationDetail(error: z.ZodError): unknown {
|
||||
return error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message }));
|
||||
}
|
||||
|
||||
ipcMain.handle("rpc:call", async (_event, method: unknown, payload: unknown): Promise<RpcResult<RpcMethod>> => {
|
||||
if (!isRpcMethod(method)) {
|
||||
return { ok: false, error: { code: "VALIDATION_ERROR", message: "Unknown RPC method." } };
|
||||
}
|
||||
|
||||
let parsedPayload;
|
||||
try {
|
||||
parsedPayload = parseRpcRequest(method, payload);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Invalid RPC payload.",
|
||||
detail: error instanceof z.ZodError ? rpcValidationDetail(error) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await handleRpc(method, parsedPayload);
|
||||
if (!result.ok) return result;
|
||||
|
||||
try {
|
||||
parseRpcResponse(method, result.data);
|
||||
return result;
|
||||
} catch {
|
||||
return { ok: false, error: { code: "INTERNAL_ERROR", message: "Invalid RPC response." } };
|
||||
}
|
||||
});
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
app.whenReady().then(async () => {
|
||||
// Initialize database
|
||||
const db = getDatabase();
|
||||
|
||||
// Load client settings from JSON file if exists
|
||||
const settingsPath = path.join(getDataDir(), "settings.json");
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const saved = JSON.parse(readFileSync(settingsPath, "utf-8")) as Partial<ClientSettings>;
|
||||
// Merge saved settings into database
|
||||
updateClientSettings(db, saved);
|
||||
console.log("[Settings] Loaded settings from", settingsPath);
|
||||
} catch (error) {
|
||||
console.warn("[Settings] Failed to load settings file:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed demo data if empty (check if any companies exist)
|
||||
const companyCount = db.prepare("SELECT COUNT(*) as count FROM companies").get() as { count: number };
|
||||
if (companyCount.count === 0) {
|
||||
seedDatabase(db);
|
||||
}
|
||||
|
||||
const win = await createWindow();
|
||||
|
||||
// Register file handlers
|
||||
registerFileHandlers(win);
|
||||
|
||||
// Start data refresh service
|
||||
const stopDataRefresh = startDataRefresh(db, win, {
|
||||
priceInterval: 5, // 5 minutes
|
||||
filingInterval: 60, // 1 hour
|
||||
earningsInterval: 1440, // daily
|
||||
});
|
||||
|
||||
// Start auto-snapshot service
|
||||
const stopSnapshot = startAutoSnapshot(db, win, {
|
||||
interval: 5, // 5 minutes
|
||||
maxSnapshots: 100,
|
||||
});
|
||||
|
||||
// Register Cmd+S for manual snapshot
|
||||
globalShortcut.register("CommandOrControl+S", async () => {
|
||||
const activeCompanyId = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as {
|
||||
active_company_id: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (activeCompanyId?.active_company_id) {
|
||||
// Get the manual snapshot function from the snapshot service
|
||||
const createManualSnapshot = (globalThis as any).createManualSnapshot;
|
||||
if (createManualSnapshot) {
|
||||
const snapshot = await createManualSnapshot(activeCompanyId.active_company_id);
|
||||
|
||||
// Show notification
|
||||
win.webContents.send("snapshot:notification", {
|
||||
title: "Snapshot Created",
|
||||
message: `Manual snapshot created for ${activeCompanyId.active_company_id.toUpperCase()}`,
|
||||
snapshotId: snapshot?.id,
|
||||
});
|
||||
|
||||
console.log(`[Shortcut] Manual snapshot created for ${activeCompanyId.active_company_id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Register Cmd+Shift+S for labeled snapshot
|
||||
globalShortcut.register("CommandOrControl+Shift+S", async () => {
|
||||
const activeCompanyId = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as {
|
||||
active_company_id: string | null;
|
||||
} | undefined;
|
||||
|
||||
if (activeCompanyId?.active_company_id) {
|
||||
// Send event to renderer to show input dialog
|
||||
win.webContents.send("snapshot:request-label", {
|
||||
companyId: activeCompanyId.active_company_id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Forward event emitter events to renderer process
|
||||
const eventTypes = ["agent.progress", "agent.completed", "agent.failed", "agent.started", "agent.streaming", "validation.updated", "memo.updated", "model.updated"];
|
||||
const unsubscribeForwarders: Array<() => void> = [];
|
||||
|
||||
for (const eventType of eventTypes) {
|
||||
const listener = (data: unknown) => {
|
||||
win.webContents.send("server-event", data);
|
||||
};
|
||||
unsubscribeForwarders.push(eventEmitter.on(eventType, listener));
|
||||
}
|
||||
|
||||
win.on("closed", () => {
|
||||
for (const unsubscribe of unsubscribeForwarders) unsubscribe();
|
||||
});
|
||||
|
||||
// Handle label response from renderer
|
||||
ipcMain.on("snapshot:create-with-label", (_event, { companyId, label }) => {
|
||||
const createManualSnapshot = (globalThis as any).createManualSnapshot;
|
||||
if (createManualSnapshot) {
|
||||
createManualSnapshot(companyId, label || undefined).then((snapshot: any) => {
|
||||
win.webContents.send("snapshot:notification", {
|
||||
title: "Snapshot Created",
|
||||
message: `Snapshot "${label || "Manual"}" created`,
|
||||
snapshotId: snapshot?.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on app quit
|
||||
app.on("before-quit", () => {
|
||||
for (const unsubscribe of unsubscribeForwarders) unsubscribe();
|
||||
stopDataRefresh();
|
||||
stopSnapshot();
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
});
|
||||
|
||||
// Save settings when they change
|
||||
ipcMain.on("settings:save", (_event, settings: Partial<ClientSettings>) => {
|
||||
const db = getDatabase();
|
||||
updateClientSettings(db, settings);
|
||||
|
||||
// Also persist to JSON file for backup and portability
|
||||
const settingsPath = path.join(getDataDir(), "settings.json");
|
||||
try {
|
||||
const currentSettings = getClientSettings(db);
|
||||
writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2), "utf-8");
|
||||
console.log("[Settings] Saved settings to", settingsPath);
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to save settings file:", error);
|
||||
}
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { RpcClient, RpcMethod, RpcRequestMap } from "../../../packages/contracts/src/rpc.js";
|
||||
import type { RpcClient, RpcMethod, RpcRequestMap, ServerEvent } from "../../../packages/contracts/src/rpc.js";
|
||||
import { ServerEventSchema, ServerEventTypeSchema } from "../../../packages/contracts/src/rpcSchemas.js";
|
||||
|
||||
const api: RpcClient = {
|
||||
call(method, payload) {
|
||||
@@ -7,7 +8,67 @@ const api: RpcClient = {
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("mosaic", api);
|
||||
// Event listener management
|
||||
const eventListeners = new Map<string, Set<(data: unknown) => void>>();
|
||||
|
||||
// Subscribe to IPC events from main process
|
||||
ipcRenderer.on("server-event", (_event, eventData) => {
|
||||
const parsedEvent = ServerEventSchema.safeParse(eventData);
|
||||
if (!parsedEvent.success) {
|
||||
console.warn("[IPC] Ignoring invalid server event.");
|
||||
return;
|
||||
}
|
||||
const event = parsedEvent.data as ServerEvent;
|
||||
const listeners = eventListeners.get(event.type);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error(`[IPC] Error in event listener for ${event.type}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("mosaic", {
|
||||
...api,
|
||||
// Subscribe to server events
|
||||
on(eventType: string, callback: (data: unknown) => void) {
|
||||
const parsedEventType = ServerEventTypeSchema.safeParse(eventType);
|
||||
if (!parsedEventType.success) {
|
||||
console.warn(`[IPC] Ignoring subscription for unknown event type: ${eventType}`);
|
||||
return () => {};
|
||||
}
|
||||
const type = parsedEventType.data;
|
||||
if (!eventListeners.has(type)) {
|
||||
eventListeners.set(type, new Set());
|
||||
}
|
||||
eventListeners.get(type)!.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const listeners = eventListeners.get(type);
|
||||
if (listeners) {
|
||||
listeners.delete(callback);
|
||||
if (listeners.size === 0) {
|
||||
eventListeners.delete(type);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
// Remove all listeners for an event type
|
||||
removeAllListeners(eventType?: string) {
|
||||
if (eventType) {
|
||||
const parsedEventType = ServerEventTypeSchema.safeParse(eventType);
|
||||
if (parsedEventType.success) {
|
||||
eventListeners.delete(parsedEventType.data);
|
||||
}
|
||||
} else {
|
||||
eventListeners.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -16,6 +77,8 @@ declare global {
|
||||
method: T,
|
||||
payload: RpcRequestMap[T]
|
||||
): Promise<import("../../../packages/contracts/src/rpc.js").RpcResult<T>>;
|
||||
on(eventType: string, callback: (data: unknown) => void): () => void;
|
||||
removeAllListeners(eventType?: string): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { RpcMethod, RpcRequestMap, RpcResult } from "../../../packages/contracts/src/rpc.js";
|
||||
import { handleMockRpc } from "../../../packages/shared/src/mockRpc.js";
|
||||
import type { RpcMethod, RpcRequestMap, RpcResult } from "@mosaiciq/contracts/rpc";
|
||||
import { getDatabase, createRpcHandler } from "@mosaiciq/shared/db";
|
||||
|
||||
// Get database instance (initialized in main.ts)
|
||||
let dbInstance = getDatabase();
|
||||
|
||||
// Create RPC handler with database
|
||||
const dbRpcHandler = createRpcHandler(dbInstance);
|
||||
|
||||
export async function handleRpc<T extends RpcMethod>(
|
||||
method: T,
|
||||
payload: RpcRequestMap[T]
|
||||
): Promise<RpcResult<T>> {
|
||||
return handleMockRpc(method, payload);
|
||||
return dbRpcHandler(method, payload);
|
||||
}
|
||||
|
||||
export function setDatabase(db: typeof dbInstance): void {
|
||||
dbInstance = db;
|
||||
}
|
||||
|
||||
237
apps/desktop/src/snapshotService.ts
Normal file
237
apps/desktop/src/snapshotService.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Auto-snapshot service
|
||||
* Creates periodic snapshots of company state
|
||||
*/
|
||||
|
||||
import type { BrowserWindow } from "electron";
|
||||
import type { Db } from "@mosaiciq/shared/db";
|
||||
import { createSnapshot, listSnapshots, getSnapshot } from "@mosaiciq/shared/db";
|
||||
import type { Snapshot } from "@mosaiciq/contracts/rpc";
|
||||
|
||||
export interface SnapshotOptions {
|
||||
interval?: number; // minutes
|
||||
maxSnapshots?: number; // per company
|
||||
}
|
||||
|
||||
interface SnapshotChangeTracker {
|
||||
[companyId: string]: {
|
||||
changeCount: number;
|
||||
lastSnapshot: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function startAutoSnapshot(
|
||||
db: Db,
|
||||
mainWindow: BrowserWindow,
|
||||
options: SnapshotOptions = {}
|
||||
): () => void {
|
||||
const {
|
||||
interval = 5, // 5 minutes
|
||||
maxSnapshots = 100,
|
||||
} = options;
|
||||
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const tracker: SnapshotChangeTracker = {};
|
||||
|
||||
// Get active company ID
|
||||
function getActiveCompanyId(): string | null {
|
||||
const row = db.prepare("SELECT active_company_id FROM portfolios WHERE id = 'default'").get() as {
|
||||
active_company_id: string | null;
|
||||
} | undefined;
|
||||
return row?.active_company_id || null;
|
||||
}
|
||||
|
||||
// Capture current company state as JSON
|
||||
function captureCompanyState(companyId: string): string {
|
||||
// Get memo sections
|
||||
const memoSections = db.prepare(`
|
||||
SELECT id, title, content, updated_at, primary_agent
|
||||
FROM memo_sections
|
||||
WHERE company_id = ?
|
||||
`).all(companyId);
|
||||
|
||||
// Get model data
|
||||
const models = db.prepare(`
|
||||
SELECT id, tab FROM models WHERE company_id = ?
|
||||
`).all(companyId);
|
||||
|
||||
const modelData = models.map((model: any) => {
|
||||
const headers = db.prepare("SELECT label FROM model_headers WHERE model_id = ? ORDER BY position").all(model.id);
|
||||
const rows = db.prepare("SELECT label, kind, values FROM model_rows WHERE model_id = ? ORDER BY position").all(model.id);
|
||||
return {
|
||||
id: model.id,
|
||||
tab: model.tab,
|
||||
headers,
|
||||
rows,
|
||||
};
|
||||
});
|
||||
|
||||
// Get workspace sections
|
||||
const workspaceSections = db.prepare(`
|
||||
SELECT id, title, content, validation_state, source_agent
|
||||
FROM workspace_sections
|
||||
WHERE company_id = ?
|
||||
`).all(companyId);
|
||||
|
||||
// Get annotations
|
||||
const annotations = db.prepare(`
|
||||
SELECT id, section_id, kind, selected_text, comment, created_by, created_at, status
|
||||
FROM memo_annotations
|
||||
WHERE company_id = ?
|
||||
`).all(companyId);
|
||||
|
||||
// Get risks
|
||||
const risks = db.prepare(`
|
||||
SELECT id, risk, category, severity, likelihood, mitigation, status
|
||||
FROM risks
|
||||
WHERE company_id = ?
|
||||
`).all(companyId);
|
||||
|
||||
// Get catalysts
|
||||
const catalysts = db.prepare(`
|
||||
SELECT id, date, event, impact, thesis_relevance, source
|
||||
FROM catalysts
|
||||
WHERE company_id = ?
|
||||
`).all(companyId);
|
||||
|
||||
return JSON.stringify({
|
||||
companyId,
|
||||
capturedAt: new Date().toISOString(),
|
||||
memoSections,
|
||||
modelData,
|
||||
workspaceSections,
|
||||
annotations,
|
||||
risks,
|
||||
catalysts,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a manual snapshot
|
||||
async function createManualSnapshot(companyId?: string, label?: string): Promise<Snapshot | null> {
|
||||
const targetId = companyId || getActiveCompanyId();
|
||||
if (!targetId) return null;
|
||||
|
||||
try {
|
||||
const state = captureCompanyState(targetId);
|
||||
const snapshot = createSnapshot(db, targetId, state, {
|
||||
label,
|
||||
type: "manual",
|
||||
changeCount: tracker[targetId]?.changeCount || 0,
|
||||
});
|
||||
|
||||
// Clean up old snapshots if over limit
|
||||
cleanupOldSnapshots(targetId, maxSnapshots);
|
||||
|
||||
// Notify UI
|
||||
mainWindow?.webContents.send("snapshot:created", {
|
||||
companyId: targetId,
|
||||
snapshotId: snapshot.id,
|
||||
label: snapshot.label || snapshot.type,
|
||||
});
|
||||
|
||||
// Reset change count
|
||||
if (tracker[targetId]) {
|
||||
tracker[targetId].changeCount = 0;
|
||||
tracker[targetId].lastSnapshot = Date.now();
|
||||
}
|
||||
|
||||
console.log(`[Snapshot] Created manual snapshot for ${targetId}`);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
console.error("[Snapshot] Error creating manual snapshot:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-snapshot on interval
|
||||
async function performAutoSnapshot() {
|
||||
const companyId = getActiveCompanyId();
|
||||
if (!companyId) {
|
||||
// Schedule next check
|
||||
timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize tracker if needed
|
||||
if (!tracker[companyId]) {
|
||||
tracker[companyId] = {
|
||||
changeCount: 0,
|
||||
lastSnapshot: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if there are changes
|
||||
const companyTracker = tracker[companyId];
|
||||
if (companyTracker.changeCount > 0) {
|
||||
try {
|
||||
const state = captureCompanyState(companyId);
|
||||
createSnapshot(db, companyId, state, {
|
||||
type: "auto",
|
||||
changeCount: companyTracker.changeCount,
|
||||
});
|
||||
|
||||
// Clean up old snapshots
|
||||
cleanupOldSnapshots(companyId, maxSnapshots);
|
||||
|
||||
// Notify UI
|
||||
mainWindow?.webContents.send("snapshot:created", {
|
||||
companyId,
|
||||
snapshotId: `snapshot-${Date.now()}`,
|
||||
label: "Auto-snapshot",
|
||||
});
|
||||
|
||||
// Reset change count
|
||||
companyTracker.changeCount = 0;
|
||||
companyTracker.lastSnapshot = Date.now();
|
||||
|
||||
console.log(`[Snapshot] Auto-snapshot created for ${companyId}`);
|
||||
} catch (error) {
|
||||
console.error("[Snapshot] Error creating auto-snapshot:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule next snapshot
|
||||
timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000);
|
||||
}
|
||||
|
||||
// Keep only the most recent snapshots
|
||||
function cleanupOldSnapshots(companyId: string, maxCount: number) {
|
||||
const snapshots = listSnapshots(db, companyId);
|
||||
if (snapshots.length > maxCount) {
|
||||
// Delete oldest snapshots beyond the limit
|
||||
const toDelete = snapshots.slice(maxCount);
|
||||
const stmt = db.prepare("DELETE FROM snapshots WHERE id = ?");
|
||||
for (const snapshot of toDelete) {
|
||||
stmt.run(snapshot.id);
|
||||
}
|
||||
console.log(`[Snapshot] Cleaned up ${toDelete.length} old snapshots for ${companyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track changes to data
|
||||
function trackChange(companyId: string) {
|
||||
if (!tracker[companyId]) {
|
||||
tracker[companyId] = {
|
||||
changeCount: 0,
|
||||
lastSnapshot: Date.now(),
|
||||
};
|
||||
}
|
||||
tracker[companyId].changeCount++;
|
||||
}
|
||||
|
||||
// Start auto-snapshot cycle
|
||||
console.log("[Snapshot] Starting auto-snapshot service");
|
||||
timeout = setTimeout(performAutoSnapshot, interval * 60 * 1000);
|
||||
|
||||
// Expose the manual snapshot function
|
||||
(globalThis as any).createManualSnapshot = createManualSnapshot;
|
||||
(globalThis as any).trackSnapshotChange = trackChange;
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
delete (globalThis as any).createManualSnapshot;
|
||||
delete (globalThis as any).trackSnapshotChange;
|
||||
console.log("[Snapshot] Stopped auto-snapshot service");
|
||||
};
|
||||
}
|
||||
8
apps/desktop/tsconfig.json
Normal file
8
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "electron"],
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src", "tsdown.config.ts"]
|
||||
}
|
||||
21
apps/desktop/tsdown.config.ts
Normal file
21
apps/desktop/tsdown.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
const shared = {
|
||||
format: "cjs" as const,
|
||||
outDir: "dist-electron",
|
||||
sourcemap: true,
|
||||
outExtensions: () => ({ js: ".cjs" }),
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
...shared,
|
||||
entry: ["src/main.ts"],
|
||||
clean: true,
|
||||
noExternal: (id) => id.startsWith("@mosaiciq/"),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
entry: ["src/preload.ts"],
|
||||
},
|
||||
]);
|
||||
Reference in New Issue
Block a user