UNPKG

@deskthing/server

Version:
1,521 lines (1,510 loc) 71.6 kB
// src/deskthing.ts import * as fs from "fs"; import * as path from "path"; import { Worker } from "worker_threads"; // node_modules/@deskthing/types/dist/apps/appSettings.js var SETTING_TYPES; (function(SETTING_TYPES2) { SETTING_TYPES2["BOOLEAN"] = "boolean"; SETTING_TYPES2["NUMBER"] = "number"; SETTING_TYPES2["STRING"] = "string"; SETTING_TYPES2["RANGE"] = "range"; SETTING_TYPES2["SELECT"] = "select"; SETTING_TYPES2["MULTISELECT"] = "multiselect"; SETTING_TYPES2["LIST"] = "list"; SETTING_TYPES2["RANKED"] = "ranked"; SETTING_TYPES2["COLOR"] = "color"; SETTING_TYPES2["FILE"] = "file"; })(SETTING_TYPES || (SETTING_TYPES = {})); // node_modules/@deskthing/types/dist/apps/appTasks.js var STEP_TYPES; (function(STEP_TYPES2) { STEP_TYPES2["ACTION"] = "action"; STEP_TYPES2["SHORTCUT"] = "shortcut"; STEP_TYPES2["SETTING"] = "setting"; STEP_TYPES2["TASK"] = "task"; STEP_TYPES2["EXTERNAL"] = "external"; STEP_TYPES2["STEP"] = "step"; })(STEP_TYPES || (STEP_TYPES = {})); // node_modules/@deskthing/types/dist/apps/appTransit.js var APP_REQUESTS; (function(APP_REQUESTS2) { APP_REQUESTS2["DEFAULT"] = "default"; APP_REQUESTS2["GET"] = "get"; APP_REQUESTS2["SET"] = "set"; APP_REQUESTS2["DELETE"] = "delete"; APP_REQUESTS2["OPEN"] = "open"; APP_REQUESTS2["SEND"] = "send"; APP_REQUESTS2["TOAPP"] = "toApp"; APP_REQUESTS2["LOG"] = "log"; APP_REQUESTS2["KEY"] = "key"; APP_REQUESTS2["ACTION"] = "action"; APP_REQUESTS2["TASK"] = "task"; APP_REQUESTS2["STEP"] = "step"; APP_REQUESTS2["SONG"] = "song"; })(APP_REQUESTS || (APP_REQUESTS = {})); // node_modules/@deskthing/types/dist/deskthing/deskthingTransit.js var DESKTHING_DEVICE; (function(DESKTHING_DEVICE2) { DESKTHING_DEVICE2["GLOBAL_SETTINGS"] = "global_settings"; DESKTHING_DEVICE2["MAPPINGS"] = "button_mappings"; DESKTHING_DEVICE2["CONFIG"] = "configuration"; DESKTHING_DEVICE2["GET"] = "get"; DESKTHING_DEVICE2["ERROR"] = "error"; DESKTHING_DEVICE2["PONG"] = "pong"; DESKTHING_DEVICE2["PING"] = "ping"; DESKTHING_DEVICE2["SETTINGS"] = "settings"; DESKTHING_DEVICE2["APPS"] = "apps"; DESKTHING_DEVICE2["TIME"] = "time"; DESKTHING_DEVICE2["HEARTBEAT"] = "heartbeat"; DESKTHING_DEVICE2["META_DATA"] = "meta_data"; DESKTHING_DEVICE2["MUSIC"] = "music"; DESKTHING_DEVICE2["ICON"] = "icon"; })(DESKTHING_DEVICE || (DESKTHING_DEVICE = {})); var DESKTHING_EVENTS; (function(DESKTHING_EVENTS2) { DESKTHING_EVENTS2["MESSAGE"] = "message"; DESKTHING_EVENTS2["DATA"] = "data"; DESKTHING_EVENTS2["APPDATA"] = "appdata"; DESKTHING_EVENTS2["CALLBACK_DATA"] = "callback-data"; DESKTHING_EVENTS2["START"] = "start"; DESKTHING_EVENTS2["STOP"] = "stop"; DESKTHING_EVENTS2["PURGE"] = "purge"; DESKTHING_EVENTS2["INPUT"] = "input"; DESKTHING_EVENTS2["ACTION"] = "action"; DESKTHING_EVENTS2["CONFIG"] = "config"; DESKTHING_EVENTS2["SETTINGS"] = "settings"; DESKTHING_EVENTS2["TASKS"] = "tasks"; DESKTHING_EVENTS2["CLIENT_STATUS"] = "client_status"; })(DESKTHING_EVENTS || (DESKTHING_EVENTS = {})); // node_modules/@deskthing/types/dist/deskthing/mappings.js var EventFlavor; (function(EventFlavor2) { EventFlavor2[EventFlavor2["KeyUp"] = 0] = "KeyUp"; EventFlavor2[EventFlavor2["KeyDown"] = 1] = "KeyDown"; EventFlavor2[EventFlavor2["ScrollUp"] = 2] = "ScrollUp"; EventFlavor2[EventFlavor2["ScrollDown"] = 3] = "ScrollDown"; EventFlavor2[EventFlavor2["ScrollLeft"] = 4] = "ScrollLeft"; EventFlavor2[EventFlavor2["ScrollRight"] = 5] = "ScrollRight"; EventFlavor2[EventFlavor2["SwipeUp"] = 6] = "SwipeUp"; EventFlavor2[EventFlavor2["SwipeDown"] = 7] = "SwipeDown"; EventFlavor2[EventFlavor2["SwipeLeft"] = 8] = "SwipeLeft"; EventFlavor2[EventFlavor2["SwipeRight"] = 9] = "SwipeRight"; EventFlavor2[EventFlavor2["PressShort"] = 10] = "PressShort"; EventFlavor2[EventFlavor2["PressLong"] = 11] = "PressLong"; })(EventFlavor || (EventFlavor = {})); var EventMode; (function(EventMode3) { EventMode3[EventMode3["KeyUp"] = 0] = "KeyUp"; EventMode3[EventMode3["KeyDown"] = 1] = "KeyDown"; EventMode3[EventMode3["ScrollUp"] = 2] = "ScrollUp"; EventMode3[EventMode3["ScrollDown"] = 3] = "ScrollDown"; EventMode3[EventMode3["ScrollLeft"] = 4] = "ScrollLeft"; EventMode3[EventMode3["ScrollRight"] = 5] = "ScrollRight"; EventMode3[EventMode3["SwipeUp"] = 6] = "SwipeUp"; EventMode3[EventMode3["SwipeDown"] = 7] = "SwipeDown"; EventMode3[EventMode3["SwipeLeft"] = 8] = "SwipeLeft"; EventMode3[EventMode3["SwipeRight"] = 9] = "SwipeRight"; EventMode3[EventMode3["PressShort"] = 10] = "PressShort"; EventMode3[EventMode3["PressLong"] = 11] = "PressLong"; })(EventMode || (EventMode = {})); // src/actions/actionUtils.ts var isValidAction = (action) => { if (!action || typeof action !== "object") throw new Error("Action must be an object"); const actionObj = action; if (typeof actionObj.id !== "string") throw new Error("Action id must be a string"); if (typeof actionObj.version !== "string") { throw new Error("Action version must be a string"); } if (typeof actionObj.enabled !== "boolean") { throw new Error("Action enabled must be a boolean"); } if (typeof actionObj.name !== "string") { throw new Error("Action name must be a string"); } if (typeof actionObj.version_code !== "number") { throw new Error("Action version_code must be a number"); } if (actionObj.description !== void 0 && typeof actionObj.description !== "string") { throw new Error("Action description must be a string"); } if (actionObj.value !== void 0 && typeof actionObj.value !== "string") { throw new Error("Action value must be a string"); } if (actionObj.value_options !== void 0 && !Array.isArray(actionObj.value_options)) { throw new Error("Action value_options must be an array of strings"); } if (actionObj.value_instructions !== void 0 && typeof actionObj.value_instructions !== "string") { throw new Error("Action value_instructions must be a string"); } if (actionObj.icon !== void 0 && typeof actionObj.icon !== "string") { throw new Error("Action icon must be a string"); } if (actionObj.tag !== void 0 && !["nav", "media", "basic"].includes(actionObj.tag)) { throw new Error("Action tag must be one of: nav, media, basic"); } }; var isValidActionReference = (action) => { if (typeof action !== "object" || !action) { throw new Error("validateActionReference: action is not a valid object"); } const actionRef = action; if (typeof actionRef.id !== "string") { throw new Error("validateActionReference: id is not a valid string"); } if (typeof actionRef.enabled !== "boolean") { action.enabled = true; console.warn( "validateActionReference: enabled was not set to a boolean value" ); } }; // src/tasks/taskUtils.ts function isValidTask(task) { if (!task || typeof task !== "object") throw new Error("Task must be an object"); const t = task; if (!t.id) { throw new Error("[ValidateTask] Tasks must have an ID"); } if (!t.source) { throw new Error(`[ValidateTask] Task ${t.id} does not have a source`); } if (!t.version) { throw new Error( `[ValidateTask] Task ${t.id} from ${t.source} must have a specified version` ); } if (!t.steps || typeof t.steps !== "object" || Object.values(t.steps).length === 0) { throw new Error( `[ValidateTask] Task ${t.id} from ${t.source} must have at least one specified step` ); } for (const step of Object.values(t.steps)) { isValidStep(step); } } function isValidStep(step) { if (!step || typeof step !== "object") throw new Error("Step must be an object"); const s = step; if (!s.id) { throw new Error("[ValidateStep] Step must have an ID"); } if (!s.type) { throw new Error(`[ValidateStep] Step ${s.id} does not have a type`); } switch (s.type) { case STEP_TYPES.ACTION: isValidTaskAction(s); break; case STEP_TYPES.SHORTCUT: isValidTaskShortcut(s); break; case STEP_TYPES.SETTING: isValidTaskSetting(s); break; case STEP_TYPES.TASK: isValidTaskTask(s); break; case STEP_TYPES.EXTERNAL: isValidTaskExternal(s); break; case STEP_TYPES.STEP: isValidTaskStep(s); break; default: throw new Error(`[ValidateStep] Step ${s.id} has invalid type ${s.type}`); } } function validateStepBase(step, expectedType) { if (!step || typeof step !== "object") throw new Error("Step must be an object"); const s = step; if (!s.type) { throw new Error("[ValidateStep] Step must have a type"); } if (s.type !== expectedType) { throw new Error(`[ValidateStep] Step ${s.id} is not a ${expectedType}`); } } function isValidTaskAction(step) { validateStepBase(step, STEP_TYPES.ACTION); const s = step; if (!s.action) { throw new Error( `[ValidateTaskAction] Step ${s.id} does not have an action` ); } const action = s.action; if (typeof action === "string") { return; } try { if (typeof action === "object" && "version" in action) { isValidAction(action); } else { isValidActionReference(action); } } catch (error) { console.error(`There was an error validating the task action`, error); } } function isValidTaskShortcut(step) { validateStepBase(step, STEP_TYPES.SHORTCUT); const s = step; if (!s.destination) { throw new Error( `[ValidateTaskShortcut] Step ${s.id} does not have a destination` ); } } function isValidTaskSetting(step) { validateStepBase(step, STEP_TYPES.SETTING); const s = step; if (!s.setting) { throw new Error( `[ValidateTaskSetting] Step ${s.id} does not have a setting` ); } if (!("type" in s.setting)) { if (!s.setting.id) throw new Error(`[ValidateTaskSetting] Setting reference does not have an id`); return; } const validTypes = Object.values(SETTING_TYPES); if (!s.setting.type || !validTypes.includes(s.setting.type)) { throw new Error( `[ValidateTaskSetting] Step ${s.id} has invalid setting type` ); } if (!s.setting.label) { throw new Error( `[ValidateTaskSetting] Step ${s.id} setting does not have a label` ); } } function isValidTaskTask(step) { validateStepBase(step, STEP_TYPES.TASK); const s = step; if (!s.taskReference?.id) { throw new Error(`[ValidateTaskTask] Step ${s.id} does not have a taskId`); } } function isValidTaskExternal(step) { validateStepBase(step, STEP_TYPES.EXTERNAL); } function isValidTaskStep(step) { validateStepBase(step, STEP_TYPES.STEP); } // src/settings/settingsUtils.ts var isValidSettings = (setting) => { if (!setting) { throw new Error("[isValidSetting] Setting must be a valid object"); } if (typeof setting !== "object") { throw new Error("[isValidSetting] Setting must be an object"); } if ("type" in setting && typeof setting.type !== "string") { throw new Error("[isValidSetting] Setting type must be a string"); } if ("label" in setting && typeof setting.label !== "string") { throw new Error("[isValidSetting] Setting label must be a string"); } const typedSetting = setting; switch (typedSetting.type) { case SETTING_TYPES.NUMBER: if (typeof typedSetting.value !== "number") throw new Error("[isValidSetting] Number setting value must be a number"); if (typedSetting.min && typeof typedSetting.min !== "number") throw new Error("[isValidSetting] Number setting min must be a number"); if (typedSetting.max && typeof typedSetting.max !== "number") throw new Error("[isValidSetting] Number setting max must be a number"); if (typedSetting.step && typeof typedSetting.step !== "number") throw new Error("[isValidSetting] Number setting max must be a number"); break; case SETTING_TYPES.BOOLEAN: if (typeof typedSetting.value !== "boolean") throw new Error("[isValidSetting] Boolean setting value must be a boolean"); break; case SETTING_TYPES.STRING: if (typeof typedSetting.value !== "string") throw new Error("[isValidSetting] String setting value must be a string"); if (typedSetting.maxLength && typeof typedSetting.maxLength !== "number") throw new Error("[isValidSetting] String setting maxLength must be a number"); break; case SETTING_TYPES.SELECT: case SETTING_TYPES.MULTISELECT: case SETTING_TYPES.RANKED: case SETTING_TYPES.LIST: if (!Array.isArray(typedSetting.options)) throw new Error(`[isValidSetting] ${typedSetting.type} setting must have options array`); typedSetting.options.forEach((option) => { if (typeof option.label !== "string") throw new Error("[isValidSetting] Option label must be a string"); if (typeof option.value !== "string") throw new Error("[isValidSetting] Option value must be a string"); }); break; case SETTING_TYPES.RANGE: if (typeof typedSetting.value !== "number") throw new Error("[isValidSetting] Range setting value must be a number"); if (typedSetting.min && typeof typedSetting.min !== "number") throw new Error("[isValidSetting] Range setting min must be a number"); if (typedSetting.max && typeof typedSetting.max !== "number") throw new Error("[isValidSetting] Range setting max must be a number"); if (typedSetting.step && typeof typedSetting.step !== "number") throw new Error("[isValidSetting] Range setting max must be a number"); break; case SETTING_TYPES.COLOR: if (typedSetting.value && typeof typedSetting.value !== "string") throw new Error("[isValidSetting] Color setting value must be a string"); break; case SETTING_TYPES.FILE: break; // nothing is needed technically speaking default: throw new Error(`[isValidSetting] Invalid setting type: ${JSON.stringify(typedSetting)}`); } }; var sanitizeSettings = (setting) => { isValidSettings(setting); const commonSettings = { ...setting, disabled: setting.disabled, id: setting.id, label: setting.label || setting.id || "", value: setting.value, source: setting.source, description: setting.description || "No Description" }; switch (setting.type) { case SETTING_TYPES.SELECT: setting = { ...commonSettings, type: SETTING_TYPES.SELECT, value: setting.value, label: setting.label, description: setting.description || "", placeholder: setting.placeholder, options: setting.options }; break; case SETTING_TYPES.MULTISELECT: setting = { ...commonSettings, type: SETTING_TYPES.MULTISELECT, value: setting.value, label: setting.label, description: setting.description || "", placeholder: setting.placeholder, options: setting.options }; break; case SETTING_TYPES.NUMBER: setting = { ...commonSettings, type: SETTING_TYPES.NUMBER, value: setting.value, label: setting.label, min: setting.min, max: setting.max, description: setting.description || "" }; break; case SETTING_TYPES.BOOLEAN: setting = { ...commonSettings, type: SETTING_TYPES.BOOLEAN, value: setting.value, description: setting.description || "", label: setting.label }; break; case SETTING_TYPES.STRING: setting = { ...commonSettings, type: SETTING_TYPES.STRING, description: setting.description || "", value: setting.value, label: setting.label }; break; case SETTING_TYPES.RANGE: setting = { ...commonSettings, type: SETTING_TYPES.RANGE, value: setting.value, label: setting.label, min: setting.min, max: setting.max, step: setting.step || 1, description: setting.description || "" }; break; case SETTING_TYPES.RANKED: setting = { ...commonSettings, type: SETTING_TYPES.RANKED, value: setting.value, label: setting.label, description: setting.description || "", options: setting.options }; break; case SETTING_TYPES.LIST: setting = { ...commonSettings, type: SETTING_TYPES.LIST, value: setting.value, label: setting.label, unique: setting.unique, orderable: setting.orderable, placeholder: setting.placeholder, maxValues: setting.maxValues, description: setting.description || "", options: setting.options || [] }; break; case SETTING_TYPES.COLOR: setting = { ...commonSettings, type: SETTING_TYPES.COLOR, value: setting.value, label: setting.label, description: setting.description || "" }; break; case SETTING_TYPES.FILE: setting = { ...commonSettings, type: SETTING_TYPES.FILE, value: setting.value, label: setting.label, fileTypes: setting.fileTypes || [], placeholder: setting.placeholder || "" }; break; default: throw new Error(`[isValidSetting] Unknown setting type: ${setting}`); } return setting; }; var settingHasOptions = (setting) => { if (!setting) throw new Error("[settingHasOptions] Setting must be defined"); if (!setting.type) throw new Error("[settingHasOptions] Setting type must be defined"); return setting.type === SETTING_TYPES.RANKED || setting.type === SETTING_TYPES.LIST || setting.type === SETTING_TYPES.SELECT || setting.type === SETTING_TYPES.MULTISELECT; }; // src/deskthing.ts import { parentPort } from "worker_threads"; // src/utils/validators.ts var isValidAppDataInterface = (app) => { if (!app) { throw new Error("App data interface is undefined"); } if (typeof app !== "object") { throw new Error("App data interface is not an object"); } if (!app.version) { throw new Error("App data interface version is undefined"); } if (app.settings) { isValidAppSettings(app.settings); } if (app.tasks) { Object.values(app.tasks).forEach((task) => { isValidTask(task); }); } if (app.actions) { Object.values(app.actions).forEach((action) => { isValidAction2(action); }); } if (app.keys) { Object.values(app.keys).forEach((key) => { isValidKey(key); }); } }; var isValidAction2 = (action) => { if (!action || typeof action !== "object") throw new Error("Action must be an object"); const actionObj = action; if (typeof actionObj.id !== "string") throw new Error("Action id must be a string"); if (typeof actionObj.source !== "string") throw new Error("Action source must be a string"); if (typeof actionObj.version !== "string") { actionObj.version = "0.0.0"; console.warn("WARNING_MISSING_ACTION_VERSION"); } if (typeof actionObj.enabled !== "boolean") { actionObj.enabled = true; console.warn("WARNING_MISSING_ACTION_ENABLED"); } }; var isValidKey = (key) => { if (!key || typeof key !== "object") throw new Error("Key must be an object"); const keyObj = key; if (typeof keyObj.id !== "string") throw new Error("Key id must be a string"); if (typeof keyObj.source !== "string") throw new Error("Key source must be a string"); if (typeof keyObj.version !== "string") throw new Error("Key version must be a string"); if (typeof keyObj.enabled !== "boolean") throw new Error("Key enabled must be a boolean"); if (!Array.isArray(keyObj.modes)) throw new Error("Key modes must be an array"); if (!keyObj.modes.every((Mode) => Object.values(EventMode).includes(Mode))) { throw new Error("Key modes must all be valid EventMode values"); } }; var isValidAppSettings = (appSettings) => { if (typeof appSettings !== "object") { throw new Error("[sanitizeAppSettings] App settings must be an object"); } Object.entries(appSettings).forEach(([key, setting]) => { if (typeof setting !== "object") { throw new Error("[sanitizeAppSettings] App settings must be an object"); } try { isValidSettings(setting); } catch (error) { console.error(`Failed to validate settings!`, error); } }); }; // src/deskthing.ts var DeskThingClass = class _DeskThingClass { constructor() { // Context this.manifest = null; // Communication with the server this.imageUrls = {}; // Listener data this.Listeners = {}; this.sysListeners = []; this.backgroundTasks = []; this.stopRequested = false; /** * Either just sends data, sends and listens for data, or sends - listens - and provides a callback hook * @param requestData * @param listenData * @param callback */ this.fetch = async (requestData, listenData, callback, timeoutMs = 500) => { if (!requestData.type) { console.warn(`[fetch]: Request Data doesn't have a "type" field`); return void 0; } this.sendToServer(requestData); if (!listenData) return void 0; try { const dataPromise = new Promise( (resolve2) => { let timeoutId = null; let isResolved = false; const handleResolve = (data) => { if (isResolved) return; isResolved = true; if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } resolve2(data); }; timeoutId = setTimeout(() => { console.debug(`[fetch]: Request timed out after ${timeoutMs}ms for type: ${listenData.type}`); handleResolve(void 0); }, timeoutMs); try { this.once( listenData.type, (data) => handleResolve(data), listenData.request ).catch((error) => { console.warn(`[fetch]: Error during fetch listener! ${error}`); handleResolve(void 0); }); } catch (error) { console.warn(`[fetch]: Error during fetch listener setup! ${error}`); handleResolve(void 0); } } ); const response = await dataPromise; if (callback) { try { await callback(response); } catch (error) { console.warn( `[fetch]: Error during fetch callback! ${error instanceof Error ? error.message : error}` ); } } return response; } catch (error) { console.warn( `[fetch]: Error during deskthing fetch! ${error instanceof Error ? error.message : error}` ); if (callback) { try { await callback(void 0); } catch (error2) { console.warn( `[fetch]: Error during errored callback! ${error2 instanceof Error ? error2.message : error2}` ); } } return void 0; } }; /** * Adds a new setting or overwrites an existing one. Automatically saves the new setting to the server to be persisted. * * @param settings - An object containing the settings to add or update. * @param notifyServer - Leave true. Otherwise the settings will not be saved to the server. * @remarks Use {@link DeskThing.initSettings} for the first settings call. Only use this to update settings or add them later. * * @example * // Adding a boolean setting * deskThing.setSettings({ * darkMode: { * type: 'boolean', * label: 'Dark Mode', * value: false, * description: 'Enable dark mode theme' * } * }) * @example * // Adding a select setting * deskThing.setSettings({ * theme: { * type: 'select', * label: 'Theme', * value: 'light', * description: 'Choose your theme', * options: [ * { label: 'Light', value: 'light' }, * { label: 'Dark', value: 'dark' }, * { label: 'System', value: 'system' } * ] * } * }) * @example * // Adding a multiselect setting * deskThing.setSettings({ * notifications: { * type: 'multiselect', * label: 'Notifications', * value: ['email'], * description: 'Choose notification methods', * options: [ * { label: 'Email', value: 'email' }, * { label: 'SMS', value: 'sms' }, * { label: 'Push', value: 'push' } * ] * } * }) * @example * // Adding a number setting * deskThing.setSettings({ * fontSize: { * type: 'number', * label: 'Font Size', * value: 16, * description: 'Set the font size in pixels', * min: 12, * max: 24 * } * }) * @example * // Adding a string setting * deskThing.setSettings({ * username: { * type: 'string', * label: 'Username', * value: '', * description: 'Enter your username' * } * }) * @example * // Adding a range setting * deskThing.setSettings({ * volume: { * type: 'range', * label: 'Volume', * value: 50, * description: 'Adjust the volume level', * min: 0, * max: 100, * step: 1 * } * }) * @example * // Adding an order setting * deskThing.setSettings({ * displayOrder: { * type: 'order', * label: 'Display Order', * value: ['section1', 'section2', 'section3'], * description: 'Arrange the display order of sections', * options: [ * { label: 'Section 1', value: 'section1' }, * { label: 'Section 2', value: 'section2' }, * { label: 'Section 3', value: 'section3' } * ] * } * }) * @example * // Adding a list setting * deskThing.setSettings({ * settingsList: { * label: "Settings List", * description: "Select multiple items from the list", * type: 'list', * value: ['item1', 'item2'], * options: [ * { label: 'Item1', value: 'item1' }, * { label: 'Item2', value: 'item2' }, * { label: 'Item3', value: 'item3' }, * { label: 'Item4', value: 'item4' } * ] * } * }) * @example * // Adding a color setting * deskThing.setSettings({ * settingsColor: { * label: "Settings Color", * description: "Prompt the user to select a color", * type: 'color', * value: '#1ed760' * } * }) */ this.setSettings = async (settings) => { const existingSettings = await this.getSettings() || {}; if (!settings || typeof settings !== "object") { throw new Error("Settings must be a valid object"); } Object.entries(settings).forEach(([id, setting]) => { if (!setting.type || !setting.label) { throw new Error(`Setting ${id} must have a type and label`); } try { existingSettings[id] = { ...sanitizeSettings(setting), id }; } catch (error) { if (error instanceof Error) { console.error( `Error sanitizing setting with label "${setting.label}": ${error.message}` ); } else { console.error( `Error sanitizing setting with label "${setting.label}": ${error}` ); } } }); this.saveSettings(existingSettings); }; /** * Updates the options for a specific setting */ this.setSettingOptions = async (settingId, options) => { const existingSettings = await this.getSettings(); if (!existingSettings?.[settingId]) { console.error(`Setting with id ${settingId} not found`); return; } try { settingHasOptions(existingSettings[settingId]); } catch (error) { if (error instanceof Error) { console.error(`Error setting option of setting: ${settingId}`, error.message); } return; } existingSettings[settingId].options = options; this.saveSettings(existingSettings); }; this.tasks = { /** * Adds a new task. * @throws {Error} - when the data is invalid. * @param taskData - The data for the new task. * @example * deskthing.tasks.add({ * id: 'task-id', * version: '1.0.0', * available: true, * completed: false, * label: 'Task Name', * started: false, * currentStep: 'step-1', * description: 'Task Description', * steps: { * 'step-1': { * id: 'step-1', * type: STEP_TYPES.STEP, * completed: false, * label: 'Step 1', * instructions: 'Step 1 instructions' * } * } * }); */ add: (taskData) => { try { const newTask = { ...taskData, source: this.manifest?.id || "unknown" }; isValidTask(newTask); this.sendSocketData(APP_REQUESTS.TASK, { task: newTask }, "add"); } catch (error) { if (error instanceof Error) { console.warn("Invalid task data:" + error.message); } throw error; } }, /** * Initializes the tasks * @throws {Error} - when the data is invalid. */ initTasks: async (taskData) => { try { const newTasks = Object.entries(taskData).reduce( (validatedTasks, [id, task]) => { try { const newTask = { ...task, id, source: this.manifest?.id || "unknown", steps: Object.fromEntries(Object.entries(task.steps).map(([stepId, step]) => [ stepId, { ...step, id: step.id || stepId, source: step.source || this.manifest?.id || "unknown" } ])) }; isValidTask(newTask); return { ...validatedTasks, [newTask.id]: newTask }; } catch (error) { console.warn( `Task ${task.label || task.id} failed to be verified: ` + (error instanceof Error && error.message) ); return validatedTasks; } }, {} ); this.sendSocketData(APP_REQUESTS.TASK, { tasks: newTasks }, "init"); } catch (error) { console.warn( "Invalid task data:" + (error instanceof Error && error.message) ); } }, /** * Updates a specific step within a task * @param taskId - The ID of the task containing the step * @param stepId - The ID of the step to update * @param updates - The partial step data to update * @example * deskthing.tasks.update('task-id', 'step-1', { * completed: true, * label: 'Updated Step Label', * instructions: 'New instructions' * }); */ update: (taskId, task) => { const validStepFields = [ "id", "label", "completed", "currentStep", "started", "source", "version", "available", "description", "steps" ]; const sanitizedUpdates = Object.fromEntries( Object.entries(task).filter( ([key]) => validStepFields.includes(key) ) ); this.sendSocketData( APP_REQUESTS.TASK, { taskId, task: { ...sanitizedUpdates, id: taskId } }, "update" ); }, /** * Deletes a task by its ID * @param taskId - The ID of the task to delete * @example * deskthing.tasks.delete('task-id'); */ delete: (taskId) => { this.sendSocketData(APP_REQUESTS.TASK, { taskId }, "delete"); }, /** * Marks a task as completed * @param taskId - The ID of the task to complete * @example * deskthing.tasks.complete('task-id'); */ complete: (taskId) => { this.sendSocketData(APP_REQUESTS.TASK, { taskId }, "complete"); }, /** * Restarts a task, resetting its progress * @param taskId - The ID of the task to restart * @example * deskthing.tasks.restart('task-id'); */ restart: (taskId) => { this.sendSocketData(APP_REQUESTS.TASK, { taskId }, "restart"); }, /** * Marks a task as started * @param taskId - The ID of the task to start * @example * deskthing.tasks.start('task-id'); */ start: (taskId) => { this.sendSocketData(APP_REQUESTS.TASK, { taskId }, "start"); }, /** * Ends a task without completing it * @param taskId - The ID of the task to end * @example * deskthing.tasks.end('task-id'); */ end: (taskId) => { this.sendSocketData(APP_REQUESTS.TASK, { taskId }, "end"); }, /** * Retrieves task information * @param taskId - Optional ID of the specific task to get. If omitted, returns all tasks * @example * // Get all tasks * deskthing.tasks.get(); * * // Later, listen for tasks * deskthing.on() */ get: () => { this.sendSocketData(APP_REQUESTS.TASK, {}, "get"); } }; this.steps = { /** * Adds a new step to the specified task. * @param taskId - The unique identifier of the task to which the step belongs. * @param stepData - The data for the new step. * @example * // Basic step * deskthing.steps.add('task-id', { * id: 'step-id', * type: STEP_TYPES.STEP, * label: 'Step Name', * instructions: 'Step Description', * completed: false, * debug: false, * strict: false, * parentId: 'parent-task-id' * }); * * // Action step * deskthing.steps.add('task-id', { * id: 'action-step', * type: STEP_TYPES.ACTION, * label: 'Run Action', * instructions: 'Execute this action', * completed: false, * action: { * id: 'action-id', * value: 'example-value', * enabled: true, * source: 'system' * } as ActionReference * }); * * // External step * deskthing.steps.add('task-id', { * id: 'external-step', * type: STEP_TYPES.EXTERNAL, * label: 'External Task', * instructions: 'Complete this external task', * completed: false, * url: 'https://example.com' * }); * * // Task step * deskthing.steps.add('task-id', { * id: 'task-step', * type: STEP_TYPES.TASK, * label: 'Complete Task', * instructions: 'Complete the referenced task', * completed: false, * taskId: 'referenced-task-id' * }); * * // Shortcut step * deskthing.steps.add('task-id', { * id: 'shortcut-step', * type: STEP_TYPES.SHORTCUT, * label: 'Navigate', * instructions: 'Go to location', * completed: false, * destination: 'settings/general' * }); * * // Setting step * deskthing.steps.add('task-id', { * id: 'setting-step', * type: STEP_TYPES.SETTING, * label: 'Configure Setting', * instructions: 'Set up configuration', * completed: false, * setting: { * value: 'example', * type: 'string', * label: 'Example Setting', * description: 'An example string setting' * } as SettingsString * }); * @throws {Error} If the step data is invalid. */ add: (taskId, stepData) => { try { isValidStep(stepData); this.sendSocketData(APP_REQUESTS.STEP, { taskId, step: stepData }, "add"); } catch (error) { if (error instanceof Error) { console.warn("Invalid step data:" + error.message); } } }, /** * Updates an existing step with the provided updates. * Only allows updating valid step fields and sanitizes the input. * * @param taskId - The ID of the task containing the step * @param stepId - The ID of the step to update * @param updates - Partial Step object containing the fields to update */ update: (taskId, stepId, updates) => { const validStepFields = [ "parentId", "id", "debug", "strict", "type", "label", "instructions", "completed", "debugging", "source", "action", "url", "taskId", "taskSource", "destination", "setting" ]; const sanitizedUpdates = Object.fromEntries( Object.entries(updates).filter(([key]) => validStepFields.includes(key)) ); this.sendSocketData( APP_REQUESTS.STEP, { taskId, stepId, step: { ...sanitizedUpdates, id: stepId } }, "update" ); }, /** * Deletes a step from a task. * * @param taskId - The ID of the task containing the step * @param stepId - The ID of the step to delete */ delete: (taskId, stepId) => { this.sendSocketData(APP_REQUESTS.STEP, { taskId, stepId }, "delete"); }, /** * Marks a step as completed. * * @param taskId - The ID of the task containing the step * @param stepId - The ID of the step to complete */ complete: (taskId, stepId) => { this.sendSocketData(APP_REQUESTS.STEP, { taskId, stepId }, "complete"); }, /** * Restarts a step by resetting its state. * * @param taskId - The ID of the task containing the step * @param stepId - The ID of the step to restart */ restart: (taskId, stepId) => { this.sendSocketData(APP_REQUESTS.STEP, { taskId, stepId }, "restart"); }, /** * Retrieves a specific step from a task. * * @param taskId - The ID of the task containing the step * @param stepId - The ID of the step to retrieve */ get: (taskId, stepId) => { this.sendSocketData(APP_REQUESTS.STEP, { taskId, stepId }, "get"); } }; /** * The updated way to send data to server * Simply wraps postProcessMessage using the correct version * @since v0.11.0 */ this.sendToServer = async (data) => { this.postProcessMessage({ version: _DeskThingClass.version, type: "data", payload: data }); }; this.postProcessMessage = async (data) => { if (parentPort?.postMessage) { parentPort.postMessage(data); } else { console.error("Parent port or postmessage is undefined!"); } }; this.loadManifest(); this.initializeListeners(); } static { this.version = "0.11.0"; } initializeListeners() { parentPort?.on("message", async (data) => { switch (data.type) { case "data": this.handleServerMessage(data.payload); break; case "start": this.postProcessMessage({ version: _DeskThingClass.version, type: "started" }); this.stopRequested = false; await this.notifyListeners(DESKTHING_EVENTS.START, { type: DESKTHING_EVENTS.START }); break; case "stop": try { await this.notifyListeners(DESKTHING_EVENTS.STOP, { type: DESKTHING_EVENTS.STOP }); this.stopRequested = true; this.backgroundTasks.forEach((cancel) => cancel()); this.backgroundTasks = []; } catch (error) { console.error("Error in stop:", error); } this.postProcessMessage({ version: _DeskThingClass.version, type: "stopped" }); break; case "purge": await this.purge(); break; } }); } /** * Singleton pattern: Ensures only one instance of DeskThing exists. * * @since 0.8.0 * @example * const deskThing = DeskThing.getInstance(); * deskthing.on('start', () => { * // Your code here * }); */ static getInstance() { if (!this.instance) { this.instance = new _DeskThingClass(); } return this.instance; } /** * Notifies all listeners of a particular event. * * @since 0.8.0 * @example * deskThing.on('message', (msg) => console.log(msg)); * deskThing.notifyListeners('message', 'Hello, World!'); */ async notifyListeners(event, data) { const callbacks = this.Listeners[event]; if (callbacks) { await Promise.all( callbacks.map(async (callback) => { try { await callback(data); } catch (error) { console.log( "Encountered an error in notifyListeners" + (error instanceof Error ? error.message : error) ); } }) ); } } /** * Registers an event listener for a specific incoming event. Events are either the "type" value of the incoming SocketData object or a special event like "start", "stop", or "data". * * @since 0.8.0 * @param event - The event type to listen for. * @param callback - The function to call when the event occurs. * @returns A function to remove the listener. * * @example * const removeListener = deskThing.on('data', (data) => console.log(data)); * removeListener(); // To remove the listener * * @example * const removeListener = deskThing.on('start', () => console.log('App is starting')); * removeListener(); // To remove the listener * * @example * // When {type: 'get'} is received from the server * const removeListener = deskThing.on('get', (socketData) => console.log(socketData.payload)); * removeListener(); // To remove the listener * * @example * // When a setting is updated. Passes the updated settings object * const removeListener = deskThing.on('settings', (settings) => console.log(settings.some_setting.value)); * removeListener(); // To remove the listener * * @example * // Listening to data from the client * // server * deskThing.on('set', async (socketData) => { * if (socketData.request === 'loremIpsum') { * handleData(socketData.payload); * } * }) * * // client * deskThing.send({ type: 'set', request: 'loremIpsum', payload: 'lorem ipsum' }); * * @example * // Listening to data from the client * // server * deskThing.on('doSomething', async (socketData) => { * doSomething() * }) * * // client * deskThing.send({ type: 'doSomething' }); */ on(event, callback) { if (!this.Listeners[event]) { this.Listeners[event] = []; } this.Listeners[event].push(callback); return () => this.off(event, callback); } /** * Removes a specific event listener for a particular incoming event. * * @since 0.8.0 * @param event - The event for which to remove the listener. * @param callback - The listener function to remove. * * @example * const dataListener = () => console.log('Data received'); * deskthing.on('data', dataListener); * deskthing.off('data', dataListener); */ off(event, callback) { if (!this.Listeners[event]) { return; } this.Listeners[event] = this.Listeners[event].filter( (cb) => cb !== callback ); } /** * Registers a one-time listener for an incoming event. The listener will be automatically removed after the first occurrence of the event. * * Will destructure the response from the server and just return the "payload" field * * @since 0.10.0 * @param event - The event to listen for. This is either the 'type' field of SocketData or special cases like 'get' or 'start' * @param callback - Optional callback function. If omitted, returns a promise. * @returns A promise that resolves with the event data if no callback is provided. * * @example * DeskThing.once('message').then(data => console.log('Received data:', data)); // prints 'hello' * * // elsewhere * send({ type: 'message', payload: 'hello' }); * @example * const flagType = await DeskThing.once('flagType'); * console.log('Flag type:', flagType); * @example * await DeskThing.once('flagType', someFunction); * * * @throws * if something goes wrong */ async once(event, callback, request) { try { return new Promise( (resolve2) => { const onceWrapper = async (data) => { if (request && data.request !== request) { return; } this.off(event, onceWrapper); if (callback) { await callback(data); } resolve2(data); }; this.on(event, onceWrapper); } ); } catch (error) { console.warn("Failed to listen for event: " + event); throw new Error( `Error in once() for app ${this.manifest?.id || "unset"}: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Sends data to the server with a specified event type. * * @since 0.8.0 * @param event - The event type to send. * @param payload - The data to send. * @param request - Optional request string. * * @example * deskThing.sendSocketData('log', { message: 'Logging an event' }); */ sendSocketData(event, payload, request) { const appData = { type: event, request, payload }; this.sendToServer(appData); } /** * Sends data to the client for the client to listen to * * @since 0.10.0 * @param payload - { type: string, payload: any, request?: string } * * @example * // Server * deskThing.send({ type: 'message', payload: 'Hello from the Server!' }); * * // Client * deskThing.on('message', (data: SocketData) => { * console.log('Received message:', data.payload); // prints 'Hello from the Server!' * }); * @example * // Server * deskThing.send({ type: 'someFancyData', payload: someDataObject }); * * // Client * deskThing.on('someFancyData', (data: SocketData) => { * const someData = data.payload; * }); * * @example * // Server * deskThing.send({type: 'songData', payload: musicData }); * * // Client * deskThing.on('songData', (data: SocketData) => { * const musicData = data.payload as SongData; * }); */ send(payload) { const filledPayload = { app: this.manifest?.id, ...payload }; this.sendSocketData(APP_REQUESTS.SEND, filledPayload); } sendSong(songData) { this.sendSocketData(APP_REQUESTS.SONG, songData); } /** * Routes request to another app running on the server. * Ensure that the app you are requesting data from is in your dependency array! * * @param appId - The ID of the target app. * @param data - The data to send to the target app. * @since 0.11.0 * @example * deskThing.sendToApp('utility', { type: 'set', request: 'next', payload: { id: '' } }); * @example * deskThing.sendToApp('spotify', { type: 'get', request: 'music' }); */ sendToApp(appId, payload) { this.sendSocketData(APP_REQUESTS.TOAPP, payload, appId); } /** * Requests the server to open a specified URL. * * @param url - The URL to open. * * @example * deskThing.openUrl('https://example.com'); */ openUrl(url) { this.sendSocketData(APP_REQUESTS.OPEN, url); } /** * Fetches data from the server if not already retrieved, otherwise returns the cached data. * This method also handles queuing requests while data is being fetched. * * @returns A promise that resolves with the data fetched or the cached data, or null if data is not available. * * @example * const data = await deskThing.getData(); * console.log('Fetched data:', data); */ async getData() { const data = await this.fetch( { type: APP_REQUESTS.GET, request: "data" }, { type: DESKTHING_EVENTS.DATA } ); if (!data) { console.error("[getData]: Data not available"); return null; } return data.payload; } /** * Fetches data from the server if not already retrieved, otherwise returns the cached data. * This method also handles queuing requests while data is being fetched. * * @returns A promise that resolves with the data fetched or the cached data, or null if data is not available. * * @example * const data = await deskThing.getData(); * console.log('Fetched data:', data); */ async getAppData() { const data = await this.fetch( { type: APP_REQUESTS.GET, request: "appData" }, { type: DESKTHING_EVENTS.APPDATA } ); if (!data) { console.error("[getAppData]: Data not availa