// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { handleIncomingRpcMessage, sendRawRpcMessage } from "@/app/store/wshrpc"; import { getLayoutModelForTabById, LayoutTreeActionType, LayoutTreeInsertNodeAction, LayoutTreeInsertNodeAtIndexAction, newLayoutNode, } from "@/layout/index"; import { getWebServerEndpoint, getWSServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import * as util from "@/util/util"; import { produce } from "immer"; import * as jotai from "jotai"; import * as rxjs from "rxjs"; import { modalsModel } from "./modalmodel"; import * as services from "./services"; import * as WOS from "./wos"; import { WSControl } from "./ws"; let PLATFORM: NodeJS.Platform = "darwin"; const globalStore = jotai.createStore(); let atoms: GlobalAtomsType; let globalEnvironment: "electron" | "renderer"; const blockViewModelMap = new Map(); const Counters = new Map(); const ConnStatusMap = new Map>(); type GlobalInitOptions = { platform: NodeJS.Platform; windowId: string; clientId: string; environment: "electron" | "renderer"; }; function initGlobal(initOpts: GlobalInitOptions) { globalEnvironment = initOpts.environment; setPlatform(initOpts.platform); initGlobalAtoms(initOpts); } function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = jotai.atom(initOpts.windowId) as jotai.PrimitiveAtom; const clientIdAtom = jotai.atom(initOpts.clientId) as jotai.PrimitiveAtom; const uiContextAtom = jotai.atom((get) => { const windowData = get(windowDataAtom); const uiContext: UIContext = { windowid: get(atoms.windowId), activetabid: windowData?.activetabid, }; return uiContext; }) as jotai.Atom; const isFullScreenAtom = jotai.atom(false) as jotai.PrimitiveAtom; try { getApi().onFullScreenChange((isFullScreen) => { console.log("fullscreen change", isFullScreen); globalStore.set(isFullScreenAtom, isFullScreen); }); } catch (_) { // do nothing } const showAboutModalAtom = jotai.atom(false) as jotai.PrimitiveAtom; try { getApi().onMenuItemAbout(() => { modalsModel.pushModal("AboutModal"); }); } catch (_) { // do nothing } const clientAtom: jotai.Atom = jotai.atom((get) => { const clientId = get(clientIdAtom); if (clientId == null) { return null; } return WOS.getObjectValue(WOS.makeORef("client", clientId), get); }); const windowDataAtom: jotai.Atom = jotai.atom((get) => { const windowId = get(windowIdAtom); if (windowId == null) { return null; } const rtn = WOS.getObjectValue(WOS.makeORef("window", windowId), get); return rtn; }); const workspaceAtom: jotai.Atom = jotai.atom((get) => { const windowData = get(windowDataAtom); if (windowData == null) { return null; } return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); const settingsConfigAtom = jotai.atom(null) as jotai.PrimitiveAtom; const tabAtom: jotai.Atom = jotai.atom((get) => { const windowData = get(windowDataAtom); if (windowData == null) { return null; } return WOS.getObjectValue(WOS.makeORef("tab", windowData.activetabid), get); }); const activeTabIdAtom: jotai.Atom = jotai.atom((get) => { const windowData = get(windowDataAtom); if (windowData == null) { return null; } return windowData.activetabid; }); const controlShiftDelayAtom = jotai.atom(false); const updaterStatusAtom = jotai.atom("up-to-date") as jotai.PrimitiveAtom; try { globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus()); getApi().onUpdaterStatusChange((status) => { console.log("updater status change", status); globalStore.set(updaterStatusAtom, status); }); } catch (_) { // do nothing } const reducedMotionPreferenceAtom = jotai.atom((get) => get(settingsConfigAtom).window.reducedmotion); const typeAheadModalAtom = jotai.atom({}); atoms = { // initialized in wave.ts (will not be null inside of application) windowId: windowIdAtom, clientId: clientIdAtom, uiContext: uiContextAtom, client: clientAtom, waveWindow: windowDataAtom, workspace: workspaceAtom, settingsConfigAtom, tabAtom, activeTabId: activeTabIdAtom, isFullScreen: isFullScreenAtom, controlShiftDelayAtom, updaterStatusAtom, reducedMotionPreferenceAtom, typeAheadModalAtom, }; } type WaveEventSubjectContainer = { id: string; handler: (event: WaveEvent) => void; scope: string; }; // key is "eventType" or "eventType|oref" const eventSubjects = new Map>(); const fileSubjects = new Map>(); const waveEventSubjects = new Map(); function getSubjectInternal(subjectKey: string): SubjectWithRef { let subject = eventSubjects.get(subjectKey); if (subject == null) { subject = new rxjs.Subject() as any; subject.refCount = 0; subject.release = () => { subject.refCount--; if (subject.refCount === 0) { subject.complete(); eventSubjects.delete(subjectKey); } }; eventSubjects.set(subjectKey, subject); } subject.refCount++; return subject; } function getEventSubject(eventType: string): SubjectWithRef { return getSubjectInternal(eventType); } function getEventORefSubject(eventType: string, oref: string): SubjectWithRef { return getSubjectInternal(eventType + "|" + oref); } function makeWaveReSubCommand(eventType: string): RpcMessage { let subjects = waveEventSubjects.get(eventType); if (subjects == null) { return { command: "eventunsub", data: eventType }; } let subreq: SubscriptionRequest = { event: eventType, scopes: [], allscopes: false }; for (const scont of subjects) { if (util.isBlank(scont.scope)) { subreq.allscopes = true; subreq.scopes = []; break; } subreq.scopes.push(scont.scope); } return { command: "eventsub", data: subreq }; } function updateWaveEventSub(eventType: string) { const command = makeWaveReSubCommand(eventType); sendRawRpcMessage(command); } function waveEventSubscribe(eventType: string, scope: string, handler: (event: WaveEvent) => void): () => void { if (handler == null) { return; } const id = crypto.randomUUID(); const subject = new rxjs.Subject() as any; const scont: WaveEventSubjectContainer = { id, scope, handler }; let subjects = waveEventSubjects.get(eventType); if (subjects == null) { subjects = []; waveEventSubjects.set(eventType, subjects); } subjects.push(scont); updateWaveEventSub(eventType); return () => waveEventUnsubscribe(eventType, id); } function waveEventUnsubscribe(eventType: string, id: string) { let subjects = waveEventSubjects.get(eventType); if (subjects == null) { return; } const idx = subjects.findIndex((s) => s.id === id); if (idx === -1) { return; } subjects.splice(idx, 1); if (subjects.length === 0) { waveEventSubjects.delete(eventType); } updateWaveEventSub(eventType); } function getFileSubject(zoneId: string, fileName: string): SubjectWithRef { const subjectKey = zoneId + "|" + fileName; let subject = fileSubjects.get(subjectKey); if (subject == null) { subject = new rxjs.Subject() as any; subject.refCount = 0; subject.release = () => { subject.refCount--; if (subject.refCount === 0) { subject.complete(); fileSubjects.delete(subjectKey); } }; fileSubjects.set(subjectKey, subject); } subject.refCount++; return subject; } const blockCache = new Map>(); function useBlockCache(blockId: string, name: string, makeFn: () => T): T { let blockMap = blockCache.get(blockId); if (blockMap == null) { blockMap = new Map(); blockCache.set(blockId, blockMap); } let value = blockMap.get(name); if (value == null) { value = makeFn(); blockMap.set(name, value); } return value as T; } const settingsAtomCache = new Map>(); function useSettingsAtom(name: string, settingsFn: (settings: SettingsConfigType) => T): jotai.Atom { let atom = settingsAtomCache.get(name); if (atom == null) { atom = jotai.atom((get) => { const settings = get(atoms.settingsConfigAtom); if (settings == null) { return null; } return settingsFn(settings); }) as jotai.Atom; settingsAtomCache.set(name, atom); } return atom as jotai.Atom; } const blockAtomCache = new Map>>(); function useBlockAtom(blockId: string, name: string, makeFn: () => jotai.Atom): jotai.Atom { let blockCache = blockAtomCache.get(blockId); if (blockCache == null) { blockCache = new Map>(); blockAtomCache.set(blockId, blockCache); } let atom = blockCache.get(name); if (atom == null) { atom = makeFn(); blockCache.set(name, atom); console.log("New BlockAtom", blockId, name); } return atom as jotai.Atom; } function useBlockDataLoaded(blockId: string): boolean { const loadedAtom = useBlockAtom(blockId, "block-loaded", () => { return WOS.getWaveObjectLoadingAtom(WOS.makeORef("block", blockId)); }); return jotai.useAtomValue(loadedAtom); } let globalWS: WSControl = null; function handleWaveEvent(event: WaveEvent) { const subjects = waveEventSubjects.get(event.event); if (subjects == null) { return; } for (const scont of subjects) { if (util.isBlank(scont.scope)) { scont.handler(event); continue; } if (event.scopes == null) { continue; } if (event.scopes.includes(scont.scope)) { scont.handler(event); } } } function handleWSEventMessage(msg: WSEventType) { if (msg.eventtype == null) { console.log("unsupported event", msg); return; } if (msg.eventtype == "config") { globalStore.set(atoms.settingsConfigAtom, msg.data.settings); return; } if (msg.eventtype == "userinput") { const data: UserInputRequest = msg.data; modalsModel.pushModal("UserInputModal", { ...data }); return; } if (msg.eventtype == "blockfile") { const fileData: WSFileEventData = msg.data; const fileSubject = getFileSubject(fileData.zoneid, fileData.filename); if (fileSubject != null) { fileSubject.next(fileData); } return; } if (msg.eventtype == "rpc") { const rpcMsg: RpcMessage = msg.data; handleIncomingRpcMessage(rpcMsg, handleWaveEvent); return; } if (msg.eventtype == "layoutaction") { const layoutAction: WSLayoutActionData = msg.data; const tabId = layoutAction.tabid; const layoutModel = getLayoutModelForTabById(tabId); switch (layoutAction.actiontype) { case LayoutTreeActionType.InsertNode: { const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId: layoutAction.blockid, }), magnified: layoutAction.magnified, }; layoutModel.treeReducer(insertNodeAction); break; } case LayoutTreeActionType.DeleteNode: { const leaf = layoutModel?.getNodeByBlockId(layoutAction.blockid); if (leaf) { layoutModel.closeNode(leaf); } else { console.error( "Cannot apply eventbus layout action DeleteNode, could not find leaf node with blockId", layoutAction.blockid ); } break; } case LayoutTreeActionType.InsertNodeAtIndex: { if (!layoutAction.indexarr) { console.error("Cannot apply eventbus layout action InsertNodeAtIndex, indexarr field is missing."); break; } const insertAction: LayoutTreeInsertNodeAtIndexAction = { type: LayoutTreeActionType.InsertNodeAtIndex, node: newLayoutNode(undefined, layoutAction.nodesize, undefined, { blockId: layoutAction.blockid, }), indexArr: layoutAction.indexarr, magnified: layoutAction.magnified, }; layoutModel.treeReducer(insertAction); break; } default: console.log("unsupported layout action", layoutAction); break; } return; } // we send to two subjects just eventType and eventType|oref // we don't use getORefSubject here because we don't want to create a new subject const eventSubject = eventSubjects.get(msg.eventtype); if (eventSubject != null) { eventSubject.next(msg); } const eventOrefSubject = eventSubjects.get(msg.eventtype + "|" + msg.oref); if (eventOrefSubject != null) { eventOrefSubject.next(msg); } } function handleWSMessage(msg: any) { if (msg == null) { return; } if (msg.eventtype != null) { handleWSEventMessage(msg); } } function initWS() { const windowId = globalStore.get(atoms.windowId); globalWS = new WSControl(getWSServerEndpoint(), globalStore, windowId, "", (msg) => { handleWSMessage(msg); }); globalWS.connectNow("initWS"); } function sendWSCommand(command: WSCommandType) { globalWS.pushMessage(command); } // more code that could be moved into an init // here we want to set up a "waveobj:update" handler const waveobjUpdateSubject = getEventSubject("waveobj:update"); waveobjUpdateSubject.subscribe((msg: WSEventType) => { const update: WaveObjUpdate = msg.data; WOS.updateWaveObject(update); }); /** * Get the preload api. */ function getApi(): ElectronApi { return (window as any).api; } async function createBlock(blockDef: BlockDef): Promise { const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const blockId = await services.ObjectService.CreateBlock(blockDef, rtOpts); const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId }), }; const activeTabId = globalStore.get(atoms.uiContext).activetabid; const layoutModel = getLayoutModelForTabById(activeTabId); layoutModel.treeReducer(insertNodeAction); return blockId; } // when file is not found, returns {data: null, fileInfo: null} async function fetchWaveFile( zoneId: string, fileName: string, offset?: number ): Promise<{ data: Uint8Array; fileInfo: WaveFile }> { const usp = new URLSearchParams(); usp.set("zoneid", zoneId); usp.set("name", fileName); if (offset != null) { usp.set("offset", offset.toString()); } const resp = await fetch(getWebServerEndpoint() + "/wave/file?" + usp.toString()); if (!resp.ok) { if (resp.status === 404) { return { data: null, fileInfo: null }; } throw new Error("error getting wave file: " + resp.statusText); } if (resp.status == 204) { return { data: null, fileInfo: null }; } const fileInfo64 = resp.headers.get("X-ZoneFileInfo"); if (fileInfo64 == null) { throw new Error(`missing zone file info for ${zoneId}:${fileName}`); } const fileInfo = JSON.parse(atob(fileInfo64)); const data = await resp.arrayBuffer(); return { data: new Uint8Array(data), fileInfo }; } function setBlockFocus(blockId: string) { let winData = globalStore.get(atoms.waveWindow); if (winData == null) { return; } if (winData.activeblockid === blockId) { return; } winData = produce(winData, (draft) => { draft.activeblockid = blockId; }); WOS.setObjectValue(winData, globalStore.set, true); } const objectIdWeakMap = new WeakMap(); let objectIdCounter = 0; function getObjectId(obj: any): number { if (!objectIdWeakMap.has(obj)) { objectIdWeakMap.set(obj, objectIdCounter++); } return objectIdWeakMap.get(obj); } let cachedIsDev: boolean = null; function isDev() { if (cachedIsDev == null) { cachedIsDev = getApi().getIsDev(); } return cachedIsDev; } async function openLink(uri: string) { if (globalStore.get(atoms.settingsConfigAtom)?.web?.openlinksinternally) { const blockDef: BlockDef = { meta: { view: "web", url: uri, }, }; await createBlock(blockDef); } else { getApi().openExternal(uri); } } function registerViewModel(blockId: string, viewModel: ViewModel) { blockViewModelMap.set(blockId, viewModel); } function unregisterViewModel(blockId: string) { blockViewModelMap.delete(blockId); } function getViewModel(blockId: string): ViewModel { return blockViewModelMap.get(blockId); } function countersClear() { Counters.clear(); } function counterInc(name: string, incAmt: number = 1) { let count = Counters.get(name) ?? 0; count += incAmt; Counters.set(name, count); } function countersPrint() { let outStr = ""; for (const [name, count] of Counters.entries()) { outStr += `${name}: ${count}\n`; } console.log(outStr); } async function loadConnStatus() { const connStatusArr = await services.ClientService.GetAllConnStatus(); if (connStatusArr == null) { return; } for (const connStatus of connStatusArr) { const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } } function subscribeToConnEvents() { waveEventSubscribe("connchange", null, (event: WaveEvent) => { const connStatus = event.data as ConnStatus; if (connStatus == null || util.isBlank(connStatus.connection)) { return; } let curAtom = ConnStatusMap.get(connStatus.connection); globalStore.set(curAtom, connStatus); }); } function getConnStatusAtom(conn: string): jotai.PrimitiveAtom { let rtn = ConnStatusMap.get(conn); if (rtn == null) { const connStatus: ConnStatus = { connection: conn, connected: false, error: null }; rtn = jotai.atom(connStatus); ConnStatusMap.set(conn, rtn); } return rtn; } export { atoms, counterInc, countersClear, countersPrint, createBlock, fetchWaveFile, getApi, getConnStatusAtom, getEventORefSubject, getEventSubject, getFileSubject, getObjectId, getViewModel, globalStore, globalWS, initGlobal, initWS, isDev, loadConnStatus, openLink, PLATFORM, registerViewModel, sendWSCommand, setBlockFocus, setPlatform, subscribeToConnEvents, unregisterViewModel, useBlockAtom, useBlockCache, useBlockDataLoaded, useSettingsAtom, waveEventSubscribe, waveEventUnsubscribe, WOS, };