@trops/dash-core 0.1.205 → 0.1.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29580,1790 +29580,1785 @@ var dashboardConfigUtils$1 = {
29580
29580
  checkApiCompatibility,
29581
29581
  };
29582
29582
 
29583
- var widgetRegistry$1 = {exports: {}};
29584
-
29585
- var dynamicWidgetLoader$2 = {exports: {}};
29586
-
29587
29583
  /**
29588
- * Widget Compiler
29584
+ * registryAuthController.js
29589
29585
  *
29590
- * Compiles raw widget source files (.js + .dash.js) into a single CJS bundle
29591
- * using esbuild. The output bundle is consumable by the existing
29592
- * widgetBundleLoader.js eval pipeline (new Function() + require shim).
29586
+ * Manages authentication with the Dash registry service.
29587
+ * Uses OAuth device code flow for desktop app authentication.
29593
29588
  *
29594
- * Runs in the Electron main process at widget install time.
29589
+ * Flow:
29590
+ * 1. App calls initiateDeviceFlow() — gets device code + verification URL
29591
+ * 2. User opens verification URL in browser, signs in, enters code
29592
+ * 3. App polls pollForToken() until authorized
29593
+ * 4. Token stored securely via electron-store (encrypted)
29595
29594
  */
29596
29595
 
29597
- const fs$2 = require$$0$3;
29598
- const path$5 = require$$1$2;
29596
+ const REGISTRY_BASE_URL$1 =
29597
+ process.env.DASH_REGISTRY_API_URL ||
29598
+ "https://main.d919rwhuzp7rj.amplifyapp.com";
29599
+
29600
+ // Lazy-load electron-store to avoid issues when not installed
29601
+ let store$3 = null;
29602
+ function getStore$1() {
29603
+ if (!store$3) {
29604
+ const Store = require$$1$1;
29605
+ store$3 = new Store({
29606
+ name: "dash-registry-auth",
29607
+ encryptionKey: "dash-registry-v1",
29608
+ });
29609
+ }
29610
+ return store$3;
29611
+ }
29599
29612
 
29600
29613
  /**
29601
- * Find the widgets/ directory, handling nested ZIP extraction.
29602
- *
29603
- * ZIP extraction can create a nested structure like:
29604
- * Weather/weather-widget/widgets/ instead of Weather/widgets/
29614
+ * Initiate the OAuth device code flow.
29615
+ * Returns the device code, user code, and verification URL.
29605
29616
  *
29606
- * If widgets/ doesn't exist at root, check one level deeper for a
29607
- * single subdirectory that contains widgets/.
29617
+ * @returns {Promise<Object>} { deviceCode, userCode, verificationUrl, verificationUrlComplete, expiresIn, interval }
29618
+ */
29619
+ async function initiateDeviceFlow$1() {
29620
+ const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/device`, {
29621
+ method: "POST",
29622
+ headers: { "Content-Type": "application/json" },
29623
+ });
29624
+
29625
+ if (!response.ok) {
29626
+ throw new Error(`Device flow initiation failed: ${response.status}`);
29627
+ }
29628
+
29629
+ const data = await response.json();
29630
+
29631
+ return {
29632
+ deviceCode: data.device_code,
29633
+ userCode: data.user_code,
29634
+ verificationUrl: data.verification_uri,
29635
+ verificationUrlComplete: data.verification_uri_complete,
29636
+ expiresIn: data.expires_in,
29637
+ interval: data.interval,
29638
+ };
29639
+ }
29640
+
29641
+ /**
29642
+ * Poll the registry for token after user completes browser auth.
29608
29643
  *
29609
- * @param {string} widgetPath - Absolute path to the widget directory
29610
- * @returns {string|null} Path to the widgets/ directory, or null
29644
+ * @param {string} deviceCode - The device code from initiateDeviceFlow()
29645
+ * @returns {Promise<Object>} { status: 'pending' | 'authorized' | 'expired', token?, userId? }
29611
29646
  */
29612
- function findWidgetsDir$1(widgetPath) {
29613
- const direct = path$5.join(widgetPath, "widgets");
29614
- if (fs$2.existsSync(direct)) {
29615
- return direct;
29647
+ async function pollForToken$1(deviceCode) {
29648
+ const response = await fetch(
29649
+ `${REGISTRY_BASE_URL$1}/api/auth/device?device_code=${encodeURIComponent(deviceCode)}`,
29650
+ );
29651
+
29652
+ if (response.status === 428) {
29653
+ return { status: "pending" };
29616
29654
  }
29617
29655
 
29618
- // Check configs/ directory (used by packageZip.js for distributed widgets)
29619
- const configs = path$5.join(widgetPath, "configs");
29620
- if (fs$2.existsSync(configs)) {
29621
- return configs;
29656
+ if (response.status === 400) {
29657
+ const data = await response.json();
29658
+ if (data.error === "expired_token") {
29659
+ return { status: "expired" };
29660
+ }
29661
+ return { status: "pending" };
29622
29662
  }
29623
29663
 
29624
- // Check one level deeper for nested ZIP extraction
29625
- try {
29626
- const entries = fs$2.readdirSync(widgetPath, { withFileTypes: true });
29627
- const subdirs = entries.filter(
29628
- (e) =>
29629
- e.isDirectory() &&
29630
- !e.name.startsWith(".") &&
29631
- e.name !== "dist" &&
29632
- e.name !== "node_modules",
29633
- );
29664
+ if (response.ok) {
29665
+ const data = await response.json();
29634
29666
 
29635
- for (const subdir of subdirs) {
29636
- const nested = path$5.join(widgetPath, subdir.name, "widgets");
29637
- if (fs$2.existsSync(nested)) {
29638
- console.log(`[WidgetCompiler] Found nested widgets/ at ${nested}`);
29639
- return nested;
29640
- }
29641
- }
29642
- } catch (err) {
29643
- // Non-fatal — fall through to null
29667
+ // Store the token securely
29668
+ const s = getStore$1();
29669
+ s.set("accessToken", data.access_token);
29670
+ s.set("userId", data.user_id);
29671
+ s.set("tokenType", data.token_type);
29672
+ s.set("authenticatedAt", new Date().toISOString());
29673
+
29674
+ return {
29675
+ status: "authorized",
29676
+ token: data.access_token,
29677
+ userId: data.user_id,
29678
+ };
29644
29679
  }
29645
29680
 
29646
- return null;
29681
+ throw new Error(`Unexpected response: ${response.status}`);
29647
29682
  }
29648
29683
 
29649
29684
  /**
29650
- * Compile widget source files into a CJS bundle at dist/index.cjs.js.
29651
- *
29652
- * For each {Name}.dash.js found in the widgets/ directory, a synthetic
29653
- * entry point is generated that imports the component + config and
29654
- * re-exports them as `{ ...config, component }` — matching what
29655
- * extractWidgetConfigs() in widgetBundleLoader.js expects.
29685
+ * Get the stored auth token.
29656
29686
  *
29657
- * @param {string} widgetPath - Absolute path to the widget directory
29658
- * @returns {Promise<string|null>} Path to the compiled bundle, or null if nothing to compile
29687
+ * @returns {Object|null} { token, userId, authenticatedAt } or null if not authenticated
29659
29688
  */
29660
- async function compileWidget(widgetPath) {
29661
- const widgetsDir = findWidgetsDir$1(widgetPath);
29689
+ function getStoredToken$3() {
29690
+ try {
29691
+ const s = getStore$1();
29692
+ const token = s.get("accessToken");
29693
+ if (!token) return null;
29662
29694
 
29663
- if (!widgetsDir) {
29664
- console.log(
29665
- `[WidgetCompiler] No widgets/ directory in ${widgetPath}, skipping`,
29666
- );
29695
+ return {
29696
+ token,
29697
+ userId: s.get("userId"),
29698
+ authenticatedAt: s.get("authenticatedAt"),
29699
+ };
29700
+ } catch {
29667
29701
  return null;
29668
29702
  }
29703
+ }
29669
29704
 
29670
- // Discover .dash.js config files
29671
- const files = fs$2.readdirSync(widgetsDir);
29672
- const dashFiles = files.filter((f) => f.endsWith(".dash.js"));
29673
-
29674
- if (dashFiles.length === 0) {
29675
- console.log(
29676
- `[WidgetCompiler] No .dash.js files found in ${widgetsDir}, skipping`,
29677
- );
29678
- return null;
29705
+ /**
29706
+ * Check if the user is authenticated with the registry.
29707
+ *
29708
+ * @returns {Object} { authenticated: boolean, userId?: string }
29709
+ */
29710
+ function getAuthStatus$1() {
29711
+ const stored = getStoredToken$3();
29712
+ if (!stored) {
29713
+ return { authenticated: false };
29679
29714
  }
29680
29715
 
29681
- // Build a synthetic entry point that pairs each component with its config.
29682
- // Compute relative path from the entry file (in widgetPath) to widgetsDir,
29683
- // since widgetsDir may be nested (e.g., ./weather-widget/widgets/).
29684
- const relWidgetsDir =
29685
- "./" + path$5.relative(widgetPath, widgetsDir).split(path$5.sep).join("/");
29686
- const imports = [];
29687
- const exportParts = [];
29716
+ return {
29717
+ authenticated: true,
29718
+ userId: stored.userId,
29719
+ authenticatedAt: stored.authenticatedAt,
29720
+ };
29721
+ }
29688
29722
 
29689
- for (const dashFile of dashFiles) {
29690
- const componentName = dashFile.replace(".dash.js", "");
29691
- const componentFile = `${componentName}.js`;
29692
- const componentFilePath = path$5.join(widgetsDir, componentFile);
29693
- const hasComponent = fs$2.existsSync(componentFilePath);
29723
+ /**
29724
+ * Get the user's registry profile.
29725
+ *
29726
+ * @returns {Promise<Object|null>} User profile or null
29727
+ */
29728
+ async function getRegistryProfile$2() {
29729
+ const stored = getStoredToken$3();
29730
+ if (!stored) return null;
29694
29731
 
29695
- // Import the config (always)
29696
- imports.push(
29697
- `import ${componentName}Config from "${relWidgetsDir}/${dashFile}";`,
29698
- );
29732
+ try {
29733
+ const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/me`, {
29734
+ headers: {
29735
+ Authorization: `Bearer ${stored.token}`,
29736
+ },
29737
+ });
29699
29738
 
29700
- if (hasComponent) {
29701
- // Import the component and merge with config
29702
- imports.push(
29703
- `import ${componentName}Comp from "${relWidgetsDir}/${componentFile}";`,
29704
- );
29705
- exportParts.push(
29706
- `export const ${componentName} = { ...${componentName}Config, component: ${componentName}Comp };`,
29707
- );
29708
- } else {
29709
- // Config-only (no component source file)
29710
- exportParts.push(
29711
- `export const ${componentName} = ${componentName}Config;`,
29712
- );
29739
+ if (response.status === 401) {
29740
+ // Token expired or invalid clear stored credentials
29741
+ clearToken();
29742
+ return null;
29713
29743
  }
29744
+ if (!response.ok) return null;
29745
+
29746
+ const data = await response.json();
29747
+ return data.user || null;
29748
+ } catch {
29749
+ return null;
29714
29750
  }
29751
+ }
29715
29752
 
29716
- const entryContent = [...imports, "", ...exportParts, ""].join("\n");
29753
+ /**
29754
+ * Clear stored auth token (logout).
29755
+ */
29756
+ function clearToken() {
29757
+ try {
29758
+ const s = getStore$1();
29759
+ s.clear();
29760
+ console.log("[RegistryAuthController] Token cleared");
29761
+ } catch (err) {
29762
+ console.error("[RegistryAuthController] Error clearing token:", err);
29763
+ }
29764
+ }
29717
29765
 
29718
- // Write temporary entry file in the widget root
29719
- const entryPath = path$5.join(widgetPath, "__compile_entry.js");
29720
- const distDir = path$5.join(widgetPath, "dist");
29721
- const outPath = path$5.join(distDir, "index.cjs.js");
29766
+ /**
29767
+ * Update the authenticated user's registry profile.
29768
+ *
29769
+ * @param {Object} updates - Fields to update (e.g. { displayName })
29770
+ * @returns {Promise<Object|null>} Updated user or null on 401
29771
+ */
29772
+ async function updateRegistryProfile$1(updates) {
29773
+ const stored = getStoredToken$3();
29774
+ if (!stored) return null;
29722
29775
 
29723
29776
  try {
29724
- // Ensure dist/ directory exists
29725
- if (!fs$2.existsSync(distDir)) {
29726
- fs$2.mkdirSync(distDir, { recursive: true });
29727
- }
29777
+ const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/me`, {
29778
+ method: "PATCH",
29779
+ headers: {
29780
+ Authorization: `Bearer ${stored.token}`,
29781
+ "Content-Type": "application/json",
29782
+ },
29783
+ body: JSON.stringify(updates),
29784
+ });
29728
29785
 
29729
- fs$2.writeFileSync(entryPath, entryContent, "utf8");
29786
+ if (response.status === 401) {
29787
+ clearToken();
29788
+ return null;
29789
+ }
29790
+ if (!response.ok) return null;
29730
29791
 
29731
- console.log(
29732
- `[WidgetCompiler] Compiling ${dashFiles.length} component(s) from ${widgetPath}`,
29733
- );
29792
+ const data = await response.json();
29793
+ return data.user || null;
29794
+ } catch {
29795
+ return null;
29796
+ }
29797
+ }
29734
29798
 
29735
- // Lazy-require esbuild so the module doesn't fail to load if
29736
- // esbuild is not yet installed (e.g., during first npm install)
29737
- const esbuild = require("esbuild");
29799
+ /**
29800
+ * Get the authenticated user's published packages.
29801
+ *
29802
+ * @returns {Promise<Object|null>} { packages: [...] } or null
29803
+ */
29804
+ async function getRegistryPackages$1() {
29805
+ const stored = getStoredToken$3();
29806
+ if (!stored) return null;
29738
29807
 
29739
- await esbuild.build({
29740
- entryPoints: [entryPath],
29741
- bundle: true,
29742
- format: "cjs",
29743
- outfile: outPath,
29744
- // These modules are provided by the host app via MODULE_MAP
29745
- // in widgetBundleLoader.js — do NOT bundle them
29746
- external: [
29747
- "react",
29748
- "react-dom",
29749
- "@trops/dash-react",
29750
- "@trops/dash-core",
29751
- "react/jsx-runtime",
29752
- "prop-types",
29753
- ],
29754
- // Treat .js files as JSX (widget sources use JSX in .js files)
29755
- loader: { ".js": "jsx" },
29756
- logLevel: "warning",
29808
+ try {
29809
+ const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/me/packages`, {
29810
+ headers: {
29811
+ Authorization: `Bearer ${stored.token}`,
29812
+ },
29757
29813
  });
29758
29814
 
29759
- console.log(`[WidgetCompiler] Compiled successfully → ${outPath}`);
29760
- return outPath;
29761
- } catch (error) {
29762
- console.error(
29763
- `[WidgetCompiler] Compilation failed for ${widgetPath}:`,
29764
- error,
29765
- );
29766
- throw error;
29767
- } finally {
29768
- // Clean up temporary entry file
29769
- try {
29770
- if (fs$2.existsSync(entryPath)) {
29771
- fs$2.unlinkSync(entryPath);
29772
- }
29773
- } catch (cleanupError) {
29774
- // Non-fatal
29775
- console.warn(
29776
- `[WidgetCompiler] Could not remove temp entry file:`,
29777
- cleanupError,
29778
- );
29815
+ if (response.status === 401) {
29816
+ clearToken();
29817
+ return null;
29779
29818
  }
29819
+ if (!response.ok) return null;
29820
+
29821
+ return await response.json();
29822
+ } catch {
29823
+ return null;
29780
29824
  }
29781
29825
  }
29782
29826
 
29783
- var widgetCompiler$1 = { compileWidget, findWidgetsDir: findWidgetsDir$1 };
29784
-
29785
29827
  /**
29786
- * Dynamic Widget Loader
29787
- *
29788
- * Loads React components and configurations from downloaded/local widget paths
29789
- * Works with widgets that follow the Dash widget structure:
29790
- * - widgets/
29791
- * - WidgetName.js (React component)
29792
- * - WidgetName.dash.js (configuration)
29828
+ * Update a published package's metadata.
29793
29829
  *
29794
- * Integrates with ComponentManager for automatic registration
29830
+ * @param {string} scope - Package scope (e.g. "@trops")
29831
+ * @param {string} name - Package name
29832
+ * @param {Object} updates - Fields to update (displayName, description, category, tags, visibility)
29833
+ * @returns {Promise<Object|null>} Updated package or null
29795
29834
  */
29835
+ async function updateRegistryPackage$1(scope, name, updates) {
29836
+ const stored = getStoredToken$3();
29837
+ if (!stored) return null;
29796
29838
 
29797
- const fs$1 = require$$0$3;
29798
- const path$4 = require$$1$2;
29799
- const vm = require$$2$3;
29800
- const { findWidgetsDir } = widgetCompiler$1;
29839
+ try {
29840
+ const response = await fetch(
29841
+ `${REGISTRY_BASE_URL$1}/api/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`,
29842
+ {
29843
+ method: "PATCH",
29844
+ headers: {
29845
+ Authorization: `Bearer ${stored.token}`,
29846
+ "Content-Type": "application/json",
29847
+ },
29848
+ body: JSON.stringify(updates),
29849
+ },
29850
+ );
29801
29851
 
29802
- class DynamicWidgetLoader {
29803
- constructor(componentManager = null) {
29804
- this.loadedWidgets = new Map();
29805
- this.moduleCache = new Map();
29806
- this.componentManager = componentManager;
29807
- }
29852
+ if (response.status === 401) {
29853
+ clearToken();
29854
+ return null;
29855
+ }
29856
+ if (!response.ok) return null;
29808
29857
 
29809
- /**
29810
- * Set ComponentManager instance for automatic widget registration
29811
- * @param {Object} manager - ComponentManager instance from @trops/dash-react
29812
- */
29813
- setComponentManager(manager) {
29814
- this.componentManager = manager;
29858
+ return await response.json();
29859
+ } catch {
29860
+ return null;
29815
29861
  }
29862
+ }
29816
29863
 
29817
- /**
29818
- * Load a widget from a local path
29819
- * @param {string} widgetName - Name of the widget (e.g., "MyFirstWidget")
29820
- * @param {string} widgetPath - Path to the widget directory
29821
- * @param {string} componentName - Name of the component file (e.g., "MyFirstWidgetWidget")
29822
- * @param {boolean} autoRegister - Automatically register with ComponentManager (if available)
29823
- * @returns {Promise<Object>} { component, config, registered }
29824
- */
29825
- async loadWidget(widgetName, widgetPath, componentName, autoRegister = true) {
29826
- try {
29827
- const cacheKey = `${widgetName}:${componentName}`;
29828
-
29829
- if (this.loadedWidgets.has(cacheKey)) {
29830
- console.log(`[DynamicWidgetLoader] Loading ${widgetName} from cache`);
29831
- return this.loadedWidgets.get(cacheKey);
29832
- }
29833
-
29834
- console.log(
29835
- `[DynamicWidgetLoader] Loading widget: ${widgetName} from ${widgetPath}`,
29836
- );
29864
+ /**
29865
+ * Delete a published package from the registry.
29866
+ *
29867
+ * @param {string} scope - Package scope (e.g. "@trops")
29868
+ * @param {string} name - Package name
29869
+ * @returns {Promise<Object|null>} Response or null
29870
+ */
29871
+ async function deleteRegistryPackage(scope, name) {
29872
+ const stored = getStoredToken$3();
29873
+ if (!stored) return null;
29837
29874
 
29838
- const widgetsDir =
29839
- findWidgetsDir(widgetPath) || path$4.join(widgetPath, "widgets");
29840
- const componentPath = path$4.join(widgetsDir, `${componentName}.js`);
29841
- const configPath = path$4.join(widgetsDir, `${componentName}.dash.js`);
29875
+ try {
29876
+ const response = await fetch(
29877
+ `${REGISTRY_BASE_URL$1}/api/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`,
29878
+ {
29879
+ method: "DELETE",
29880
+ headers: {
29881
+ Authorization: `Bearer ${stored.token}`,
29882
+ },
29883
+ },
29884
+ );
29842
29885
 
29843
- if (!fs$1.existsSync(componentPath)) {
29844
- throw new Error(`Component file not found: ${componentPath}`);
29845
- }
29846
- if (!fs$1.existsSync(configPath)) {
29847
- throw new Error(`Config file not found: ${configPath}`);
29848
- }
29886
+ if (response.status === 401) {
29887
+ clearToken();
29888
+ return null;
29889
+ }
29890
+ if (!response.ok) return null;
29849
29891
 
29850
- const config = await this.loadConfigFile(configPath);
29892
+ return await response.json();
29893
+ } catch {
29894
+ return null;
29895
+ }
29896
+ }
29851
29897
 
29852
- const component = {
29853
- path: componentPath,
29854
- name: componentName,
29855
- };
29898
+ var registryAuthController$1 = {
29899
+ initiateDeviceFlow: initiateDeviceFlow$1,
29900
+ pollForToken: pollForToken$1,
29901
+ getStoredToken: getStoredToken$3,
29902
+ getAuthStatus: getAuthStatus$1,
29903
+ getRegistryProfile: getRegistryProfile$2,
29904
+ updateRegistryProfile: updateRegistryProfile$1,
29905
+ getRegistryPackages: getRegistryPackages$1,
29906
+ updateRegistryPackage: updateRegistryPackage$1,
29907
+ deleteRegistryPackage,
29908
+ clearToken,
29909
+ };
29856
29910
 
29857
- let registered = false;
29911
+ var widgetRegistry$1 = {exports: {}};
29858
29912
 
29859
- if (autoRegister && this.componentManager) {
29860
- try {
29861
- // Use scoped id as registration key if available,
29862
- // otherwise fall back to componentName
29863
- const registrationKey = config.id || componentName;
29864
- this.componentManager.registerWidget(config, registrationKey);
29865
- registered = true;
29866
- console.log(
29867
- `[DynamicWidgetLoader] ✓ Registered ${registrationKey} with ComponentManager`,
29868
- );
29869
- } catch (regError) {
29870
- console.warn(
29871
- `[DynamicWidgetLoader] Failed to register with ComponentManager:`,
29872
- regError,
29873
- );
29874
- }
29875
- }
29913
+ var dynamicWidgetLoader$2 = {exports: {}};
29876
29914
 
29877
- const result = { component, config, registered };
29878
- this.loadedWidgets.set(cacheKey, result);
29915
+ /**
29916
+ * Widget Compiler
29917
+ *
29918
+ * Compiles raw widget source files (.js + .dash.js) into a single CJS bundle
29919
+ * using esbuild. The output bundle is consumable by the existing
29920
+ * widgetBundleLoader.js eval pipeline (new Function() + require shim).
29921
+ *
29922
+ * Runs in the Electron main process at widget install time.
29923
+ */
29879
29924
 
29880
- return result;
29881
- } catch (error) {
29882
- console.error(
29883
- `[DynamicWidgetLoader] Error loading widget ${widgetName}:`,
29884
- error,
29885
- );
29886
- throw error;
29887
- }
29888
- }
29925
+ const fs$2 = require$$0$3;
29926
+ const path$5 = require$$1$2;
29889
29927
 
29890
- /**
29891
- * Load and parse a .dash.js configuration file
29892
- * @param {string} configPath - Path to the .dash.js file
29893
- * @returns {Promise<Object>} Configuration object
29894
- */
29895
- async loadConfigFile(configPath) {
29896
- try {
29897
- const source = fs$1.readFileSync(configPath, "utf8");
29898
-
29899
- let exportMatch = source.match(/export\s+default\s+({[\s\S]*});?\s*$/);
29900
-
29901
- // Handle variable export pattern: const x = {...}; export default x;
29902
- if (!exportMatch) {
29903
- const varExportMatch = source.match(
29904
- /export\s+default\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*;?\s*$/,
29905
- );
29906
- if (varExportMatch) {
29907
- const varName = varExportMatch[1];
29908
- const varDeclMatch = source.match(
29909
- new RegExp(
29910
- `(?:const|let|var)\\s+${varName}\\s*=\\s*({[\\s\\S]*?});\\s*(?:export\\s+default)`,
29911
- ),
29912
- );
29913
- if (varDeclMatch) {
29914
- exportMatch = varDeclMatch;
29915
- }
29916
- }
29917
- }
29918
-
29919
- if (!exportMatch) {
29920
- throw new Error("Could not find default export in config file");
29921
- }
29922
-
29923
- // Sanitize component references so vm.runInContext doesn't fail
29924
- // on unresolvable imports — replace component: SomeName with component: "SomeName"
29925
- const exportedObjectStr = exportMatch[1].replace(
29926
- /component\s*:\s*([A-Z][a-zA-Z0-9_$]*)/g,
29927
- 'component: "$1"',
29928
- );
29929
-
29930
- const context = vm.createContext({ module: { exports: {} } });
29931
- vm.runInContext(`module.exports = ${exportedObjectStr}`, context);
29932
-
29933
- return context.module.exports;
29934
- } catch (error) {
29935
- console.error(`[DynamicWidgetLoader] Error loading config:`, error);
29936
- throw error;
29937
- }
29928
+ /**
29929
+ * Find the widgets/ directory, handling nested ZIP extraction.
29930
+ *
29931
+ * ZIP extraction can create a nested structure like:
29932
+ * Weather/weather-widget/widgets/ instead of Weather/widgets/
29933
+ *
29934
+ * If widgets/ doesn't exist at root, check one level deeper for a
29935
+ * single subdirectory that contains widgets/.
29936
+ *
29937
+ * @param {string} widgetPath - Absolute path to the widget directory
29938
+ * @returns {string|null} Path to the widgets/ directory, or null
29939
+ */
29940
+ function findWidgetsDir$1(widgetPath) {
29941
+ const direct = path$5.join(widgetPath, "widgets");
29942
+ if (fs$2.existsSync(direct)) {
29943
+ return direct;
29938
29944
  }
29939
29945
 
29940
- /**
29941
- * Discover available widgets in a directory
29942
- * @param {string} widgetPath - Path to search for widgets
29943
- * @returns {Array} List of available widget names
29944
- */
29945
- discoverWidgets(widgetPath) {
29946
- try {
29947
- const widgetsDir = findWidgetsDir(widgetPath);
29948
- if (!widgetsDir) {
29949
- return [];
29950
- }
29951
-
29952
- const files = fs$1.readdirSync(widgetsDir);
29953
- const widgets = new Set();
29946
+ // Check configs/ directory (used by packageZip.js for distributed widgets)
29947
+ const configs = path$5.join(widgetPath, "configs");
29948
+ if (fs$2.existsSync(configs)) {
29949
+ return configs;
29950
+ }
29954
29951
 
29955
- files.forEach((file) => {
29956
- if (file.endsWith(".dash.js")) {
29957
- const componentName = file.replace(".dash.js", "");
29958
- widgets.add(componentName);
29959
- }
29960
- });
29952
+ // Check one level deeper for nested ZIP extraction
29953
+ try {
29954
+ const entries = fs$2.readdirSync(widgetPath, { withFileTypes: true });
29955
+ const subdirs = entries.filter(
29956
+ (e) =>
29957
+ e.isDirectory() &&
29958
+ !e.name.startsWith(".") &&
29959
+ e.name !== "dist" &&
29960
+ e.name !== "node_modules",
29961
+ );
29961
29962
 
29962
- return Array.from(widgets);
29963
- } catch (error) {
29964
- console.error(`[DynamicWidgetLoader] Error discovering widgets:`, error);
29965
- return [];
29963
+ for (const subdir of subdirs) {
29964
+ const nested = path$5.join(widgetPath, subdir.name, "widgets");
29965
+ if (fs$2.existsSync(nested)) {
29966
+ console.log(`[WidgetCompiler] Found nested widgets/ at ${nested}`);
29967
+ return nested;
29968
+ }
29966
29969
  }
29970
+ } catch (err) {
29971
+ // Non-fatal — fall through to null
29967
29972
  }
29968
29973
 
29969
- /**
29970
- * Clear cache
29971
- */
29972
- clearCache() {
29973
- this.loadedWidgets.clear();
29974
- this.moduleCache.clear();
29975
- }
29974
+ return null;
29976
29975
  }
29977
29976
 
29978
- const dynamicWidgetLoader$1 = new DynamicWidgetLoader();
29979
-
29980
- dynamicWidgetLoader$2.exports = DynamicWidgetLoader;
29981
- dynamicWidgetLoader$2.exports.dynamicWidgetLoader = dynamicWidgetLoader$1;
29982
-
29983
- var dynamicWidgetLoaderExports = dynamicWidgetLoader$2.exports;
29984
-
29985
29977
  /**
29986
- * schedulerController.js
29978
+ * Compile widget source files into a CJS bundle at dist/index.cjs.js.
29987
29979
  *
29988
- * Main process controller for widget scheduled tasks.
29989
- * Manages a tick loop (1s resolution), persistence (electron-store),
29990
- * and dispatching task-fired events to renderer windows.
29980
+ * For each {Name}.dash.js found in the widgets/ directory, a synthetic
29981
+ * entry point is generated that imports the component + config and
29982
+ * re-exports them as `{ ...config, component }` — matching what
29983
+ * extractWidgetConfigs() in widgetBundleLoader.js expects.
29984
+ *
29985
+ * @param {string} widgetPath - Absolute path to the widget directory
29986
+ * @returns {Promise<string|null>} Path to the compiled bundle, or null if nothing to compile
29991
29987
  */
29988
+ async function compileWidget(widgetPath) {
29989
+ const widgetsDir = findWidgetsDir$1(widgetPath);
29992
29990
 
29993
- const Store$1 = require$$1$1;
29994
- const { Cron } = require$$1$5;
29995
-
29996
- const store$3 = new Store$1({ name: "dash-scheduler" });
29997
-
29998
- // --- In-memory state ---
29999
- const tasks = new Map(); // taskId -> task object
30000
- const pendingResults = new Map(); // widgetId -> Array<{ taskId, taskKey, firedAt }>
29991
+ if (!widgetsDir) {
29992
+ console.log(
29993
+ `[WidgetCompiler] No widgets/ directory in ${widgetPath}, skipping`,
29994
+ );
29995
+ return null;
29996
+ }
30001
29997
 
30002
- const MAX_TASKS_PER_WIDGET = 20;
30003
- const MAX_PENDING_PER_WIDGET = 100;
30004
- const PERSIST_DEBOUNCE_MS = 30_000;
29998
+ // Discover .dash.js config files
29999
+ const files = fs$2.readdirSync(widgetsDir);
30000
+ const dashFiles = files.filter((f) => f.endsWith(".dash.js"));
30005
30001
 
30006
- let tickInterval = null;
30007
- let persistTimeout = null;
30008
- let deps = {
30009
- getWindows: null,
30010
- notificationController: null,
30011
- getMainWindow: null,
30012
- };
30002
+ if (dashFiles.length === 0) {
30003
+ console.log(
30004
+ `[WidgetCompiler] No .dash.js files found in ${widgetsDir}, skipping`,
30005
+ );
30006
+ return null;
30007
+ }
30013
30008
 
30014
- // --- Day name to cron day number ---
30015
- const DAY_MAP = {
30016
- sun: 0,
30017
- mon: 1,
30018
- tue: 2,
30019
- wed: 3,
30020
- thu: 4,
30021
- fri: 5,
30022
- sat: 6,
30023
- };
30009
+ // Build a synthetic entry point that pairs each component with its config.
30010
+ // Compute relative path from the entry file (in widgetPath) to widgetsDir,
30011
+ // since widgetsDir may be nested (e.g., ./weather-widget/widgets/).
30012
+ const relWidgetsDir =
30013
+ "./" + path$5.relative(widgetPath, widgetsDir).split(path$5.sep).join("/");
30014
+ const imports = [];
30015
+ const exportParts = [];
30024
30016
 
30025
- /**
30026
- * Build a cron expression from days + time for use with croner.
30027
- * @param {string[]} days - ["mon","wed","fri"] or ["every"]
30028
- * @param {string} time - "09:00" (HH:mm)
30029
- * @returns {string} cron expression
30030
- */
30031
- function buildCronExpression(days, time) {
30032
- const [hours, minutes] = time.split(":").map(Number);
30033
- if (days.includes("every")) {
30034
- return `${minutes} ${hours} * * *`;
30035
- }
30036
- const dayNums = days.map((d) => DAY_MAP[d]).filter((n) => n !== undefined);
30037
- return `${minutes} ${hours} * * ${dayNums.join(",")}`;
30038
- }
30017
+ for (const dashFile of dashFiles) {
30018
+ const componentName = dashFile.replace(".dash.js", "");
30019
+ const componentFile = `${componentName}.js`;
30020
+ const componentFilePath = path$5.join(widgetsDir, componentFile);
30021
+ const hasComponent = fs$2.existsSync(componentFilePath);
30039
30022
 
30040
- /**
30041
- * Compute the next fire timestamp for a task.
30042
- * @param {Object} task
30043
- * @param {number} now - current timestamp in ms
30044
- * @returns {number} next fire timestamp in ms
30045
- */
30046
- function computeNextFire(task, now) {
30047
- if (task.scheduleType === "interval") {
30048
- return now + (task.intervalMs || 60000);
30049
- }
30023
+ // Import the config (always)
30024
+ imports.push(
30025
+ `import ${componentName}Config from "${relWidgetsDir}/${dashFile}";`,
30026
+ );
30050
30027
 
30051
- if (task.scheduleType === "dayTime" && task.days && task.time) {
30052
- try {
30053
- const cronExpr = buildCronExpression(task.days, task.time);
30054
- const job = new Cron(cronExpr);
30055
- const next = job.nextRun();
30056
- if (next) {
30057
- return next.getTime();
30058
- }
30059
- } catch (err) {
30060
- console.error(
30061
- `[schedulerController] Error computing next fire for ${task.taskId}:`,
30062
- err,
30028
+ if (hasComponent) {
30029
+ // Import the component and merge with config
30030
+ imports.push(
30031
+ `import ${componentName}Comp from "${relWidgetsDir}/${componentFile}";`,
30032
+ );
30033
+ exportParts.push(
30034
+ `export const ${componentName} = { ...${componentName}Config, component: ${componentName}Comp };`,
30035
+ );
30036
+ } else {
30037
+ // Config-only (no component source file)
30038
+ exportParts.push(
30039
+ `export const ${componentName} = ${componentName}Config;`,
30063
30040
  );
30064
30041
  }
30065
- // Fallback: 1 hour from now
30066
- return now + 3600000;
30067
30042
  }
30068
30043
 
30069
- // Unknown schedule type default 1 hour
30070
- return now + 3600000;
30071
- }
30044
+ const entryContent = [...imports, "", ...exportParts, ""].join("\n");
30072
30045
 
30073
- /**
30074
- * Fire a task: broadcast to renderer windows, queue pending, send notification if no windows.
30075
- */
30076
- function fireTask(task) {
30077
- const payload = {
30078
- taskId: task.taskId,
30079
- widgetId: task.widgetId,
30080
- taskKey: task.taskKey,
30081
- handler: task.handler,
30082
- firedAt: Date.now(),
30083
- };
30084
-
30085
- console.log(
30086
- `[schedulerController] Fired: ${task.widgetName}.${task.taskKey} (${task.displayName})`,
30087
- );
30088
-
30089
- // Add to pending results queue
30090
- let queue = pendingResults.get(task.widgetId) || [];
30091
- queue.push({
30092
- taskId: task.taskId,
30093
- taskKey: task.taskKey,
30094
- firedAt: payload.firedAt,
30095
- });
30096
- if (queue.length > MAX_PENDING_PER_WIDGET) {
30097
- queue = queue.slice(-MAX_PENDING_PER_WIDGET);
30098
- }
30099
- pendingResults.set(task.widgetId, queue);
30046
+ // Write temporary entry file in the widget root
30047
+ const entryPath = path$5.join(widgetPath, "__compile_entry.js");
30048
+ const distDir = path$5.join(widgetPath, "dist");
30049
+ const outPath = path$5.join(distDir, "index.cjs.js");
30100
30050
 
30101
- // Broadcast to all windows
30102
- const windows = deps.getWindows ? deps.getWindows() : [];
30103
- if (windows.length > 0) {
30104
- for (const win of windows) {
30105
- if (!win.isDestroyed()) {
30106
- win.webContents.send("scheduler:task-fired", payload);
30107
- }
30108
- }
30109
- } else {
30110
- // No windows open — send native OS notification
30111
- if (deps.notificationController && deps.getMainWindow) {
30112
- deps.notificationController.send(deps.getMainWindow(), {
30113
- widgetName: task.widgetName,
30114
- widgetId: task.widgetId,
30115
- workspaceId: task.workspaceId || "",
30116
- type: "scheduled-task",
30117
- title: task.displayName || task.taskKey,
30118
- body: `Scheduled task "${task.displayName}" fired`,
30119
- silent: false,
30120
- });
30051
+ try {
30052
+ // Ensure dist/ directory exists
30053
+ if (!fs$2.existsSync(distDir)) {
30054
+ fs$2.mkdirSync(distDir, { recursive: true });
30121
30055
  }
30122
- }
30123
- }
30124
30056
 
30125
- /**
30126
- * Main tick — runs every 1s, checks all enabled tasks.
30127
- */
30128
- function tick() {
30129
- const now = Date.now();
30130
- for (const [, task] of tasks) {
30131
- if (!task.enabled || !task.nextFireAt || task.nextFireAt > now) continue;
30132
- fireTask(task);
30133
- task.nextFireAt = computeNextFire(task, now);
30134
- task.lastFiredAt = now;
30135
- task.fireCount = (task.fireCount || 0) + 1;
30136
- }
30137
- debouncedPersist();
30138
- }
30057
+ fs$2.writeFileSync(entryPath, entryContent, "utf8");
30139
30058
 
30140
- /**
30141
- * Persist tasks to electron-store (debounced).
30142
- */
30143
- function debouncedPersist() {
30144
- if (persistTimeout) return;
30145
- persistTimeout = setTimeout(() => {
30146
- persistTimeout = null;
30147
- persistNow();
30148
- }, PERSIST_DEBOUNCE_MS);
30149
- }
30059
+ console.log(
30060
+ `[WidgetCompiler] Compiling ${dashFiles.length} component(s) from ${widgetPath}`,
30061
+ );
30150
30062
 
30151
- function persistNow() {
30152
- try {
30153
- const data = {};
30154
- for (const [taskId, task] of tasks) {
30155
- data[taskId] = { ...task };
30156
- }
30157
- store$3.set("tasks", data);
30158
- } catch (err) {
30159
- console.error("[schedulerController] Error persisting tasks:", err);
30160
- }
30161
- }
30063
+ // Lazy-require esbuild so the module doesn't fail to load if
30064
+ // esbuild is not yet installed (e.g., during first npm install)
30065
+ const esbuild = require("esbuild");
30162
30066
 
30163
- /**
30164
- * Load persisted tasks from electron-store.
30165
- */
30166
- function loadFromStore() {
30167
- try {
30168
- const data = store$3.get("tasks", {});
30169
- const now = Date.now();
30170
- for (const [taskId, task] of Object.entries(data)) {
30171
- // Recompute nextFireAt if it's in the past
30172
- if (task.nextFireAt && task.nextFireAt <= now && task.enabled) {
30173
- task.nextFireAt = computeNextFire(task, now);
30067
+ await esbuild.build({
30068
+ entryPoints: [entryPath],
30069
+ bundle: true,
30070
+ format: "cjs",
30071
+ outfile: outPath,
30072
+ // These modules are provided by the host app via MODULE_MAP
30073
+ // in widgetBundleLoader.js — do NOT bundle them
30074
+ external: [
30075
+ "react",
30076
+ "react-dom",
30077
+ "@trops/dash-react",
30078
+ "@trops/dash-core",
30079
+ "react/jsx-runtime",
30080
+ "prop-types",
30081
+ ],
30082
+ // Treat .js files as JSX (widget sources use JSX in .js files)
30083
+ loader: { ".js": "jsx" },
30084
+ logLevel: "warning",
30085
+ });
30086
+
30087
+ console.log(`[WidgetCompiler] Compiled successfully → ${outPath}`);
30088
+ return outPath;
30089
+ } catch (error) {
30090
+ console.error(
30091
+ `[WidgetCompiler] Compilation failed for ${widgetPath}:`,
30092
+ error,
30093
+ );
30094
+ throw error;
30095
+ } finally {
30096
+ // Clean up temporary entry file
30097
+ try {
30098
+ if (fs$2.existsSync(entryPath)) {
30099
+ fs$2.unlinkSync(entryPath);
30174
30100
  }
30175
- tasks.set(taskId, task);
30101
+ } catch (cleanupError) {
30102
+ // Non-fatal
30103
+ console.warn(
30104
+ `[WidgetCompiler] Could not remove temp entry file:`,
30105
+ cleanupError,
30106
+ );
30176
30107
  }
30177
- console.log(`[schedulerController] Loaded ${tasks.size} tasks from store`);
30178
- } catch (err) {
30179
- console.error("[schedulerController] Error loading tasks:", err);
30180
30108
  }
30181
30109
  }
30182
30110
 
30111
+ var widgetCompiler$1 = { compileWidget, findWidgetsDir: findWidgetsDir$1 };
30112
+
30183
30113
  /**
30184
- * Count tasks for a given widget instance.
30114
+ * Dynamic Widget Loader
30115
+ *
30116
+ * Loads React components and configurations from downloaded/local widget paths
30117
+ * Works with widgets that follow the Dash widget structure:
30118
+ * - widgets/
30119
+ * - WidgetName.js (React component)
30120
+ * - WidgetName.dash.js (configuration)
30121
+ *
30122
+ * Integrates with ComponentManager for automatic registration
30185
30123
  */
30186
- function countTasksForWidget(widgetId) {
30187
- let count = 0;
30188
- for (const [, task] of tasks) {
30189
- if (task.widgetId === widgetId) count++;
30190
- }
30191
- return count;
30192
- }
30193
30124
 
30194
- const schedulerController$2 = {
30195
- /**
30196
- * Wire dependencies from the Electron main process.
30197
- */
30198
- init({ getWindows, notificationController, getMainWindow }) {
30199
- deps.getWindows = getWindows;
30200
- deps.notificationController = notificationController;
30201
- deps.getMainWindow = getMainWindow;
30202
- },
30125
+ const fs$1 = require$$0$3;
30126
+ const path$4 = require$$1$2;
30127
+ const vm = require$$2$3;
30128
+ const { findWidgetsDir } = widgetCompiler$1;
30203
30129
 
30204
- /**
30205
- * Start the tick loop and load persisted tasks.
30206
- */
30207
- start() {
30208
- loadFromStore();
30209
- if (!tickInterval) {
30210
- tickInterval = setInterval(tick, 1000);
30211
- console.log("[schedulerController] Tick loop started");
30212
- }
30213
- },
30130
+ class DynamicWidgetLoader {
30131
+ constructor(componentManager = null) {
30132
+ this.loadedWidgets = new Map();
30133
+ this.moduleCache = new Map();
30134
+ this.componentManager = componentManager;
30135
+ }
30214
30136
 
30215
30137
  /**
30216
- * Stop the tick loop and persist immediately.
30138
+ * Set ComponentManager instance for automatic widget registration
30139
+ * @param {Object} manager - ComponentManager instance from @trops/dash-react
30217
30140
  */
30218
- stop() {
30219
- if (tickInterval) {
30220
- clearInterval(tickInterval);
30221
- tickInterval = null;
30222
- }
30223
- if (persistTimeout) {
30224
- clearTimeout(persistTimeout);
30225
- persistTimeout = null;
30226
- }
30227
- persistNow();
30228
- console.log("[schedulerController] Stopped");
30229
- },
30141
+ setComponentManager(manager) {
30142
+ this.componentManager = manager;
30143
+ }
30230
30144
 
30231
30145
  /**
30232
- * Register or update a scheduled task.
30233
- *
30234
- * @param {Object} payload
30235
- * @param {string} payload.widgetId - widget instance UUID
30236
- * @param {string} payload.widgetName - component name
30237
- * @param {string} [payload.workspaceId]
30238
- * @param {string} payload.taskKey - key from .dash.js
30239
- * @param {string} payload.handler - handler function name
30240
- * @param {string} payload.displayName - human-readable name
30241
- * @param {string} payload.scheduleType - "interval" | "dayTime"
30242
- * @param {number} [payload.intervalMs] - for interval type
30243
- * @param {string[]} [payload.days] - for dayTime type
30244
- * @param {string} [payload.time] - for dayTime type (HH:mm)
30245
- * @param {boolean} [payload.enabled] - defaults to true
30246
- * @returns {{ success: boolean, taskId?: string, error?: string }}
30146
+ * Load a widget from a local path
30147
+ * @param {string} widgetName - Name of the widget (e.g., "MyFirstWidget")
30148
+ * @param {string} widgetPath - Path to the widget directory
30149
+ * @param {string} componentName - Name of the component file (e.g., "MyFirstWidgetWidget")
30150
+ * @param {boolean} autoRegister - Automatically register with ComponentManager (if available)
30151
+ * @returns {Promise<Object>} { component, config, registered }
30247
30152
  */
30248
- registerTask(payload) {
30153
+ async loadWidget(widgetName, widgetPath, componentName, autoRegister = true) {
30249
30154
  try {
30250
- const {
30251
- widgetId,
30252
- widgetName,
30253
- workspaceId,
30254
- taskKey,
30255
- handler,
30256
- displayName,
30257
- scheduleType,
30258
- intervalMs,
30259
- days,
30260
- time,
30261
- } = payload;
30155
+ const cacheKey = `${widgetName}:${componentName}`;
30262
30156
 
30263
- const taskId = `${widgetId}:${taskKey}`;
30264
- const existing = tasks.get(taskId);
30157
+ if (this.loadedWidgets.has(cacheKey)) {
30158
+ console.log(`[DynamicWidgetLoader] Loading ${widgetName} from cache`);
30159
+ return this.loadedWidgets.get(cacheKey);
30160
+ }
30265
30161
 
30266
- // Rate limit: max tasks per widget
30267
- if (!existing && countTasksForWidget(widgetId) >= MAX_TASKS_PER_WIDGET) {
30268
- return { success: false, error: "max_tasks_reached" };
30162
+ console.log(
30163
+ `[DynamicWidgetLoader] Loading widget: ${widgetName} from ${widgetPath}`,
30164
+ );
30165
+
30166
+ const widgetsDir =
30167
+ findWidgetsDir(widgetPath) || path$4.join(widgetPath, "widgets");
30168
+ const componentPath = path$4.join(widgetsDir, `${componentName}.js`);
30169
+ const configPath = path$4.join(widgetsDir, `${componentName}.dash.js`);
30170
+
30171
+ if (!fs$1.existsSync(componentPath)) {
30172
+ throw new Error(`Component file not found: ${componentPath}`);
30173
+ }
30174
+ if (!fs$1.existsSync(configPath)) {
30175
+ throw new Error(`Config file not found: ${configPath}`);
30269
30176
  }
30270
30177
 
30271
- const now = Date.now();
30272
- const task = {
30273
- taskId,
30274
- widgetId,
30275
- widgetName: widgetName || existing?.widgetName || "",
30276
- workspaceId: workspaceId || existing?.workspaceId || "",
30277
- taskKey,
30278
- handler: handler || existing?.handler || taskKey,
30279
- displayName: displayName || existing?.displayName || taskKey,
30280
- scheduleType: scheduleType || existing?.scheduleType || "interval",
30281
- intervalMs:
30282
- intervalMs !== undefined ? intervalMs : existing?.intervalMs || null,
30283
- days: days !== undefined ? days : existing?.days || null,
30284
- time: time !== undefined ? time : existing?.time || null,
30285
- enabled:
30286
- payload.enabled !== undefined
30287
- ? payload.enabled
30288
- : existing?.enabled !== undefined
30289
- ? existing.enabled
30290
- : true,
30291
- nextFireAt: 0,
30292
- lastFiredAt: existing?.lastFiredAt || null,
30293
- fireCount: existing?.fireCount || 0,
30294
- createdAt: existing?.createdAt || new Date().toISOString(),
30178
+ const config = await this.loadConfigFile(configPath);
30179
+
30180
+ const component = {
30181
+ path: componentPath,
30182
+ name: componentName,
30295
30183
  };
30296
30184
 
30297
- // Compute next fire
30298
- task.nextFireAt = task.enabled ? computeNextFire(task, now) : 0;
30185
+ let registered = false;
30299
30186
 
30300
- tasks.set(taskId, task);
30301
- debouncedPersist();
30187
+ if (autoRegister && this.componentManager) {
30188
+ try {
30189
+ // Use scoped id as registration key if available,
30190
+ // otherwise fall back to componentName
30191
+ const registrationKey = config.id || componentName;
30192
+ this.componentManager.registerWidget(config, registrationKey);
30193
+ registered = true;
30194
+ console.log(
30195
+ `[DynamicWidgetLoader] ✓ Registered ${registrationKey} with ComponentManager`,
30196
+ );
30197
+ } catch (regError) {
30198
+ console.warn(
30199
+ `[DynamicWidgetLoader] Failed to register with ComponentManager:`,
30200
+ regError,
30201
+ );
30202
+ }
30203
+ }
30302
30204
 
30303
- console.log(
30304
- `[schedulerController] Registered: ${taskId} (${task.scheduleType})`,
30305
- );
30205
+ const result = { component, config, registered };
30206
+ this.loadedWidgets.set(cacheKey, result);
30306
30207
 
30307
- return { success: true, taskId };
30208
+ return result;
30308
30209
  } catch (error) {
30309
- console.error("[schedulerController] Error registering task:", error);
30310
- return { success: false, error: error.message };
30210
+ console.error(
30211
+ `[DynamicWidgetLoader] Error loading widget ${widgetName}:`,
30212
+ error,
30213
+ );
30214
+ throw error;
30311
30215
  }
30312
- },
30216
+ }
30313
30217
 
30314
30218
  /**
30315
- * Remove a single task.
30316
- * @param {string} taskId
30317
- * @returns {{ success: boolean }}
30219
+ * Load and parse a .dash.js configuration file
30220
+ * @param {string} configPath - Path to the .dash.js file
30221
+ * @returns {Promise<Object>} Configuration object
30318
30222
  */
30319
- removeTask(taskId) {
30320
- const deleted = tasks.delete(taskId);
30321
- if (deleted) {
30322
- debouncedPersist();
30323
- console.log(`[schedulerController] Removed: ${taskId}`);
30324
- }
30325
- return { success: deleted };
30326
- },
30223
+ async loadConfigFile(configPath) {
30224
+ try {
30225
+ const source = fs$1.readFileSync(configPath, "utf8");
30327
30226
 
30328
- /**
30329
- * Remove all tasks for a widget instance.
30330
- * @param {string} widgetId
30331
- * @returns {{ success: boolean, count: number }}
30332
- */
30333
- removeTasks(widgetId) {
30334
- let count = 0;
30335
- for (const [taskId, task] of tasks) {
30336
- if (task.widgetId === widgetId) {
30337
- tasks.delete(taskId);
30338
- count++;
30227
+ let exportMatch = source.match(/export\s+default\s+({[\s\S]*});?\s*$/);
30228
+
30229
+ // Handle variable export pattern: const x = {...}; export default x;
30230
+ if (!exportMatch) {
30231
+ const varExportMatch = source.match(
30232
+ /export\s+default\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*;?\s*$/,
30233
+ );
30234
+ if (varExportMatch) {
30235
+ const varName = varExportMatch[1];
30236
+ const varDeclMatch = source.match(
30237
+ new RegExp(
30238
+ `(?:const|let|var)\\s+${varName}\\s*=\\s*({[\\s\\S]*?});\\s*(?:export\\s+default)`,
30239
+ ),
30240
+ );
30241
+ if (varDeclMatch) {
30242
+ exportMatch = varDeclMatch;
30243
+ }
30244
+ }
30339
30245
  }
30340
- }
30341
- if (count > 0) {
30342
- debouncedPersist();
30343
- pendingResults.delete(widgetId);
30344
- }
30345
- console.log(
30346
- `[schedulerController] Removed ${count} tasks for widget ${widgetId}`,
30347
- );
30348
- return { success: true, count };
30349
- },
30350
30246
 
30351
- /**
30352
- * Get all tasks for a widget instance.
30353
- * @param {string} widgetId
30354
- * @returns {Object[]}
30355
- */
30356
- getTasks(widgetId) {
30357
- const result = [];
30358
- for (const [, task] of tasks) {
30359
- if (task.widgetId === widgetId) {
30360
- result.push({ ...task });
30247
+ if (!exportMatch) {
30248
+ throw new Error("Could not find default export in config file");
30361
30249
  }
30250
+
30251
+ // Sanitize component references so vm.runInContext doesn't fail
30252
+ // on unresolvable imports — replace component: SomeName with component: "SomeName"
30253
+ const exportedObjectStr = exportMatch[1].replace(
30254
+ /component\s*:\s*([A-Z][a-zA-Z0-9_$]*)/g,
30255
+ 'component: "$1"',
30256
+ );
30257
+
30258
+ const context = vm.createContext({ module: { exports: {} } });
30259
+ vm.runInContext(`module.exports = ${exportedObjectStr}`, context);
30260
+
30261
+ return context.module.exports;
30262
+ } catch (error) {
30263
+ console.error(`[DynamicWidgetLoader] Error loading config:`, error);
30264
+ throw error;
30362
30265
  }
30363
- return result;
30364
- },
30266
+ }
30365
30267
 
30366
30268
  /**
30367
- * Update a task's schedule configuration.
30368
- * @param {string} taskId
30369
- * @param {Object} updates
30370
- * @returns {{ success: boolean }}
30269
+ * Discover available widgets in a directory
30270
+ * @param {string} widgetPath - Path to search for widgets
30271
+ * @returns {Array} List of available widget names
30371
30272
  */
30372
- updateTask(taskId, updates) {
30373
- const task = tasks.get(taskId);
30374
- if (!task) {
30375
- return { success: false, error: "task_not_found" };
30376
- }
30377
-
30378
- const allowedFields = [
30379
- "scheduleType",
30380
- "intervalMs",
30381
- "days",
30382
- "time",
30383
- "displayName",
30384
- "enabled",
30385
- ];
30386
- for (const key of allowedFields) {
30387
- if (updates[key] !== undefined) {
30388
- task[key] = updates[key];
30273
+ discoverWidgets(widgetPath) {
30274
+ try {
30275
+ const widgetsDir = findWidgetsDir(widgetPath);
30276
+ if (!widgetsDir) {
30277
+ return [];
30389
30278
  }
30390
- }
30391
30279
 
30392
- // Recompute next fire
30393
- if (task.enabled) {
30394
- task.nextFireAt = computeNextFire(task, Date.now());
30395
- } else {
30396
- task.nextFireAt = 0;
30397
- }
30280
+ const files = fs$1.readdirSync(widgetsDir);
30281
+ const widgets = new Set();
30398
30282
 
30399
- debouncedPersist();
30400
- console.log(`[schedulerController] Updated: ${taskId}`);
30401
- return { success: true };
30402
- },
30283
+ files.forEach((file) => {
30284
+ if (file.endsWith(".dash.js")) {
30285
+ const componentName = file.replace(".dash.js", "");
30286
+ widgets.add(componentName);
30287
+ }
30288
+ });
30403
30289
 
30404
- /**
30405
- * Enable a task.
30406
- * @param {string} taskId
30407
- * @returns {{ success: boolean }}
30408
- */
30409
- enableTask(taskId) {
30410
- const task = tasks.get(taskId);
30411
- if (!task) return { success: false, error: "task_not_found" };
30412
- task.enabled = true;
30413
- task.nextFireAt = computeNextFire(task, Date.now());
30414
- debouncedPersist();
30415
- console.log(`[schedulerController] Enabled: ${taskId}`);
30416
- return { success: true };
30417
- },
30290
+ return Array.from(widgets);
30291
+ } catch (error) {
30292
+ console.error(`[DynamicWidgetLoader] Error discovering widgets:`, error);
30293
+ return [];
30294
+ }
30295
+ }
30418
30296
 
30419
30297
  /**
30420
- * Disable a task.
30421
- * @param {string} taskId
30422
- * @returns {{ success: boolean }}
30298
+ * Clear cache
30423
30299
  */
30424
- disableTask(taskId) {
30425
- const task = tasks.get(taskId);
30426
- if (!task) return { success: false, error: "task_not_found" };
30427
- task.enabled = false;
30428
- task.nextFireAt = 0;
30429
- debouncedPersist();
30430
- console.log(`[schedulerController] Disabled: ${taskId}`);
30431
- return { success: true };
30432
- },
30300
+ clearCache() {
30301
+ this.loadedWidgets.clear();
30302
+ this.moduleCache.clear();
30303
+ }
30304
+ }
30433
30305
 
30434
- /**
30435
- * Drain pending fire results for a widget.
30436
- * @param {string} widgetId
30437
- * @returns {Object[]}
30438
- */
30439
- getPendingResults(widgetId) {
30440
- const queue = pendingResults.get(widgetId) || [];
30441
- pendingResults.delete(widgetId);
30442
- return queue;
30443
- },
30444
-
30445
- /**
30446
- * Handle system suspend — stop tick loop.
30447
- */
30448
- handleSuspend() {
30449
- if (tickInterval) {
30450
- clearInterval(tickInterval);
30451
- tickInterval = null;
30452
- console.log("[schedulerController] Suspended (tick stopped)");
30453
- }
30454
- },
30455
-
30456
- /**
30457
- * Handle system resume — fire missed tasks, restart tick.
30458
- */
30459
- handleResume() {
30460
- const now = Date.now();
30461
- for (const [, task] of tasks) {
30462
- if (task.enabled && task.nextFireAt && task.nextFireAt <= now) {
30463
- fireTask(task);
30464
- task.nextFireAt = computeNextFire(task, now);
30465
- task.lastFiredAt = now;
30466
- task.fireCount = (task.fireCount || 0) + 1;
30467
- }
30468
- }
30469
- debouncedPersist();
30470
- if (!tickInterval) {
30471
- tickInterval = setInterval(tick, 1000);
30472
- console.log("[schedulerController] Resumed (tick restarted)");
30473
- }
30474
- },
30306
+ const dynamicWidgetLoader$1 = new DynamicWidgetLoader();
30475
30307
 
30476
- /**
30477
- * Remove all tasks for a widget name (used on widget uninstall).
30478
- * @param {string} widgetName - component name
30479
- */
30480
- cleanupWidget(widgetName) {
30481
- let count = 0;
30482
- for (const [taskId, task] of tasks) {
30483
- if (task.widgetName === widgetName) {
30484
- tasks.delete(taskId);
30485
- pendingResults.delete(task.widgetId);
30486
- count++;
30487
- }
30488
- }
30489
- if (count > 0) {
30490
- debouncedPersist();
30491
- console.log(
30492
- `[schedulerController] Cleaned up ${count} tasks for widget "${widgetName}"`,
30493
- );
30494
- }
30495
- },
30496
- };
30308
+ dynamicWidgetLoader$2.exports = DynamicWidgetLoader;
30309
+ dynamicWidgetLoader$2.exports.dynamicWidgetLoader = dynamicWidgetLoader$1;
30497
30310
 
30498
- var schedulerController_1 = schedulerController$2;
30311
+ var dynamicWidgetLoaderExports = dynamicWidgetLoader$2.exports;
30499
30312
 
30500
30313
  /**
30501
- * Widget Registry System
30502
- *
30503
- * Manages widget discovery, download, and dynamic loading
30504
- * Widgets are expected to have:
30505
- * - package.json (or dash.json) with metadata
30506
- * - widgets/ folder containing [WidgetName].js and [WidgetName].dash.js
30314
+ * schedulerController.js
30507
30315
  *
30508
- * Files are stored in the Electron app's userData directory:
30509
- * - macOS: ~/Library/Application Support/[appName]/
30510
- * - Windows: %APPDATA%/[appName]/
30511
- * - Linux: ~/.config/[appName]/
30316
+ * Main process controller for widget scheduled tasks.
30317
+ * Manages a tick loop (1s resolution), persistence (electron-store),
30318
+ * and dispatching task-fired events to renderer windows.
30512
30319
  */
30513
30320
 
30514
- (function (module) {
30515
- const fs = require$$0$3;
30516
- const path = require$$1$2;
30517
- const os = require$$2$4;
30518
- const AdmZip = require$$3$4;
30519
- const { fileURLToPath } = require$$4$1;
30520
- const { app, ipcMain, BrowserWindow } = require$$0$2;
30521
- const { dynamicWidgetLoader } = dynamicWidgetLoaderExports;
30522
- const { compileWidget, findWidgetsDir } = widgetCompiler$1;
30321
+ const Store$1 = require$$1$1;
30322
+ const { Cron } = require$$1$5;
30523
30323
 
30524
- let WIDGETS_CACHE_DIR = null;
30525
- let REGISTRY_CONFIG_FILE = null;
30324
+ const store$2 = new Store$1({ name: "dash-scheduler" });
30526
30325
 
30527
- /**
30528
- * Validate ZIP entries to prevent path traversal attacks.
30529
- * Rejects entries containing '..' segments or absolute paths that would
30530
- * write outside the target extraction directory.
30531
- * @param {AdmZip} zip - AdmZip instance
30532
- * @param {string} targetDir - Intended extraction directory
30533
- * @throws {Error} If any entry would escape the target directory
30534
- */
30535
- function validateZipEntries(zip, targetDir) {
30536
- const resolvedTarget = path.resolve(targetDir);
30537
- for (const entry of zip.getEntries()) {
30538
- const entryPath = entry.entryName;
30539
- // Reject entries with '..' path segments
30540
- if (
30541
- entryPath.split("/").includes("..") ||
30542
- entryPath.split("\\").includes("..")
30543
- ) {
30544
- throw new Error(
30545
- `Malicious ZIP entry rejected (path traversal): ${entryPath}`,
30546
- );
30547
- }
30548
- // Reject absolute paths
30549
- if (path.isAbsolute(entryPath)) {
30550
- throw new Error(
30551
- `Malicious ZIP entry rejected (absolute path): ${entryPath}`,
30552
- );
30553
- }
30554
- // Final check: resolved path must be within target directory
30555
- const resolvedEntry = path.resolve(resolvedTarget, entryPath);
30556
- if (
30557
- !resolvedEntry.startsWith(resolvedTarget + path.sep) &&
30558
- resolvedEntry !== resolvedTarget
30559
- ) {
30560
- throw new Error(
30561
- `Malicious ZIP entry rejected (escapes target): ${entryPath}`,
30562
- );
30563
- }
30564
- }
30565
- }
30326
+ // --- In-memory state ---
30327
+ const tasks = new Map(); // taskId -> task object
30328
+ const pendingResults = new Map(); // widgetId -> Array<{ taskId, taskKey, firedAt }>
30566
30329
 
30567
- /**
30568
- * Initialize registry with custom path or default userData path
30569
- * @param {string} customPath - Optional custom path for storing widgets
30570
- */
30571
- function initializeRegistry(customPath = null) {
30572
- if (customPath) {
30573
- WIDGETS_CACHE_DIR = path.join(customPath, "widgets");
30574
- } else {
30575
- WIDGETS_CACHE_DIR = path.join(app.getPath("userData"), "widgets");
30576
- }
30577
- REGISTRY_CONFIG_FILE = path.join(WIDGETS_CACHE_DIR, "registry.json");
30578
- console.log(`[WidgetRegistry] Using storage path: ${WIDGETS_CACHE_DIR}`);
30579
- }
30330
+ const MAX_TASKS_PER_WIDGET = 20;
30331
+ const MAX_PENDING_PER_WIDGET = 100;
30332
+ const PERSIST_DEBOUNCE_MS = 30_000;
30580
30333
 
30581
- class WidgetRegistry {
30582
- constructor(componentManager = null, customPath = null) {
30583
- if (!WIDGETS_CACHE_DIR) {
30584
- initializeRegistry(customPath);
30585
- }
30334
+ let tickInterval = null;
30335
+ let persistTimeout = null;
30336
+ let deps = {
30337
+ getWindows: null,
30338
+ notificationController: null,
30339
+ getMainWindow: null,
30340
+ };
30586
30341
 
30587
- this.widgets = new Map();
30588
- this.componentManager = componentManager;
30589
- this.ensureCacheDir();
30590
- this.loadRegistry();
30591
- }
30342
+ // --- Day name to cron day number ---
30343
+ const DAY_MAP = {
30344
+ sun: 0,
30345
+ mon: 1,
30346
+ tue: 2,
30347
+ wed: 3,
30348
+ thu: 4,
30349
+ fri: 5,
30350
+ sat: 6,
30351
+ };
30592
30352
 
30593
- /**
30594
- * Static method to initialize registry with custom path
30595
- * Call this early in your app startup (e.g., in main.js)
30596
- * @param {string} customPath - Custom path for storing widgets/configs
30597
- */
30598
- static initialize(customPath = null) {
30599
- initializeRegistry(customPath);
30600
- }
30353
+ /**
30354
+ * Build a cron expression from days + time for use with croner.
30355
+ * @param {string[]} days - ["mon","wed","fri"] or ["every"]
30356
+ * @param {string} time - "09:00" (HH:mm)
30357
+ * @returns {string} cron expression
30358
+ */
30359
+ function buildCronExpression(days, time) {
30360
+ const [hours, minutes] = time.split(":").map(Number);
30361
+ if (days.includes("every")) {
30362
+ return `${minutes} ${hours} * * *`;
30363
+ }
30364
+ const dayNums = days.map((d) => DAY_MAP[d]).filter((n) => n !== undefined);
30365
+ return `${minutes} ${hours} * * ${dayNums.join(",")}`;
30366
+ }
30601
30367
 
30602
- /**
30603
- * Set ComponentManager instance for automatic widget registration
30604
- * @param {Object} manager - ComponentManager instance from @trops/dash-react
30605
- */
30606
- setComponentManager(manager) {
30607
- this.componentManager = manager;
30608
- }
30368
+ /**
30369
+ * Compute the next fire timestamp for a task.
30370
+ * @param {Object} task
30371
+ * @param {number} now - current timestamp in ms
30372
+ * @returns {number} next fire timestamp in ms
30373
+ */
30374
+ function computeNextFire(task, now) {
30375
+ if (task.scheduleType === "interval") {
30376
+ return now + (task.intervalMs || 60000);
30377
+ }
30609
30378
 
30610
- /**
30611
- * Ensure cache directory exists
30612
- */
30613
- ensureCacheDir() {
30614
- if (!fs.existsSync(WIDGETS_CACHE_DIR)) {
30615
- fs.mkdirSync(WIDGETS_CACHE_DIR, { recursive: true });
30616
- }
30617
- }
30379
+ if (task.scheduleType === "dayTime" && task.days && task.time) {
30380
+ try {
30381
+ const cronExpr = buildCronExpression(task.days, task.time);
30382
+ const job = new Cron(cronExpr);
30383
+ const next = job.nextRun();
30384
+ if (next) {
30385
+ return next.getTime();
30386
+ }
30387
+ } catch (err) {
30388
+ console.error(
30389
+ `[schedulerController] Error computing next fire for ${task.taskId}:`,
30390
+ err,
30391
+ );
30392
+ }
30393
+ // Fallback: 1 hour from now
30394
+ return now + 3600000;
30395
+ }
30618
30396
 
30619
- /**
30620
- * Load registry from disk
30621
- */
30622
- loadRegistry() {
30623
- try {
30624
- if (fs.existsSync(REGISTRY_CONFIG_FILE)) {
30625
- const data = fs.readFileSync(REGISTRY_CONFIG_FILE, "utf8");
30626
- const registryData = JSON.parse(data);
30627
- this.widgets = new Map(registryData.widgets || []);
30628
- console.log(
30629
- `[WidgetRegistry] Loaded ${this.widgets.size} widgets from cache`,
30630
- );
30631
- }
30632
- } catch (error) {
30633
- console.error("[WidgetRegistry] Error loading registry:", error);
30634
- }
30635
- }
30636
-
30637
- /**
30638
- * Save registry to disk
30639
- */
30640
- saveRegistry() {
30641
- try {
30642
- const registryData = {
30643
- lastUpdated: new Date().toISOString(),
30644
- widgets: Array.from(this.widgets.entries()),
30645
- };
30646
- fs.writeFileSync(
30647
- REGISTRY_CONFIG_FILE,
30648
- JSON.stringify(registryData, null, 2),
30649
- );
30650
- } catch (error) {
30651
- console.error("[WidgetRegistry] Error saving registry:", error);
30652
- }
30653
- }
30654
-
30655
- /**
30656
- * Resolve download URL from partial template or full URL
30657
- * Supports placeholders: {version}, {name}
30658
- *
30659
- * Examples:
30660
- * - Full URL: "https://github.com/user/widget/releases/download/v1.0.0/widget.zip"
30661
- * - Template: "https://github.com/user/weather-widget/releases/download/v{version}/weather-widget.zip"
30662
- * - Partial: "https://github.com/user/weather-widget/releases/download/" (auto-generates v{version}/{name}.zip)
30663
- *
30664
- * @param {string} urlTemplate - URL template or partial URL
30665
- * @param {string} version - Widget version (e.g., "1.0.0")
30666
- * @param {string} name - Widget name (e.g., "weather-widget")
30667
- * @returns {string} Resolved download URL
30668
- */
30669
- resolveDownloadUrl(urlTemplate, version, name) {
30670
- if (!urlTemplate) return null;
30671
-
30672
- if (urlTemplate.endsWith("/")) {
30673
- return `${urlTemplate}v${version}/${name}.zip`;
30674
- }
30675
-
30676
- let url = urlTemplate;
30677
- url = url.replace("{version}", version);
30678
- url = url.replace("{name}", name);
30679
- return url;
30680
- }
30681
-
30682
- /**
30683
- * Determine if the input points to a local path (file:// or filesystem path)
30684
- * @param {string} input - URL or path
30685
- * @returns {boolean}
30686
- */
30687
- isLocalSource(input) {
30688
- if (!input) return false;
30689
- if (input.startsWith("file://")) return true;
30690
- if (input.startsWith("http://") || input.startsWith("https://"))
30691
- return false;
30692
- const resolvedPath = this.resolveLocalPath(input);
30693
- return fs.existsSync(resolvedPath);
30694
- }
30695
-
30696
- /**
30697
- * Normalize a local path (supports file:// and ~)
30698
- * @param {string} input - Local path or file:// URL
30699
- * @returns {string}
30700
- */
30701
- resolveLocalPath(input) {
30702
- if (input.startsWith("file://")) {
30703
- return fileURLToPath(input);
30704
- }
30705
- if (input.startsWith("~")) {
30706
- return path.join(os.homedir(), input.slice(1));
30707
- }
30708
- return path.resolve(input);
30709
- }
30397
+ // Unknown schedule type — default 1 hour
30398
+ return now + 3600000;
30399
+ }
30710
30400
 
30711
- /**
30712
- * Install a widget from a local ZIP file or folder path
30713
- * @param {string} widgetName - Name of the widget
30714
- * @param {string} localPath - Path to ZIP file or widget folder
30715
- * @param {boolean} autoRegister - Automatically register with ComponentManager
30716
- * @param {string} dashConfigPath - Optional: path to dash.json metadata file
30717
- * @returns {Promise<Object>} Widget configuration
30718
- */
30719
- async installFromLocalPath(
30720
- widgetName,
30721
- localPath,
30722
- autoRegister = true,
30723
- dashConfigPath = null,
30724
- ) {
30725
- try {
30726
- const resolvedPath = this.resolveLocalPath(localPath);
30401
+ /**
30402
+ * Fire a task: broadcast to renderer windows, queue pending, send notification if no windows.
30403
+ */
30404
+ function fireTask(task) {
30405
+ const payload = {
30406
+ taskId: task.taskId,
30407
+ widgetId: task.widgetId,
30408
+ taskKey: task.taskKey,
30409
+ handler: task.handler,
30410
+ firedAt: Date.now(),
30411
+ };
30727
30412
 
30728
- if (!fs.existsSync(resolvedPath)) {
30729
- throw new Error(`Local path not found: ${resolvedPath}`);
30730
- }
30413
+ console.log(
30414
+ `[schedulerController] Fired: ${task.widgetName}.${task.taskKey} (${task.displayName})`,
30415
+ );
30731
30416
 
30732
- const widgetPath = path.join(WIDGETS_CACHE_DIR, widgetName);
30417
+ // Add to pending results queue
30418
+ let queue = pendingResults.get(task.widgetId) || [];
30419
+ queue.push({
30420
+ taskId: task.taskId,
30421
+ taskKey: task.taskKey,
30422
+ firedAt: payload.firedAt,
30423
+ });
30424
+ if (queue.length > MAX_PENDING_PER_WIDGET) {
30425
+ queue = queue.slice(-MAX_PENDING_PER_WIDGET);
30426
+ }
30427
+ pendingResults.set(task.widgetId, queue);
30733
30428
 
30734
- if (fs.existsSync(widgetPath)) {
30735
- fs.rmSync(widgetPath, { recursive: true });
30736
- }
30429
+ // Broadcast to all windows
30430
+ const windows = deps.getWindows ? deps.getWindows() : [];
30431
+ if (windows.length > 0) {
30432
+ for (const win of windows) {
30433
+ if (!win.isDestroyed()) {
30434
+ win.webContents.send("scheduler:task-fired", payload);
30435
+ }
30436
+ }
30437
+ } else {
30438
+ // No windows open — send native OS notification
30439
+ if (deps.notificationController && deps.getMainWindow) {
30440
+ deps.notificationController.send(deps.getMainWindow(), {
30441
+ widgetName: task.widgetName,
30442
+ widgetId: task.widgetId,
30443
+ workspaceId: task.workspaceId || "",
30444
+ type: "scheduled-task",
30445
+ title: task.displayName || task.taskKey,
30446
+ body: `Scheduled task "${task.displayName}" fired`,
30447
+ silent: false,
30448
+ });
30449
+ }
30450
+ }
30451
+ }
30737
30452
 
30738
- const isDirectory = fs.statSync(resolvedPath).isDirectory();
30739
- if (isDirectory) {
30740
- fs.cpSync(resolvedPath, widgetPath, { recursive: true });
30741
- } else if (resolvedPath.endsWith(".zip")) {
30742
- const zip = new AdmZip(resolvedPath);
30743
- validateZipEntries(zip, widgetPath);
30744
- zip.extractAllTo(widgetPath, true);
30745
- } else {
30746
- throw new Error(`Unsupported local source type: ${resolvedPath}`);
30747
- }
30453
+ /**
30454
+ * Main tick — runs every 1s, checks all enabled tasks.
30455
+ */
30456
+ function tick() {
30457
+ const now = Date.now();
30458
+ for (const [, task] of tasks) {
30459
+ if (!task.enabled || !task.nextFireAt || task.nextFireAt > now) continue;
30460
+ fireTask(task);
30461
+ task.nextFireAt = computeNextFire(task, now);
30462
+ task.lastFiredAt = now;
30463
+ task.fireCount = (task.fireCount || 0) + 1;
30464
+ }
30465
+ debouncedPersist();
30466
+ }
30748
30467
 
30749
- let config = await this.loadWidgetConfig(widgetName, widgetPath);
30468
+ /**
30469
+ * Persist tasks to electron-store (debounced).
30470
+ */
30471
+ function debouncedPersist() {
30472
+ if (persistTimeout) return;
30473
+ persistTimeout = setTimeout(() => {
30474
+ persistTimeout = null;
30475
+ persistNow();
30476
+ }, PERSIST_DEBOUNCE_MS);
30477
+ }
30750
30478
 
30751
- if (dashConfigPath) {
30752
- const configPath = this.resolveLocalPath(dashConfigPath);
30753
- if (fs.existsSync(configPath)) {
30754
- const dashConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
30755
- config = { ...config, ...dashConfig };
30756
- }
30757
- }
30479
+ function persistNow() {
30480
+ try {
30481
+ const data = {};
30482
+ for (const [taskId, task] of tasks) {
30483
+ data[taskId] = { ...task };
30484
+ }
30485
+ store$2.set("tasks", data);
30486
+ } catch (err) {
30487
+ console.error("[schedulerController] Error persisting tasks:", err);
30488
+ }
30489
+ }
30758
30490
 
30759
- this.registerWidget(widgetName, config, widgetPath, false);
30491
+ /**
30492
+ * Load persisted tasks from electron-store.
30493
+ */
30494
+ function loadFromStore() {
30495
+ try {
30496
+ const data = store$2.get("tasks", {});
30497
+ const now = Date.now();
30498
+ for (const [taskId, task] of Object.entries(data)) {
30499
+ // Recompute nextFireAt if it's in the past
30500
+ if (task.nextFireAt && task.nextFireAt <= now && task.enabled) {
30501
+ task.nextFireAt = computeNextFire(task, now);
30502
+ }
30503
+ tasks.set(taskId, task);
30504
+ }
30505
+ console.log(`[schedulerController] Loaded ${tasks.size} tasks from store`);
30506
+ } catch (err) {
30507
+ console.error("[schedulerController] Error loading tasks:", err);
30508
+ }
30509
+ }
30760
30510
 
30761
- if (autoRegister) {
30762
- await this.loadWidgetComponents(widgetName, widgetPath);
30763
- }
30511
+ /**
30512
+ * Count tasks for a given widget instance.
30513
+ */
30514
+ function countTasksForWidget(widgetId) {
30515
+ let count = 0;
30516
+ for (const [, task] of tasks) {
30517
+ if (task.widgetId === widgetId) count++;
30518
+ }
30519
+ return count;
30520
+ }
30764
30521
 
30765
- return config;
30766
- } catch (error) {
30767
- console.error(
30768
- `[WidgetRegistry] Error installing local widget ${widgetName}:`,
30769
- error,
30770
- );
30771
- throw error;
30772
- }
30773
- }
30522
+ const schedulerController$2 = {
30523
+ /**
30524
+ * Wire dependencies from the Electron main process.
30525
+ */
30526
+ init({ getWindows, notificationController, getMainWindow }) {
30527
+ deps.getWindows = getWindows;
30528
+ deps.notificationController = notificationController;
30529
+ deps.getMainWindow = getMainWindow;
30530
+ },
30774
30531
 
30775
- /**
30776
- * Check if a directory looks like a valid widget folder.
30777
- * A directory is a widget if it has:
30778
- * - package.json or dash.json at its root, OR
30779
- * - A widgets/ subdirectory containing at least one .dash.js file
30780
- * @param {string} dirPath - Path to the directory
30781
- * @returns {boolean}
30782
- */
30783
- isWidgetFolder(dirPath) {
30784
- if (fs.existsSync(path.join(dirPath, "package.json"))) return true;
30785
- if (fs.existsSync(path.join(dirPath, "dash.json"))) return true;
30532
+ /**
30533
+ * Start the tick loop and load persisted tasks.
30534
+ */
30535
+ start() {
30536
+ loadFromStore();
30537
+ if (!tickInterval) {
30538
+ tickInterval = setInterval(tick, 1000);
30539
+ console.log("[schedulerController] Tick loop started");
30540
+ }
30541
+ },
30786
30542
 
30787
- const widgetsDir = path.join(dirPath, "widgets");
30788
- if (fs.existsSync(widgetsDir) && fs.statSync(widgetsDir).isDirectory()) {
30789
- const files = fs.readdirSync(widgetsDir);
30790
- if (files.some((f) => f.endsWith(".dash.js"))) return true;
30791
- }
30543
+ /**
30544
+ * Stop the tick loop and persist immediately.
30545
+ */
30546
+ stop() {
30547
+ if (tickInterval) {
30548
+ clearInterval(tickInterval);
30549
+ tickInterval = null;
30550
+ }
30551
+ if (persistTimeout) {
30552
+ clearTimeout(persistTimeout);
30553
+ persistTimeout = null;
30554
+ }
30555
+ persistNow();
30556
+ console.log("[schedulerController] Stopped");
30557
+ },
30792
30558
 
30793
- return false;
30794
- }
30559
+ /**
30560
+ * Register or update a scheduled task.
30561
+ *
30562
+ * @param {Object} payload
30563
+ * @param {string} payload.widgetId - widget instance UUID
30564
+ * @param {string} payload.widgetName - component name
30565
+ * @param {string} [payload.workspaceId]
30566
+ * @param {string} payload.taskKey - key from .dash.js
30567
+ * @param {string} payload.handler - handler function name
30568
+ * @param {string} payload.displayName - human-readable name
30569
+ * @param {string} payload.scheduleType - "interval" | "dayTime"
30570
+ * @param {number} [payload.intervalMs] - for interval type
30571
+ * @param {string[]} [payload.days] - for dayTime type
30572
+ * @param {string} [payload.time] - for dayTime type (HH:mm)
30573
+ * @param {boolean} [payload.enabled] - defaults to true
30574
+ * @returns {{ success: boolean, taskId?: string, error?: string }}
30575
+ */
30576
+ registerTask(payload) {
30577
+ try {
30578
+ const {
30579
+ widgetId,
30580
+ widgetName,
30581
+ workspaceId,
30582
+ taskKey,
30583
+ handler,
30584
+ displayName,
30585
+ scheduleType,
30586
+ intervalMs,
30587
+ days,
30588
+ time,
30589
+ } = payload;
30795
30590
 
30796
- /**
30797
- * Register all widgets found in a local folder.
30798
- *
30799
- * Smart detection:
30800
- * 1. If the selected folder itself is a widget, install it directly.
30801
- * 2. Otherwise iterate subdirectories, skipping non-widget dirs.
30802
- *
30803
- * @param {string} folderPath - Path containing widget folders (or a single widget folder)
30804
- * @param {boolean} autoRegister - Automatically register with ComponentManager
30805
- * @returns {Promise<Array>} Registered widgets (with optional `mode` and `skipped` metadata)
30806
- */
30807
- async registerWidgetsFromFolder(folderPath, autoRegister = true) {
30808
- const SKIP_DIRS = new Set(["node_modules", "dist", "__MACOSX", ".git"]);
30591
+ const taskId = `${widgetId}:${taskKey}`;
30592
+ const existing = tasks.get(taskId);
30809
30593
 
30810
- try {
30811
- const resolvedPath = this.resolveLocalPath(folderPath);
30812
- if (!fs.existsSync(resolvedPath)) {
30813
- throw new Error(`Folder not found: ${resolvedPath}`);
30814
- }
30815
- if (!fs.statSync(resolvedPath).isDirectory()) {
30816
- throw new Error(`Path is not a directory: ${resolvedPath}`);
30817
- }
30594
+ // Rate limit: max tasks per widget
30595
+ if (!existing && countTasksForWidget(widgetId) >= MAX_TASKS_PER_WIDGET) {
30596
+ return { success: false, error: "max_tasks_reached" };
30597
+ }
30818
30598
 
30819
- // 1. Check if the selected folder itself is a widget
30820
- if (this.isWidgetFolder(resolvedPath)) {
30821
- const widgetName = path.basename(resolvedPath);
30822
- const config = await this.installFromLocalPath(
30823
- widgetName,
30824
- resolvedPath,
30825
- autoRegister,
30826
- );
30827
- return [
30828
- {
30829
- name: widgetName,
30830
- path: resolvedPath,
30831
- ...config,
30832
- mode: "single",
30833
- },
30834
- ];
30835
- }
30599
+ const now = Date.now();
30600
+ const task = {
30601
+ taskId,
30602
+ widgetId,
30603
+ widgetName: widgetName || existing?.widgetName || "",
30604
+ workspaceId: workspaceId || existing?.workspaceId || "",
30605
+ taskKey,
30606
+ handler: handler || existing?.handler || taskKey,
30607
+ displayName: displayName || existing?.displayName || taskKey,
30608
+ scheduleType: scheduleType || existing?.scheduleType || "interval",
30609
+ intervalMs:
30610
+ intervalMs !== undefined ? intervalMs : existing?.intervalMs || null,
30611
+ days: days !== undefined ? days : existing?.days || null,
30612
+ time: time !== undefined ? time : existing?.time || null,
30613
+ enabled:
30614
+ payload.enabled !== undefined
30615
+ ? payload.enabled
30616
+ : existing?.enabled !== undefined
30617
+ ? existing.enabled
30618
+ : true,
30619
+ nextFireAt: 0,
30620
+ lastFiredAt: existing?.lastFiredAt || null,
30621
+ fireCount: existing?.fireCount || 0,
30622
+ createdAt: existing?.createdAt || new Date().toISOString(),
30623
+ };
30836
30624
 
30837
- // 2. Iterate subdirectories with filtering
30838
- const entries = fs.readdirSync(resolvedPath, {
30839
- withFileTypes: true,
30840
- });
30841
- const results = [];
30842
- let skipped = 0;
30625
+ // Compute next fire
30626
+ task.nextFireAt = task.enabled ? computeNextFire(task, now) : 0;
30843
30627
 
30844
- for (const entry of entries) {
30845
- if (!entry.isDirectory()) continue;
30628
+ tasks.set(taskId, task);
30629
+ debouncedPersist();
30846
30630
 
30847
- // Skip hidden dirs and known non-widget dirs
30848
- if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
30849
- skipped++;
30850
- continue;
30851
- }
30631
+ console.log(
30632
+ `[schedulerController] Registered: ${taskId} (${task.scheduleType})`,
30633
+ );
30852
30634
 
30853
- const widgetPath = path.join(resolvedPath, entry.name);
30635
+ return { success: true, taskId };
30636
+ } catch (error) {
30637
+ console.error("[schedulerController] Error registering task:", error);
30638
+ return { success: false, error: error.message };
30639
+ }
30640
+ },
30854
30641
 
30855
- if (!this.isWidgetFolder(widgetPath)) {
30856
- skipped++;
30857
- continue;
30858
- }
30642
+ /**
30643
+ * Remove a single task.
30644
+ * @param {string} taskId
30645
+ * @returns {{ success: boolean }}
30646
+ */
30647
+ removeTask(taskId) {
30648
+ const deleted = tasks.delete(taskId);
30649
+ if (deleted) {
30650
+ debouncedPersist();
30651
+ console.log(`[schedulerController] Removed: ${taskId}`);
30652
+ }
30653
+ return { success: deleted };
30654
+ },
30859
30655
 
30860
- const config = await this.loadWidgetConfig(entry.name, widgetPath);
30861
- this.registerWidget(entry.name, config, widgetPath, false);
30656
+ /**
30657
+ * Remove all tasks for a widget instance.
30658
+ * @param {string} widgetId
30659
+ * @returns {{ success: boolean, count: number }}
30660
+ */
30661
+ removeTasks(widgetId) {
30662
+ let count = 0;
30663
+ for (const [taskId, task] of tasks) {
30664
+ if (task.widgetId === widgetId) {
30665
+ tasks.delete(taskId);
30666
+ count++;
30667
+ }
30668
+ }
30669
+ if (count > 0) {
30670
+ debouncedPersist();
30671
+ pendingResults.delete(widgetId);
30672
+ }
30673
+ console.log(
30674
+ `[schedulerController] Removed ${count} tasks for widget ${widgetId}`,
30675
+ );
30676
+ return { success: true, count };
30677
+ },
30862
30678
 
30863
- if (autoRegister) {
30864
- await this.loadWidgetComponents(entry.name, widgetPath);
30865
- }
30679
+ /**
30680
+ * Get all tasks for a widget instance.
30681
+ * @param {string} widgetId
30682
+ * @returns {Object[]}
30683
+ */
30684
+ getTasks(widgetId) {
30685
+ const result = [];
30686
+ for (const [, task] of tasks) {
30687
+ if (task.widgetId === widgetId) {
30688
+ result.push({ ...task });
30689
+ }
30690
+ }
30691
+ return result;
30692
+ },
30866
30693
 
30867
- results.push({
30868
- name: entry.name,
30869
- path: widgetPath,
30870
- ...config,
30871
- });
30872
- }
30694
+ /**
30695
+ * Update a task's schedule configuration.
30696
+ * @param {string} taskId
30697
+ * @param {Object} updates
30698
+ * @returns {{ success: boolean }}
30699
+ */
30700
+ updateTask(taskId, updates) {
30701
+ const task = tasks.get(taskId);
30702
+ if (!task) {
30703
+ return { success: false, error: "task_not_found" };
30704
+ }
30873
30705
 
30874
- // Attach skipped count as metadata on the array
30875
- results.skipped = skipped;
30876
- return results;
30877
- } catch (error) {
30878
- console.error(
30879
- "[WidgetRegistry] Error registering widgets from folder:",
30880
- error,
30881
- );
30882
- throw error;
30883
- }
30884
- }
30706
+ const allowedFields = [
30707
+ "scheduleType",
30708
+ "intervalMs",
30709
+ "days",
30710
+ "time",
30711
+ "displayName",
30712
+ "enabled",
30713
+ ];
30714
+ for (const key of allowedFields) {
30715
+ if (updates[key] !== undefined) {
30716
+ task[key] = updates[key];
30717
+ }
30718
+ }
30885
30719
 
30886
- /**
30887
- * Download widget from URL (ZIP file)
30888
- * @param {string} widgetName - Name of the widget
30889
- * @param {string} downloadUrl - URL to download ZIP file from (supports templates and partial URLs)
30890
- * @param {string} dashConfigUrl - Optional: URL to dash.json metadata file
30891
- * @param {boolean} autoRegister - Automatically register with ComponentManager
30892
- * @returns {Promise<Object>} Widget configuration
30893
- */
30894
- async downloadWidget(
30895
- widgetName,
30896
- downloadUrl,
30897
- dashConfigUrl = null,
30898
- autoRegister = true,
30899
- ) {
30900
- try {
30901
- if (this.isLocalSource(downloadUrl)) {
30902
- return this.installFromLocalPath(
30903
- widgetName,
30904
- downloadUrl,
30905
- autoRegister,
30906
- dashConfigUrl,
30907
- );
30908
- }
30909
- // Enforce HTTPS to prevent MITM attacks on widget downloads
30910
- const parsedUrl = new URL(downloadUrl);
30911
- if (parsedUrl.protocol !== "https:") {
30912
- throw new Error(
30913
- `Widget downloads must use HTTPS. Refusing to fetch: ${downloadUrl}`,
30914
- );
30915
- }
30720
+ // Recompute next fire
30721
+ if (task.enabled) {
30722
+ task.nextFireAt = computeNextFire(task, Date.now());
30723
+ } else {
30724
+ task.nextFireAt = 0;
30725
+ }
30916
30726
 
30917
- console.log(
30918
- `[WidgetRegistry] Downloading widget: ${widgetName} from ${downloadUrl}`,
30919
- );
30727
+ debouncedPersist();
30728
+ console.log(`[schedulerController] Updated: ${taskId}`);
30729
+ return { success: true };
30730
+ },
30920
30731
 
30921
- const response = await fetch(downloadUrl);
30922
- if (!response.ok)
30923
- throw new Error(`Failed to fetch: ${response.statusText}`);
30732
+ /**
30733
+ * Enable a task.
30734
+ * @param {string} taskId
30735
+ * @returns {{ success: boolean }}
30736
+ */
30737
+ enableTask(taskId) {
30738
+ const task = tasks.get(taskId);
30739
+ if (!task) return { success: false, error: "task_not_found" };
30740
+ task.enabled = true;
30741
+ task.nextFireAt = computeNextFire(task, Date.now());
30742
+ debouncedPersist();
30743
+ console.log(`[schedulerController] Enabled: ${taskId}`);
30744
+ return { success: true };
30745
+ },
30924
30746
 
30925
- const buffer = await response.arrayBuffer();
30926
- const zip = new AdmZip(Buffer.from(buffer));
30747
+ /**
30748
+ * Disable a task.
30749
+ * @param {string} taskId
30750
+ * @returns {{ success: boolean }}
30751
+ */
30752
+ disableTask(taskId) {
30753
+ const task = tasks.get(taskId);
30754
+ if (!task) return { success: false, error: "task_not_found" };
30755
+ task.enabled = false;
30756
+ task.nextFireAt = 0;
30757
+ debouncedPersist();
30758
+ console.log(`[schedulerController] Disabled: ${taskId}`);
30759
+ return { success: true };
30760
+ },
30927
30761
 
30928
- const widgetPath = path.join(WIDGETS_CACHE_DIR, widgetName);
30762
+ /**
30763
+ * Drain pending fire results for a widget.
30764
+ * @param {string} widgetId
30765
+ * @returns {Object[]}
30766
+ */
30767
+ getPendingResults(widgetId) {
30768
+ const queue = pendingResults.get(widgetId) || [];
30769
+ pendingResults.delete(widgetId);
30770
+ return queue;
30771
+ },
30929
30772
 
30930
- if (fs.existsSync(widgetPath)) {
30931
- fs.rmSync(widgetPath, { recursive: true });
30932
- }
30773
+ /**
30774
+ * Handle system suspend — stop tick loop.
30775
+ */
30776
+ handleSuspend() {
30777
+ if (tickInterval) {
30778
+ clearInterval(tickInterval);
30779
+ tickInterval = null;
30780
+ console.log("[schedulerController] Suspended (tick stopped)");
30781
+ }
30782
+ },
30933
30783
 
30934
- validateZipEntries(zip, widgetPath);
30935
- zip.extractAllTo(widgetPath, true);
30936
- console.log(`[WidgetRegistry] Extracted widget to: ${widgetPath}`);
30784
+ /**
30785
+ * Handle system resume — fire missed tasks, restart tick.
30786
+ */
30787
+ handleResume() {
30788
+ const now = Date.now();
30789
+ for (const [, task] of tasks) {
30790
+ if (task.enabled && task.nextFireAt && task.nextFireAt <= now) {
30791
+ fireTask(task);
30792
+ task.nextFireAt = computeNextFire(task, now);
30793
+ task.lastFiredAt = now;
30794
+ task.fireCount = (task.fireCount || 0) + 1;
30795
+ }
30796
+ }
30797
+ debouncedPersist();
30798
+ if (!tickInterval) {
30799
+ tickInterval = setInterval(tick, 1000);
30800
+ console.log("[schedulerController] Resumed (tick restarted)");
30801
+ }
30802
+ },
30803
+
30804
+ /**
30805
+ * Remove all tasks for a widget name (used on widget uninstall).
30806
+ * @param {string} widgetName - component name
30807
+ */
30808
+ cleanupWidget(widgetName) {
30809
+ let count = 0;
30810
+ for (const [taskId, task] of tasks) {
30811
+ if (task.widgetName === widgetName) {
30812
+ tasks.delete(taskId);
30813
+ pendingResults.delete(task.widgetId);
30814
+ count++;
30815
+ }
30816
+ }
30817
+ if (count > 0) {
30818
+ debouncedPersist();
30819
+ console.log(
30820
+ `[schedulerController] Cleaned up ${count} tasks for widget "${widgetName}"`,
30821
+ );
30822
+ }
30823
+ },
30824
+ };
30937
30825
 
30938
- let config = await this.loadWidgetConfig(widgetName, widgetPath);
30826
+ var schedulerController_1 = schedulerController$2;
30939
30827
 
30940
- if (dashConfigUrl) {
30941
- const dashConfig = await this.fetchJSON(dashConfigUrl);
30942
- config = { ...config, ...dashConfig };
30943
- }
30828
+ /**
30829
+ * Widget Registry System
30830
+ *
30831
+ * Manages widget discovery, download, and dynamic loading
30832
+ * Widgets are expected to have:
30833
+ * - package.json (or dash.json) with metadata
30834
+ * - widgets/ folder containing [WidgetName].js and [WidgetName].dash.js
30835
+ *
30836
+ * Files are stored in the Electron app's userData directory:
30837
+ * - macOS: ~/Library/Application Support/[appName]/
30838
+ * - Windows: %APPDATA%/[appName]/
30839
+ * - Linux: ~/.config/[appName]/
30840
+ */
30944
30841
 
30945
- this.registerWidget(widgetName, config, widgetPath, false);
30842
+ (function (module) {
30843
+ const fs = require$$0$3;
30844
+ const path = require$$1$2;
30845
+ const os = require$$2$4;
30846
+ const AdmZip = require$$3$4;
30847
+ const { fileURLToPath } = require$$4$1;
30848
+ const { app, ipcMain, BrowserWindow } = require$$0$2;
30849
+ const { dynamicWidgetLoader } = dynamicWidgetLoaderExports;
30850
+ const { compileWidget, findWidgetsDir } = widgetCompiler$1;
30946
30851
 
30947
- if (autoRegister) {
30948
- await this.loadWidgetComponents(widgetName, widgetPath);
30949
- }
30852
+ let WIDGETS_CACHE_DIR = null;
30853
+ let REGISTRY_CONFIG_FILE = null;
30950
30854
 
30951
- return config;
30952
- } catch (error) {
30953
- console.error(
30954
- `[WidgetRegistry] Error downloading widget ${widgetName}:`,
30955
- error,
30855
+ /**
30856
+ * Validate ZIP entries to prevent path traversal attacks.
30857
+ * Rejects entries containing '..' segments or absolute paths that would
30858
+ * write outside the target extraction directory.
30859
+ * @param {AdmZip} zip - AdmZip instance
30860
+ * @param {string} targetDir - Intended extraction directory
30861
+ * @throws {Error} If any entry would escape the target directory
30862
+ */
30863
+ function validateZipEntries(zip, targetDir) {
30864
+ const resolvedTarget = path.resolve(targetDir);
30865
+ for (const entry of zip.getEntries()) {
30866
+ const entryPath = entry.entryName;
30867
+ // Reject entries with '..' path segments
30868
+ if (
30869
+ entryPath.split("/").includes("..") ||
30870
+ entryPath.split("\\").includes("..")
30871
+ ) {
30872
+ throw new Error(
30873
+ `Malicious ZIP entry rejected (path traversal): ${entryPath}`,
30874
+ );
30875
+ }
30876
+ // Reject absolute paths
30877
+ if (path.isAbsolute(entryPath)) {
30878
+ throw new Error(
30879
+ `Malicious ZIP entry rejected (absolute path): ${entryPath}`,
30880
+ );
30881
+ }
30882
+ // Final check: resolved path must be within target directory
30883
+ const resolvedEntry = path.resolve(resolvedTarget, entryPath);
30884
+ if (
30885
+ !resolvedEntry.startsWith(resolvedTarget + path.sep) &&
30886
+ resolvedEntry !== resolvedTarget
30887
+ ) {
30888
+ throw new Error(
30889
+ `Malicious ZIP entry rejected (escapes target): ${entryPath}`,
30956
30890
  );
30957
- throw error;
30958
30891
  }
30959
30892
  }
30893
+ }
30960
30894
 
30961
- /**
30962
- * Load widget configuration from local path
30963
- * @param {string} widgetName - Name of the widget
30964
- * @param {string} widgetPath - Path to widget directory
30965
- * @returns {Promise<Object>} Widget configuration
30966
- */
30967
- async loadWidgetConfig(widgetName, widgetPath) {
30968
- try {
30969
- const dashJsonPath = path.join(widgetPath, "dash.json");
30970
- if (fs.existsSync(dashJsonPath)) {
30971
- const data = fs.readFileSync(dashJsonPath, "utf8");
30972
- return JSON.parse(data);
30973
- }
30974
-
30975
- const packageJsonPath = path.join(widgetPath, "package.json");
30976
- if (fs.existsSync(packageJsonPath)) {
30977
- const packageJson = JSON.parse(
30978
- fs.readFileSync(packageJsonPath, "utf8"),
30979
- );
30980
- return {
30981
- name: packageJson.name || widgetName,
30982
- version: packageJson.version,
30983
- description: packageJson.description,
30984
- author: packageJson.author,
30985
- repository: packageJson.repository,
30986
- };
30987
- }
30895
+ /**
30896
+ * Initialize registry with custom path or default userData path
30897
+ * @param {string} customPath - Optional custom path for storing widgets
30898
+ */
30899
+ function initializeRegistry(customPath = null) {
30900
+ if (customPath) {
30901
+ WIDGETS_CACHE_DIR = path.join(customPath, "widgets");
30902
+ } else {
30903
+ WIDGETS_CACHE_DIR = path.join(app.getPath("userData"), "widgets");
30904
+ }
30905
+ REGISTRY_CONFIG_FILE = path.join(WIDGETS_CACHE_DIR, "registry.json");
30906
+ console.log(`[WidgetRegistry] Using storage path: ${WIDGETS_CACHE_DIR}`);
30907
+ }
30988
30908
 
30989
- return {
30990
- name: widgetName,
30991
- version: "1.0.0",
30992
- };
30993
- } catch (error) {
30994
- console.error(
30995
- `[WidgetRegistry] Error loading config for ${widgetName}:`,
30996
- error,
30997
- );
30998
- return { name: widgetName };
30909
+ class WidgetRegistry {
30910
+ constructor(componentManager = null, customPath = null) {
30911
+ if (!WIDGETS_CACHE_DIR) {
30912
+ initializeRegistry(customPath);
30999
30913
  }
30914
+
30915
+ this.widgets = new Map();
30916
+ this.componentManager = componentManager;
30917
+ this.ensureCacheDir();
30918
+ this.loadRegistry();
31000
30919
  }
31001
30920
 
31002
30921
  /**
31003
- * Register a widget in the registry
31004
- * @param {string} widgetName - Name of the widget
31005
- * @param {Object} config - Widget configuration
31006
- * @param {string} widgetPath - Path to widget directory
31007
- * @param {boolean} autoRegister - Automatically register with ComponentManager
30922
+ * Static method to initialize registry with custom path
30923
+ * Call this early in your app startup (e.g., in main.js)
30924
+ * @param {string} customPath - Custom path for storing widgets/configs
31008
30925
  */
31009
- registerWidget(widgetName, config, widgetPath, autoRegister = true) {
31010
- const widgetEntry = {
31011
- name: widgetName,
31012
- path: widgetPath,
31013
- ...config,
31014
- registeredAt: new Date().toISOString(),
31015
- };
31016
-
31017
- this.widgets.set(widgetName, widgetEntry);
31018
- this.saveRegistry();
31019
- console.log(`[WidgetRegistry] Registered widget: ${widgetName}`);
30926
+ static initialize(customPath = null) {
30927
+ initializeRegistry(customPath);
31020
30928
  }
31021
30929
 
31022
30930
  /**
31023
- * Load all components for a widget and register them with ComponentManager
31024
- * @param {string} widgetName - Name of the widget
31025
- * @param {string} widgetPath - Path to widget directory
30931
+ * Set ComponentManager instance for automatic widget registration
30932
+ * @param {Object} manager - ComponentManager instance from @trops/dash-react
31026
30933
  */
31027
- async loadWidgetComponents(widgetName, widgetPath) {
31028
- try {
31029
- // Auto-compile widget source to CJS bundle if none exists
31030
- if (!findBundlePath(widgetPath)) {
31031
- try {
31032
- await compileWidget(widgetPath);
31033
- console.log(`[WidgetRegistry] Auto-compiled ${widgetName}`);
31034
- } catch (compileError) {
31035
- console.warn(
31036
- `[WidgetRegistry] Could not compile ${widgetName}:`,
31037
- compileError,
31038
- );
31039
- }
31040
- }
31041
-
31042
- if (this.componentManager) {
31043
- dynamicWidgetLoader.setComponentManager(this.componentManager);
31044
- }
31045
-
31046
- const components = dynamicWidgetLoader.discoverWidgets(widgetPath);
31047
- console.log(
31048
- `[WidgetRegistry] Found ${components.length} components in ${widgetName}`,
31049
- );
31050
-
31051
- const existingEntry = this.widgets.get(widgetName);
31052
- let registryUpdated = false;
31053
-
31054
- // Store component names as displayName on the registry entry
31055
- // so settings UI shows "WeatherWidget" instead of "weather-widget"
31056
- if (components.length > 0 && existingEntry) {
31057
- existingEntry.displayName = components.join(", ");
31058
- existingEntry.componentNames = components;
31059
- registryUpdated = true;
31060
- }
31061
-
31062
- for (const componentName of components) {
31063
- try {
31064
- const result = await dynamicWidgetLoader.loadWidget(
31065
- widgetName,
31066
- widgetPath,
31067
- componentName,
31068
- true,
31069
- );
31070
- console.log(`[WidgetRegistry] ✓ Loaded ${componentName}`);
30934
+ setComponentManager(manager) {
30935
+ this.componentManager = manager;
30936
+ }
31071
30937
 
31072
- // Enrich registry entry with .dash.js config fields
31073
- // (icon, providers, workspace, etc.) so the settings UI
31074
- // has full display data without needing ComponentManager.
31075
- if (result?.config && existingEntry) {
31076
- const cfg = result.config;
31077
- if (cfg.icon && !existingEntry.icon) existingEntry.icon = cfg.icon;
31078
- if (cfg.providers?.length && !existingEntry.providers?.length)
31079
- existingEntry.providers = cfg.providers;
31080
- if (cfg.workspace && !existingEntry.workspace)
31081
- existingEntry.workspace = cfg.workspace;
31082
- if (cfg.events?.length && !existingEntry.events?.length)
31083
- existingEntry.events = cfg.events;
31084
- if (
31085
- cfg.eventHandlers?.length &&
31086
- !existingEntry.eventHandlers?.length
31087
- )
31088
- existingEntry.eventHandlers = cfg.eventHandlers;
31089
- registryUpdated = true;
31090
- }
31091
- } catch (error) {
31092
- console.error(
31093
- `[WidgetRegistry] Error loading component ${componentName}:`,
31094
- error,
31095
- );
31096
- }
31097
- }
30938
+ /**
30939
+ * Ensure cache directory exists
30940
+ */
30941
+ ensureCacheDir() {
30942
+ if (!fs.existsSync(WIDGETS_CACHE_DIR)) {
30943
+ fs.mkdirSync(WIDGETS_CACHE_DIR, { recursive: true });
30944
+ }
30945
+ }
31098
30946
 
31099
- if (registryUpdated && existingEntry) {
31100
- this.widgets.set(widgetName, existingEntry);
31101
- this.saveRegistry();
30947
+ /**
30948
+ * Load registry from disk
30949
+ */
30950
+ loadRegistry() {
30951
+ try {
30952
+ if (fs.existsSync(REGISTRY_CONFIG_FILE)) {
30953
+ const data = fs.readFileSync(REGISTRY_CONFIG_FILE, "utf8");
30954
+ const registryData = JSON.parse(data);
30955
+ this.widgets = new Map(registryData.widgets || []);
30956
+ console.log(
30957
+ `[WidgetRegistry] Loaded ${this.widgets.size} widgets from cache`,
30958
+ );
31102
30959
  }
31103
30960
  } catch (error) {
31104
- console.error("[WidgetRegistry] Error loading widget components:", error);
30961
+ console.error("[WidgetRegistry] Error loading registry:", error);
31105
30962
  }
31106
30963
  }
31107
30964
 
31108
30965
  /**
31109
- * Get all registered widgets
31110
- * @returns {Array} List of widget configurations
30966
+ * Save registry to disk
31111
30967
  */
31112
- getWidgets() {
31113
- return Array.from(this.widgets.values());
30968
+ saveRegistry() {
30969
+ try {
30970
+ const registryData = {
30971
+ lastUpdated: new Date().toISOString(),
30972
+ widgets: Array.from(this.widgets.entries()),
30973
+ };
30974
+ fs.writeFileSync(
30975
+ REGISTRY_CONFIG_FILE,
30976
+ JSON.stringify(registryData, null, 2),
30977
+ );
30978
+ } catch (error) {
30979
+ console.error("[WidgetRegistry] Error saving registry:", error);
30980
+ }
31114
30981
  }
31115
30982
 
31116
30983
  /**
31117
- * Get widget by name
31118
- * @param {string} widgetName - Name of the widget
31119
- * @returns {Object|null} Widget configuration or null
30984
+ * Resolve download URL from partial template or full URL
30985
+ * Supports placeholders: {version}, {name}
30986
+ *
30987
+ * Examples:
30988
+ * - Full URL: "https://github.com/user/widget/releases/download/v1.0.0/widget.zip"
30989
+ * - Template: "https://github.com/user/weather-widget/releases/download/v{version}/weather-widget.zip"
30990
+ * - Partial: "https://github.com/user/weather-widget/releases/download/" (auto-generates v{version}/{name}.zip)
30991
+ *
30992
+ * @param {string} urlTemplate - URL template or partial URL
30993
+ * @param {string} version - Widget version (e.g., "1.0.0")
30994
+ * @param {string} name - Widget name (e.g., "weather-widget")
30995
+ * @returns {string} Resolved download URL
31120
30996
  */
31121
- getWidget(widgetName) {
31122
- return this.widgets.get(widgetName) || null;
30997
+ resolveDownloadUrl(urlTemplate, version, name) {
30998
+ if (!urlTemplate) return null;
30999
+
31000
+ if (urlTemplate.endsWith("/")) {
31001
+ return `${urlTemplate}v${version}/${name}.zip`;
31002
+ }
31003
+
31004
+ let url = urlTemplate;
31005
+ url = url.replace("{version}", version);
31006
+ url = url.replace("{name}", name);
31007
+ return url;
31123
31008
  }
31124
31009
 
31125
31010
  /**
31126
- * Uninstall a widget
31127
- * @param {string} widgetName - Name of the widget to remove
31011
+ * Determine if the input points to a local path (file:// or filesystem path)
31012
+ * @param {string} input - URL or path
31013
+ * @returns {boolean}
31128
31014
  */
31129
- uninstallWidget(widgetName) {
31130
- const widget = this.widgets.get(widgetName);
31131
- if (!widget) {
31132
- console.warn(`[WidgetRegistry] Widget not found: ${widgetName}`);
31015
+ isLocalSource(input) {
31016
+ if (!input) return false;
31017
+ if (input.startsWith("file://")) return true;
31018
+ if (input.startsWith("http://") || input.startsWith("https://"))
31133
31019
  return false;
31020
+ const resolvedPath = this.resolveLocalPath(input);
31021
+ return fs.existsSync(resolvedPath);
31022
+ }
31023
+
31024
+ /**
31025
+ * Normalize a local path (supports file:// and ~)
31026
+ * @param {string} input - Local path or file:// URL
31027
+ * @returns {string}
31028
+ */
31029
+ resolveLocalPath(input) {
31030
+ if (input.startsWith("file://")) {
31031
+ return fileURLToPath(input);
31032
+ }
31033
+ if (input.startsWith("~")) {
31034
+ return path.join(os.homedir(), input.slice(1));
31134
31035
  }
31036
+ return path.resolve(input);
31037
+ }
31135
31038
 
31039
+ /**
31040
+ * Install a widget from a local ZIP file or folder path
31041
+ * @param {string} widgetName - Name of the widget
31042
+ * @param {string} localPath - Path to ZIP file or widget folder
31043
+ * @param {boolean} autoRegister - Automatically register with ComponentManager
31044
+ * @param {string} dashConfigPath - Optional: path to dash.json metadata file
31045
+ * @returns {Promise<Object>} Widget configuration
31046
+ */
31047
+ async installFromLocalPath(
31048
+ widgetName,
31049
+ localPath,
31050
+ autoRegister = true,
31051
+ dashConfigPath = null,
31052
+ ) {
31136
31053
  try {
31137
- if (fs.existsSync(widget.path)) {
31138
- fs.rmSync(widget.path, { recursive: true });
31054
+ const resolvedPath = this.resolveLocalPath(localPath);
31055
+
31056
+ if (!fs.existsSync(resolvedPath)) {
31057
+ throw new Error(`Local path not found: ${resolvedPath}`);
31139
31058
  }
31140
- this.widgets.delete(widgetName);
31141
- this.saveRegistry();
31142
- console.log(`[WidgetRegistry] Uninstalled widget: ${widgetName}`);
31143
- return true;
31059
+
31060
+ const widgetPath = path.join(WIDGETS_CACHE_DIR, widgetName);
31061
+
31062
+ if (fs.existsSync(widgetPath)) {
31063
+ fs.rmSync(widgetPath, { recursive: true });
31064
+ }
31065
+
31066
+ const isDirectory = fs.statSync(resolvedPath).isDirectory();
31067
+ if (isDirectory) {
31068
+ fs.cpSync(resolvedPath, widgetPath, { recursive: true });
31069
+ } else if (resolvedPath.endsWith(".zip")) {
31070
+ const zip = new AdmZip(resolvedPath);
31071
+ validateZipEntries(zip, widgetPath);
31072
+ zip.extractAllTo(widgetPath, true);
31073
+ } else {
31074
+ throw new Error(`Unsupported local source type: ${resolvedPath}`);
31075
+ }
31076
+
31077
+ let config = await this.loadWidgetConfig(widgetName, widgetPath);
31078
+
31079
+ if (dashConfigPath) {
31080
+ const configPath = this.resolveLocalPath(dashConfigPath);
31081
+ if (fs.existsSync(configPath)) {
31082
+ const dashConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
31083
+ config = { ...config, ...dashConfig };
31084
+ }
31085
+ }
31086
+
31087
+ this.registerWidget(widgetName, config, widgetPath, false);
31088
+
31089
+ if (autoRegister) {
31090
+ await this.loadWidgetComponents(widgetName, widgetPath);
31091
+ }
31092
+
31093
+ return config;
31144
31094
  } catch (error) {
31145
31095
  console.error(
31146
- `[WidgetRegistry] Error uninstalling ${widgetName}:`,
31096
+ `[WidgetRegistry] Error installing local widget ${widgetName}:`,
31147
31097
  error,
31148
31098
  );
31149
- return false;
31099
+ throw error;
31150
31100
  }
31151
31101
  }
31152
31102
 
31153
31103
  /**
31154
- * Helper: Fetch JSON from URL
31104
+ * Check if a directory looks like a valid widget folder.
31105
+ * A directory is a widget if it has:
31106
+ * - package.json or dash.json at its root, OR
31107
+ * - A widgets/ subdirectory containing at least one .dash.js file
31108
+ * @param {string} dirPath - Path to the directory
31109
+ * @returns {boolean}
31155
31110
  */
31156
- async fetchJSON(url) {
31157
- const response = await fetch(url);
31158
- if (!response.ok)
31159
- throw new Error(`Failed to fetch: ${response.statusText}`);
31160
- return response.json();
31161
- }
31111
+ isWidgetFolder(dirPath) {
31112
+ if (fs.existsSync(path.join(dirPath, "package.json"))) return true;
31113
+ if (fs.existsSync(path.join(dirPath, "dash.json"))) return true;
31162
31114
 
31163
- /**
31164
- * Get cache directory path
31165
- */
31166
- getCachePath() {
31167
- return WIDGETS_CACHE_DIR;
31115
+ const widgetsDir = path.join(dirPath, "widgets");
31116
+ if (fs.existsSync(widgetsDir) && fs.statSync(widgetsDir).isDirectory()) {
31117
+ const files = fs.readdirSync(widgetsDir);
31118
+ if (files.some((f) => f.endsWith(".dash.js"))) return true;
31119
+ }
31120
+
31121
+ return false;
31168
31122
  }
31169
31123
 
31170
31124
  /**
31171
- * Get the storage directory (parent of widgets directory)
31172
- * @returns {string} Full path to storage directory
31125
+ * Register all widgets found in a local folder.
31126
+ *
31127
+ * Smart detection:
31128
+ * 1. If the selected folder itself is a widget, install it directly.
31129
+ * 2. Otherwise iterate subdirectories, skipping non-widget dirs.
31130
+ *
31131
+ * @param {string} folderPath - Path containing widget folders (or a single widget folder)
31132
+ * @param {boolean} autoRegister - Automatically register with ComponentManager
31133
+ * @returns {Promise<Array>} Registered widgets (with optional `mode` and `skipped` metadata)
31173
31134
  */
31174
- getStoragePath() {
31175
- return path.dirname(WIDGETS_CACHE_DIR);
31176
- }
31177
- }
31135
+ async registerWidgetsFromFolder(folderPath, autoRegister = true) {
31136
+ const SKIP_DIRS = new Set(["node_modules", "dist", "__MACOSX", ".git"]);
31178
31137
 
31179
- // Lazy initialization to avoid accessing app.getPath before app is ready
31180
- let widgetRegistry = null;
31138
+ try {
31139
+ const resolvedPath = this.resolveLocalPath(folderPath);
31140
+ if (!fs.existsSync(resolvedPath)) {
31141
+ throw new Error(`Folder not found: ${resolvedPath}`);
31142
+ }
31143
+ if (!fs.statSync(resolvedPath).isDirectory()) {
31144
+ throw new Error(`Path is not a directory: ${resolvedPath}`);
31145
+ }
31181
31146
 
31182
- function getWidgetRegistry() {
31183
- if (!widgetRegistry) {
31184
- widgetRegistry = new WidgetRegistry();
31185
- }
31186
- return widgetRegistry;
31187
- }
31147
+ // 1. Check if the selected folder itself is a widget
31148
+ if (this.isWidgetFolder(resolvedPath)) {
31149
+ const widgetName = path.basename(resolvedPath);
31150
+ const config = await this.installFromLocalPath(
31151
+ widgetName,
31152
+ resolvedPath,
31153
+ autoRegister,
31154
+ );
31155
+ return [
31156
+ {
31157
+ name: widgetName,
31158
+ path: resolvedPath,
31159
+ ...config,
31160
+ mode: "single",
31161
+ },
31162
+ ];
31163
+ }
31188
31164
 
31189
- /**
31190
- * Look for a CJS bundle file in a widget directory.
31191
- * Checks multiple candidate paths in priority order because
31192
- * packageZip.js may extract dist/ contents to the widget root.
31193
- *
31194
- * @param {string} widgetPath - Path to the widget directory
31195
- * @returns {string|null} Absolute path to the bundle, or null if not found
31196
- */
31197
- function findBundlePath(widgetPath) {
31198
- const candidates = [
31199
- path.join(widgetPath, "dist", "index.cjs.js"),
31200
- path.join(widgetPath, "index.cjs.js"),
31201
- path.join(widgetPath, "dist", "index.js"),
31202
- path.join(widgetPath, "index.js"),
31203
- ];
31165
+ // 2. Iterate subdirectories with filtering
31166
+ const entries = fs.readdirSync(resolvedPath, {
31167
+ withFileTypes: true,
31168
+ });
31169
+ const results = [];
31170
+ let skipped = 0;
31204
31171
 
31205
- for (const candidate of candidates) {
31206
- if (fs.existsSync(candidate)) {
31207
- // Skip ESM files — the eval pipeline requires CJS
31208
- if (candidate.endsWith(".js") && !candidate.endsWith(".cjs.js")) {
31209
- try {
31210
- const head = fs.readFileSync(candidate, "utf8").slice(0, 256);
31211
- if (/^\s*(import\s|export\s)/m.test(head)) {
31212
- console.log(`[WidgetRegistry] Skipping ESM bundle: ${candidate}`);
31213
- continue;
31214
- }
31215
- } catch (_) {
31216
- // Non-fatal — allow fallthrough
31172
+ for (const entry of entries) {
31173
+ if (!entry.isDirectory()) continue;
31174
+
31175
+ // Skip hidden dirs and known non-widget dirs
31176
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
31177
+ skipped++;
31178
+ continue;
31217
31179
  }
31218
- }
31219
- return candidate;
31220
- }
31221
- }
31222
31180
 
31223
- return null;
31224
- }
31181
+ const widgetPath = path.join(resolvedPath, entry.name);
31225
31182
 
31226
- /**
31227
- * Setup IPC handlers for widget management (use in main.js)
31228
- */
31229
- function setupWidgetRegistryHandlers() {
31230
- ipcMain.handle("widget:list", () => getWidgetRegistry().getWidgets());
31183
+ if (!this.isWidgetFolder(widgetPath)) {
31184
+ skipped++;
31185
+ continue;
31186
+ }
31231
31187
 
31232
- ipcMain.handle("widget:get", (event, widgetName) => {
31233
- return getWidgetRegistry().getWidget(widgetName);
31234
- });
31188
+ const config = await this.loadWidgetConfig(entry.name, widgetPath);
31189
+ this.registerWidget(entry.name, config, widgetPath, false);
31235
31190
 
31236
- ipcMain.handle(
31237
- "widget:install",
31238
- async (event, widgetName, downloadUrl, dashConfigUrl) => {
31239
- const config = await getWidgetRegistry().downloadWidget(
31240
- widgetName,
31241
- downloadUrl,
31242
- dashConfigUrl,
31243
- );
31191
+ if (autoRegister) {
31192
+ await this.loadWidgetComponents(entry.name, widgetPath);
31193
+ }
31244
31194
 
31245
- BrowserWindow.getAllWindows().forEach((win) => {
31246
- win.webContents.send("widget:installed", {
31247
- widgetName,
31248
- config,
31195
+ results.push({
31196
+ name: entry.name,
31197
+ path: widgetPath,
31198
+ ...config,
31249
31199
  });
31250
- });
31251
-
31252
- return config;
31253
- },
31254
- );
31200
+ }
31255
31201
 
31256
- ipcMain.handle(
31257
- "widget:install-local",
31258
- async (event, widgetName, localPath, dashConfigPath) => {
31259
- const config = await getWidgetRegistry().installFromLocalPath(
31260
- widgetName,
31261
- localPath,
31262
- true,
31263
- dashConfigPath,
31202
+ // Attach skipped count as metadata on the array
31203
+ results.skipped = skipped;
31204
+ return results;
31205
+ } catch (error) {
31206
+ console.error(
31207
+ "[WidgetRegistry] Error registering widgets from folder:",
31208
+ error,
31264
31209
  );
31210
+ throw error;
31211
+ }
31212
+ }
31265
31213
 
31266
- BrowserWindow.getAllWindows().forEach((win) => {
31267
- win.webContents.send("widget:installed", {
31214
+ /**
31215
+ * Download widget from URL (ZIP file)
31216
+ * @param {string} widgetName - Name of the widget
31217
+ * @param {string} downloadUrl - URL to download ZIP file from (supports templates and partial URLs)
31218
+ * @param {string} dashConfigUrl - Optional: URL to dash.json metadata file
31219
+ * @param {boolean} autoRegister - Automatically register with ComponentManager
31220
+ * @returns {Promise<Object>} Widget configuration
31221
+ */
31222
+ async downloadWidget(
31223
+ widgetName,
31224
+ downloadUrl,
31225
+ dashConfigUrl = null,
31226
+ autoRegister = true,
31227
+ ) {
31228
+ try {
31229
+ if (this.isLocalSource(downloadUrl)) {
31230
+ return this.installFromLocalPath(
31268
31231
  widgetName,
31269
- config,
31270
- });
31271
- });
31232
+ downloadUrl,
31233
+ autoRegister,
31234
+ dashConfigUrl,
31235
+ );
31236
+ }
31237
+ // Enforce HTTPS to prevent MITM attacks on widget downloads
31238
+ const parsedUrl = new URL(downloadUrl);
31239
+ if (parsedUrl.protocol !== "https:") {
31240
+ throw new Error(
31241
+ `Widget downloads must use HTTPS. Refusing to fetch: ${downloadUrl}`,
31242
+ );
31243
+ }
31272
31244
 
31273
- return config;
31274
- },
31275
- );
31245
+ console.log(
31246
+ `[WidgetRegistry] Downloading widget: ${widgetName} from ${downloadUrl}`,
31247
+ );
31276
31248
 
31277
- ipcMain.handle("widget:load-folder", async (event, folderPath) => {
31278
- const results = await getWidgetRegistry().registerWidgetsFromFolder(
31279
- folderPath,
31280
- true,
31281
- );
31249
+ const response = await fetch(downloadUrl);
31250
+ if (!response.ok)
31251
+ throw new Error(`Failed to fetch: ${response.statusText}`);
31282
31252
 
31283
- BrowserWindow.getAllWindows().forEach((win) => {
31284
- win.webContents.send("widgets:loaded", {
31285
- count: results.length,
31286
- widgets: results,
31287
- });
31288
- });
31253
+ const buffer = await response.arrayBuffer();
31254
+ const zip = new AdmZip(Buffer.from(buffer));
31289
31255
 
31290
- return results;
31291
- });
31256
+ const widgetPath = path.join(WIDGETS_CACHE_DIR, widgetName);
31292
31257
 
31293
- ipcMain.handle("widget:uninstall", (event, widgetName) => {
31294
- const schedulerController = schedulerController_1;
31295
- schedulerController.cleanupWidget(widgetName);
31296
- return getWidgetRegistry().uninstallWidget(widgetName);
31297
- });
31258
+ if (fs.existsSync(widgetPath)) {
31259
+ fs.rmSync(widgetPath, { recursive: true });
31260
+ }
31298
31261
 
31299
- ipcMain.handle("widget:cache-path", () => getWidgetRegistry().getCachePath());
31262
+ validateZipEntries(zip, widgetPath);
31263
+ zip.extractAllTo(widgetPath, true);
31264
+ console.log(`[WidgetRegistry] Extracted widget to: ${widgetPath}`);
31300
31265
 
31301
- ipcMain.handle("widget:storage-path", () =>
31302
- getWidgetRegistry().getStoragePath(),
31303
- );
31266
+ let config = await this.loadWidgetConfig(widgetName, widgetPath);
31304
31267
 
31305
- ipcMain.handle("widget:get-component-configs", async () => {
31306
- try {
31307
- const registry = getWidgetRegistry();
31308
- const installedWidgets = registry.getWidgets();
31309
- const configs = [];
31268
+ if (dashConfigUrl) {
31269
+ const dashConfig = await this.fetchJSON(dashConfigUrl);
31270
+ config = { ...config, ...dashConfig };
31271
+ }
31310
31272
 
31311
- for (const widget of installedWidgets) {
31312
- const widgetPath = widget.path;
31313
- if (!widgetPath || !fs.existsSync(widgetPath)) continue;
31273
+ this.registerWidget(widgetName, config, widgetPath, false);
31314
31274
 
31315
- const componentNames = dynamicWidgetLoader.discoverWidgets(widgetPath);
31316
- const widgetsDir = findWidgetsDir(widgetPath);
31317
- for (const componentName of componentNames) {
31318
- try {
31319
- const configPath = path.join(
31320
- widgetsDir || path.join(widgetPath, "widgets"),
31321
- `${componentName}.dash.js`,
31322
- );
31323
- const config = await dynamicWidgetLoader.loadConfigFile(configPath);
31324
- configs.push({
31325
- componentName,
31326
- widgetPackage: widget.name,
31327
- // Include scoped id if present in the config
31328
- id: config.id || null,
31329
- config,
31330
- });
31331
- } catch (err) {
31332
- console.error(
31333
- `[WidgetRegistry] Error loading config for ${componentName}:`,
31334
- err,
31335
- );
31336
- }
31337
- }
31275
+ if (autoRegister) {
31276
+ await this.loadWidgetComponents(widgetName, widgetPath);
31338
31277
  }
31339
31278
 
31340
- return configs;
31279
+ return config;
31341
31280
  } catch (error) {
31342
- console.error("[WidgetRegistry] Error getting component configs:", error);
31343
- return [];
31281
+ console.error(
31282
+ `[WidgetRegistry] Error downloading widget ${widgetName}:`,
31283
+ error,
31284
+ );
31285
+ throw error;
31344
31286
  }
31345
- });
31287
+ }
31346
31288
 
31347
- ipcMain.handle("widget:read-bundle", async (event, widgetName) => {
31289
+ /**
31290
+ * Load widget configuration from local path
31291
+ * @param {string} widgetName - Name of the widget
31292
+ * @param {string} widgetPath - Path to widget directory
31293
+ * @returns {Promise<Object>} Widget configuration
31294
+ */
31295
+ async loadWidgetConfig(widgetName, widgetPath) {
31348
31296
  try {
31349
- const registry = getWidgetRegistry();
31350
- const widget = registry.getWidget(widgetName);
31351
- if (!widget || !widget.path) {
31297
+ const dashJsonPath = path.join(widgetPath, "dash.json");
31298
+ if (fs.existsSync(dashJsonPath)) {
31299
+ const data = fs.readFileSync(dashJsonPath, "utf8");
31300
+ return JSON.parse(data);
31301
+ }
31302
+
31303
+ const packageJsonPath = path.join(widgetPath, "package.json");
31304
+ if (fs.existsSync(packageJsonPath)) {
31305
+ const packageJson = JSON.parse(
31306
+ fs.readFileSync(packageJsonPath, "utf8"),
31307
+ );
31352
31308
  return {
31353
- success: false,
31354
- error: `Widget not found: ${widgetName}`,
31309
+ name: packageJson.name || widgetName,
31310
+ version: packageJson.version,
31311
+ description: packageJson.description,
31312
+ author: packageJson.author,
31313
+ repository: packageJson.repository,
31355
31314
  };
31356
31315
  }
31357
31316
 
31358
- let bundlePath = findBundlePath(widget.path);
31317
+ return {
31318
+ name: widgetName,
31319
+ version: "1.0.0",
31320
+ };
31321
+ } catch (error) {
31322
+ console.error(
31323
+ `[WidgetRegistry] Error loading config for ${widgetName}:`,
31324
+ error,
31325
+ );
31326
+ return { name: widgetName };
31327
+ }
31328
+ }
31329
+
31330
+ /**
31331
+ * Register a widget in the registry
31332
+ * @param {string} widgetName - Name of the widget
31333
+ * @param {Object} config - Widget configuration
31334
+ * @param {string} widgetPath - Path to widget directory
31335
+ * @param {boolean} autoRegister - Automatically register with ComponentManager
31336
+ */
31337
+ registerWidget(widgetName, config, widgetPath, autoRegister = true) {
31338
+ const widgetEntry = {
31339
+ name: widgetName,
31340
+ path: widgetPath,
31341
+ ...config,
31342
+ registeredAt: new Date().toISOString(),
31343
+ };
31344
+
31345
+ this.widgets.set(widgetName, widgetEntry);
31346
+ this.saveRegistry();
31347
+ console.log(`[WidgetRegistry] Registered widget: ${widgetName}`);
31348
+ }
31359
31349
 
31360
- // Auto-compile if no bundle exists (same as read-all-bundles)
31361
- if (!bundlePath) {
31350
+ /**
31351
+ * Load all components for a widget and register them with ComponentManager
31352
+ * @param {string} widgetName - Name of the widget
31353
+ * @param {string} widgetPath - Path to widget directory
31354
+ */
31355
+ async loadWidgetComponents(widgetName, widgetPath) {
31356
+ try {
31357
+ // Auto-compile widget source to CJS bundle if none exists
31358
+ if (!findBundlePath(widgetPath)) {
31362
31359
  try {
31363
- const compiled = await compileWidget(widget.path);
31364
- if (compiled) {
31365
- bundlePath = compiled;
31366
- }
31360
+ await compileWidget(widgetPath);
31361
+ console.log(`[WidgetRegistry] Auto-compiled ${widgetName}`);
31367
31362
  } catch (compileError) {
31368
31363
  console.warn(
31369
31364
  `[WidgetRegistry] Could not compile ${widgetName}:`,
@@ -31372,430 +31367,435 @@ var schedulerController_1 = schedulerController$2;
31372
31367
  }
31373
31368
  }
31374
31369
 
31375
- if (!bundlePath) {
31376
- return {
31377
- success: false,
31378
- error: `No bundle found in: ${widget.path}`,
31379
- };
31370
+ if (this.componentManager) {
31371
+ dynamicWidgetLoader.setComponentManager(this.componentManager);
31380
31372
  }
31381
31373
 
31382
- const source = fs.readFileSync(bundlePath, "utf8");
31383
- return { success: true, source, widgetName };
31384
- } catch (error) {
31385
- console.error(
31386
- `[WidgetRegistry] Error reading bundle for ${widgetName}:`,
31387
- error,
31374
+ const components = dynamicWidgetLoader.discoverWidgets(widgetPath);
31375
+ console.log(
31376
+ `[WidgetRegistry] Found ${components.length} components in ${widgetName}`,
31388
31377
  );
31389
- return { success: false, error: error.message };
31390
- }
31391
- });
31392
-
31393
- ipcMain.handle("widget:read-all-bundles", async () => {
31394
- try {
31395
- const registry = getWidgetRegistry();
31396
- const installedWidgets = registry.getWidgets();
31397
- const results = [];
31398
-
31399
- for (const widget of installedWidgets) {
31400
- const widgetPath = widget.path;
31401
- if (!widgetPath || !fs.existsSync(widgetPath)) continue;
31402
31378
 
31403
- let bundlePath = findBundlePath(widgetPath);
31379
+ const existingEntry = this.widgets.get(widgetName);
31380
+ let registryUpdated = false;
31404
31381
 
31405
- // Auto-compile if no bundle exists
31406
- if (!bundlePath) {
31407
- try {
31408
- const compiled = await compileWidget(widgetPath);
31409
- if (compiled) {
31410
- bundlePath = compiled;
31411
- }
31412
- } catch (compileError) {
31413
- console.warn(
31414
- `[WidgetRegistry] Could not compile ${widget.name}:`,
31415
- compileError,
31416
- );
31417
- }
31418
- }
31382
+ // Store component names as displayName on the registry entry
31383
+ // so settings UI shows "WeatherWidget" instead of "weather-widget"
31384
+ if (components.length > 0 && existingEntry) {
31385
+ existingEntry.displayName = components.join(", ");
31386
+ existingEntry.componentNames = components;
31387
+ registryUpdated = true;
31388
+ }
31419
31389
 
31420
- if (!bundlePath) {
31421
- console.log(
31422
- `[WidgetRegistry] No CJS bundle for ${widget.name}, skipping (will use config fallback)`,
31390
+ for (const componentName of components) {
31391
+ try {
31392
+ const result = await dynamicWidgetLoader.loadWidget(
31393
+ widgetName,
31394
+ widgetPath,
31395
+ componentName,
31396
+ true,
31423
31397
  );
31424
- continue;
31425
- }
31398
+ console.log(`[WidgetRegistry] ✓ Loaded ${componentName}`);
31426
31399
 
31427
- try {
31428
- const source = fs.readFileSync(bundlePath, "utf8");
31429
- results.push({
31430
- widgetName: widget.name,
31431
- source,
31432
- });
31433
- } catch (readError) {
31400
+ // Enrich registry entry with .dash.js config fields
31401
+ // (icon, providers, workspace, etc.) so the settings UI
31402
+ // has full display data without needing ComponentManager.
31403
+ if (result?.config && existingEntry) {
31404
+ const cfg = result.config;
31405
+ if (cfg.icon && !existingEntry.icon) existingEntry.icon = cfg.icon;
31406
+ if (cfg.providers?.length && !existingEntry.providers?.length)
31407
+ existingEntry.providers = cfg.providers;
31408
+ if (cfg.workspace && !existingEntry.workspace)
31409
+ existingEntry.workspace = cfg.workspace;
31410
+ if (cfg.events?.length && !existingEntry.events?.length)
31411
+ existingEntry.events = cfg.events;
31412
+ if (
31413
+ cfg.eventHandlers?.length &&
31414
+ !existingEntry.eventHandlers?.length
31415
+ )
31416
+ existingEntry.eventHandlers = cfg.eventHandlers;
31417
+ registryUpdated = true;
31418
+ }
31419
+ } catch (error) {
31434
31420
  console.error(
31435
- `[WidgetRegistry] Error reading bundle for ${widget.name}:`,
31436
- readError,
31421
+ `[WidgetRegistry] Error loading component ${componentName}:`,
31422
+ error,
31437
31423
  );
31438
31424
  }
31439
31425
  }
31440
31426
 
31441
- return results;
31427
+ if (registryUpdated && existingEntry) {
31428
+ this.widgets.set(widgetName, existingEntry);
31429
+ this.saveRegistry();
31430
+ }
31442
31431
  } catch (error) {
31443
- console.error("[WidgetRegistry] Error reading all bundles:", error);
31444
- return [];
31432
+ console.error("[WidgetRegistry] Error loading widget components:", error);
31445
31433
  }
31446
- });
31434
+ }
31447
31435
 
31448
- ipcMain.handle("widget:set-storage-path", (event, customPath) => {
31449
- try {
31450
- WidgetRegistry.initialize(customPath);
31451
- console.log(`[WidgetRegistry] Storage path changed to: ${customPath}`);
31452
- return { success: true, path: customPath };
31453
- } catch (error) {
31454
- console.error("[WidgetRegistry] Error setting storage path:", error);
31455
- return { success: false, error: error.message };
31456
- }
31457
- });
31458
- }
31436
+ /**
31437
+ * Get all registered widgets
31438
+ * @returns {Array} List of widget configurations
31439
+ */
31440
+ getWidgets() {
31441
+ return Array.from(this.widgets.values());
31442
+ }
31459
31443
 
31460
- module.exports = WidgetRegistry;
31461
- module.exports.getWidgetRegistry = getWidgetRegistry;
31462
- // For backward compatibility, provide widgetRegistry as a getter
31463
- Object.defineProperty(module.exports, "widgetRegistry", {
31464
- get: getWidgetRegistry,
31465
- });
31466
- module.exports.setupWidgetRegistryHandlers = setupWidgetRegistryHandlers;
31467
- module.exports.validateZipEntries = validateZipEntries;
31468
- } (widgetRegistry$1));
31444
+ /**
31445
+ * Get widget by name
31446
+ * @param {string} widgetName - Name of the widget
31447
+ * @returns {Object|null} Widget configuration or null
31448
+ */
31449
+ getWidget(widgetName) {
31450
+ return this.widgets.get(widgetName) || null;
31451
+ }
31469
31452
 
31470
- var widgetRegistryExports = widgetRegistry$1.exports;
31453
+ /**
31454
+ * Uninstall a widget
31455
+ * @param {string} widgetName - Name of the widget to remove
31456
+ */
31457
+ uninstallWidget(widgetName) {
31458
+ const widget = this.widgets.get(widgetName);
31459
+ if (!widget) {
31460
+ console.warn(`[WidgetRegistry] Widget not found: ${widgetName}`);
31461
+ return false;
31462
+ }
31471
31463
 
31472
- /**
31473
- * registryAuthController.js
31474
- *
31475
- * Manages authentication with the Dash registry service.
31476
- * Uses OAuth device code flow for desktop app authentication.
31477
- *
31478
- * Flow:
31479
- * 1. App calls initiateDeviceFlow() — gets device code + verification URL
31480
- * 2. User opens verification URL in browser, signs in, enters code
31481
- * 3. App polls pollForToken() until authorized
31482
- * 4. Token stored securely via electron-store (encrypted)
31483
- */
31464
+ try {
31465
+ if (fs.existsSync(widget.path)) {
31466
+ fs.rmSync(widget.path, { recursive: true });
31467
+ }
31468
+ this.widgets.delete(widgetName);
31469
+ this.saveRegistry();
31470
+ console.log(`[WidgetRegistry] Uninstalled widget: ${widgetName}`);
31471
+ return true;
31472
+ } catch (error) {
31473
+ console.error(
31474
+ `[WidgetRegistry] Error uninstalling ${widgetName}:`,
31475
+ error,
31476
+ );
31477
+ return false;
31478
+ }
31479
+ }
31484
31480
 
31485
- const REGISTRY_BASE_URL$1 =
31486
- process.env.DASH_REGISTRY_API_URL ||
31487
- "https://main.d919rwhuzp7rj.amplifyapp.com";
31481
+ /**
31482
+ * Helper: Fetch JSON from URL
31483
+ */
31484
+ async fetchJSON(url) {
31485
+ const response = await fetch(url);
31486
+ if (!response.ok)
31487
+ throw new Error(`Failed to fetch: ${response.statusText}`);
31488
+ return response.json();
31489
+ }
31488
31490
 
31489
- // Lazy-load electron-store to avoid issues when not installed
31490
- let store$2 = null;
31491
- function getStore$1() {
31492
- if (!store$2) {
31493
- const Store = require$$1$1;
31494
- store$2 = new Store({
31495
- name: "dash-registry-auth",
31496
- encryptionKey: "dash-registry-v1",
31497
- });
31498
- }
31499
- return store$2;
31500
- }
31491
+ /**
31492
+ * Get cache directory path
31493
+ */
31494
+ getCachePath() {
31495
+ return WIDGETS_CACHE_DIR;
31496
+ }
31501
31497
 
31502
- /**
31503
- * Initiate the OAuth device code flow.
31504
- * Returns the device code, user code, and verification URL.
31505
- *
31506
- * @returns {Promise<Object>} { deviceCode, userCode, verificationUrl, verificationUrlComplete, expiresIn, interval }
31507
- */
31508
- async function initiateDeviceFlow$1() {
31509
- const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/device`, {
31510
- method: "POST",
31511
- headers: { "Content-Type": "application/json" },
31512
- });
31498
+ /**
31499
+ * Get the storage directory (parent of widgets directory)
31500
+ * @returns {string} Full path to storage directory
31501
+ */
31502
+ getStoragePath() {
31503
+ return path.dirname(WIDGETS_CACHE_DIR);
31504
+ }
31505
+ }
31513
31506
 
31514
- if (!response.ok) {
31515
- throw new Error(`Device flow initiation failed: ${response.status}`);
31516
- }
31507
+ // Lazy initialization to avoid accessing app.getPath before app is ready
31508
+ let widgetRegistry = null;
31517
31509
 
31518
- const data = await response.json();
31510
+ function getWidgetRegistry() {
31511
+ if (!widgetRegistry) {
31512
+ widgetRegistry = new WidgetRegistry();
31513
+ }
31514
+ return widgetRegistry;
31515
+ }
31519
31516
 
31520
- return {
31521
- deviceCode: data.device_code,
31522
- userCode: data.user_code,
31523
- verificationUrl: data.verification_uri,
31524
- verificationUrlComplete: data.verification_uri_complete,
31525
- expiresIn: data.expires_in,
31526
- interval: data.interval,
31527
- };
31528
- }
31517
+ /**
31518
+ * Look for a CJS bundle file in a widget directory.
31519
+ * Checks multiple candidate paths in priority order because
31520
+ * packageZip.js may extract dist/ contents to the widget root.
31521
+ *
31522
+ * @param {string} widgetPath - Path to the widget directory
31523
+ * @returns {string|null} Absolute path to the bundle, or null if not found
31524
+ */
31525
+ function findBundlePath(widgetPath) {
31526
+ const candidates = [
31527
+ path.join(widgetPath, "dist", "index.cjs.js"),
31528
+ path.join(widgetPath, "index.cjs.js"),
31529
+ path.join(widgetPath, "dist", "index.js"),
31530
+ path.join(widgetPath, "index.js"),
31531
+ ];
31529
31532
 
31530
- /**
31531
- * Poll the registry for token after user completes browser auth.
31532
- *
31533
- * @param {string} deviceCode - The device code from initiateDeviceFlow()
31534
- * @returns {Promise<Object>} { status: 'pending' | 'authorized' | 'expired', token?, userId? }
31535
- */
31536
- async function pollForToken$1(deviceCode) {
31537
- const response = await fetch(
31538
- `${REGISTRY_BASE_URL$1}/api/auth/device?device_code=${encodeURIComponent(deviceCode)}`,
31539
- );
31533
+ for (const candidate of candidates) {
31534
+ if (fs.existsSync(candidate)) {
31535
+ // Skip ESM files — the eval pipeline requires CJS
31536
+ if (candidate.endsWith(".js") && !candidate.endsWith(".cjs.js")) {
31537
+ try {
31538
+ const head = fs.readFileSync(candidate, "utf8").slice(0, 256);
31539
+ if (/^\s*(import\s|export\s)/m.test(head)) {
31540
+ console.log(`[WidgetRegistry] Skipping ESM bundle: ${candidate}`);
31541
+ continue;
31542
+ }
31543
+ } catch (_) {
31544
+ // Non-fatal — allow fallthrough
31545
+ }
31546
+ }
31547
+ return candidate;
31548
+ }
31549
+ }
31540
31550
 
31541
- if (response.status === 428) {
31542
- return { status: "pending" };
31543
- }
31551
+ return null;
31552
+ }
31544
31553
 
31545
- if (response.status === 400) {
31546
- const data = await response.json();
31547
- if (data.error === "expired_token") {
31548
- return { status: "expired" };
31549
- }
31550
- return { status: "pending" };
31551
- }
31554
+ /**
31555
+ * Setup IPC handlers for widget management (use in main.js)
31556
+ */
31557
+ function setupWidgetRegistryHandlers() {
31558
+ ipcMain.handle("widget:list", () => getWidgetRegistry().getWidgets());
31552
31559
 
31553
- if (response.ok) {
31554
- const data = await response.json();
31560
+ ipcMain.handle("widget:get", (event, widgetName) => {
31561
+ return getWidgetRegistry().getWidget(widgetName);
31562
+ });
31555
31563
 
31556
- // Store the token securely
31557
- const s = getStore$1();
31558
- s.set("accessToken", data.access_token);
31559
- s.set("userId", data.user_id);
31560
- s.set("tokenType", data.token_type);
31561
- s.set("authenticatedAt", new Date().toISOString());
31564
+ ipcMain.handle(
31565
+ "widget:install",
31566
+ async (event, widgetName, downloadUrl, dashConfigUrl) => {
31567
+ const config = await getWidgetRegistry().downloadWidget(
31568
+ widgetName,
31569
+ downloadUrl,
31570
+ dashConfigUrl,
31571
+ );
31562
31572
 
31563
- return {
31564
- status: "authorized",
31565
- token: data.access_token,
31566
- userId: data.user_id,
31567
- };
31568
- }
31573
+ BrowserWindow.getAllWindows().forEach((win) => {
31574
+ win.webContents.send("widget:installed", {
31575
+ widgetName,
31576
+ config,
31577
+ });
31578
+ });
31569
31579
 
31570
- throw new Error(`Unexpected response: ${response.status}`);
31571
- }
31580
+ return config;
31581
+ },
31582
+ );
31572
31583
 
31573
- /**
31574
- * Get the stored auth token.
31575
- *
31576
- * @returns {Object|null} { token, userId, authenticatedAt } or null if not authenticated
31577
- */
31578
- function getStoredToken$2() {
31579
- try {
31580
- const s = getStore$1();
31581
- const token = s.get("accessToken");
31582
- if (!token) return null;
31584
+ ipcMain.handle(
31585
+ "widget:install-local",
31586
+ async (event, widgetName, localPath, dashConfigPath) => {
31587
+ const config = await getWidgetRegistry().installFromLocalPath(
31588
+ widgetName,
31589
+ localPath,
31590
+ true,
31591
+ dashConfigPath,
31592
+ );
31583
31593
 
31584
- return {
31585
- token,
31586
- userId: s.get("userId"),
31587
- authenticatedAt: s.get("authenticatedAt"),
31588
- };
31589
- } catch {
31590
- return null;
31591
- }
31592
- }
31594
+ BrowserWindow.getAllWindows().forEach((win) => {
31595
+ win.webContents.send("widget:installed", {
31596
+ widgetName,
31597
+ config,
31598
+ });
31599
+ });
31593
31600
 
31594
- /**
31595
- * Check if the user is authenticated with the registry.
31596
- *
31597
- * @returns {Object} { authenticated: boolean, userId?: string }
31598
- */
31599
- function getAuthStatus$1() {
31600
- const stored = getStoredToken$2();
31601
- if (!stored) {
31602
- return { authenticated: false };
31603
- }
31601
+ return config;
31602
+ },
31603
+ );
31604
31604
 
31605
- return {
31606
- authenticated: true,
31607
- userId: stored.userId,
31608
- authenticatedAt: stored.authenticatedAt,
31609
- };
31610
- }
31605
+ ipcMain.handle("widget:load-folder", async (event, folderPath) => {
31606
+ const results = await getWidgetRegistry().registerWidgetsFromFolder(
31607
+ folderPath,
31608
+ true,
31609
+ );
31611
31610
 
31612
- /**
31613
- * Get the user's registry profile.
31614
- *
31615
- * @returns {Promise<Object|null>} User profile or null
31616
- */
31617
- async function getRegistryProfile$2() {
31618
- const stored = getStoredToken$2();
31619
- if (!stored) return null;
31611
+ BrowserWindow.getAllWindows().forEach((win) => {
31612
+ win.webContents.send("widgets:loaded", {
31613
+ count: results.length,
31614
+ widgets: results,
31615
+ });
31616
+ });
31620
31617
 
31621
- try {
31622
- const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/me`, {
31623
- headers: {
31624
- Authorization: `Bearer ${stored.token}`,
31625
- },
31626
- });
31618
+ return results;
31619
+ });
31627
31620
 
31628
- if (response.status === 401) {
31629
- // Token expired or invalid — clear stored credentials
31630
- clearToken();
31631
- return null;
31632
- }
31633
- if (!response.ok) return null;
31621
+ ipcMain.handle("widget:uninstall", (event, widgetName) => {
31622
+ const schedulerController = schedulerController_1;
31623
+ schedulerController.cleanupWidget(widgetName);
31624
+ return getWidgetRegistry().uninstallWidget(widgetName);
31625
+ });
31634
31626
 
31635
- const data = await response.json();
31636
- return data.user || null;
31637
- } catch {
31638
- return null;
31639
- }
31640
- }
31627
+ ipcMain.handle("widget:cache-path", () => getWidgetRegistry().getCachePath());
31641
31628
 
31642
- /**
31643
- * Clear stored auth token (logout).
31644
- */
31645
- function clearToken() {
31646
- try {
31647
- const s = getStore$1();
31648
- s.clear();
31649
- console.log("[RegistryAuthController] Token cleared");
31650
- } catch (err) {
31651
- console.error("[RegistryAuthController] Error clearing token:", err);
31652
- }
31653
- }
31629
+ ipcMain.handle("widget:storage-path", () =>
31630
+ getWidgetRegistry().getStoragePath(),
31631
+ );
31654
31632
 
31655
- /**
31656
- * Update the authenticated user's registry profile.
31657
- *
31658
- * @param {Object} updates - Fields to update (e.g. { displayName })
31659
- * @returns {Promise<Object|null>} Updated user or null on 401
31660
- */
31661
- async function updateRegistryProfile$1(updates) {
31662
- const stored = getStoredToken$2();
31663
- if (!stored) return null;
31633
+ ipcMain.handle("widget:get-component-configs", async () => {
31634
+ try {
31635
+ const registry = getWidgetRegistry();
31636
+ const installedWidgets = registry.getWidgets();
31637
+ const configs = [];
31638
+
31639
+ for (const widget of installedWidgets) {
31640
+ const widgetPath = widget.path;
31641
+ if (!widgetPath || !fs.existsSync(widgetPath)) continue;
31642
+
31643
+ const componentNames = dynamicWidgetLoader.discoverWidgets(widgetPath);
31644
+ const widgetsDir = findWidgetsDir(widgetPath);
31645
+ for (const componentName of componentNames) {
31646
+ try {
31647
+ const configPath = path.join(
31648
+ widgetsDir || path.join(widgetPath, "widgets"),
31649
+ `${componentName}.dash.js`,
31650
+ );
31651
+ const config = await dynamicWidgetLoader.loadConfigFile(configPath);
31652
+ configs.push({
31653
+ componentName,
31654
+ widgetPackage: widget.name,
31655
+ // Include scoped id if present in the config
31656
+ id: config.id || null,
31657
+ config,
31658
+ });
31659
+ } catch (err) {
31660
+ console.error(
31661
+ `[WidgetRegistry] Error loading config for ${componentName}:`,
31662
+ err,
31663
+ );
31664
+ }
31665
+ }
31666
+ }
31664
31667
 
31665
- try {
31666
- const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/me`, {
31667
- method: "PATCH",
31668
- headers: {
31669
- Authorization: `Bearer ${stored.token}`,
31670
- "Content-Type": "application/json",
31671
- },
31672
- body: JSON.stringify(updates),
31673
- });
31668
+ return configs;
31669
+ } catch (error) {
31670
+ console.error("[WidgetRegistry] Error getting component configs:", error);
31671
+ return [];
31672
+ }
31673
+ });
31674
31674
 
31675
- if (response.status === 401) {
31676
- clearToken();
31677
- return null;
31678
- }
31679
- if (!response.ok) return null;
31675
+ ipcMain.handle("widget:read-bundle", async (event, widgetName) => {
31676
+ try {
31677
+ const registry = getWidgetRegistry();
31678
+ const widget = registry.getWidget(widgetName);
31679
+ if (!widget || !widget.path) {
31680
+ return {
31681
+ success: false,
31682
+ error: `Widget not found: ${widgetName}`,
31683
+ };
31684
+ }
31680
31685
 
31681
- const data = await response.json();
31682
- return data.user || null;
31683
- } catch {
31684
- return null;
31685
- }
31686
- }
31686
+ let bundlePath = findBundlePath(widget.path);
31687
31687
 
31688
- /**
31689
- * Get the authenticated user's published packages.
31690
- *
31691
- * @returns {Promise<Object|null>} { packages: [...] } or null
31692
- */
31693
- async function getRegistryPackages$1() {
31694
- const stored = getStoredToken$2();
31695
- if (!stored) return null;
31688
+ // Auto-compile if no bundle exists (same as read-all-bundles)
31689
+ if (!bundlePath) {
31690
+ try {
31691
+ const compiled = await compileWidget(widget.path);
31692
+ if (compiled) {
31693
+ bundlePath = compiled;
31694
+ }
31695
+ } catch (compileError) {
31696
+ console.warn(
31697
+ `[WidgetRegistry] Could not compile ${widgetName}:`,
31698
+ compileError,
31699
+ );
31700
+ }
31701
+ }
31696
31702
 
31697
- try {
31698
- const response = await fetch(`${REGISTRY_BASE_URL$1}/api/auth/me/packages`, {
31699
- headers: {
31700
- Authorization: `Bearer ${stored.token}`,
31701
- },
31702
- });
31703
+ if (!bundlePath) {
31704
+ return {
31705
+ success: false,
31706
+ error: `No bundle found in: ${widget.path}`,
31707
+ };
31708
+ }
31703
31709
 
31704
- if (response.status === 401) {
31705
- clearToken();
31706
- return null;
31707
- }
31708
- if (!response.ok) return null;
31710
+ const source = fs.readFileSync(bundlePath, "utf8");
31711
+ return { success: true, source, widgetName };
31712
+ } catch (error) {
31713
+ console.error(
31714
+ `[WidgetRegistry] Error reading bundle for ${widgetName}:`,
31715
+ error,
31716
+ );
31717
+ return { success: false, error: error.message };
31718
+ }
31719
+ });
31709
31720
 
31710
- return await response.json();
31711
- } catch {
31712
- return null;
31713
- }
31714
- }
31721
+ ipcMain.handle("widget:read-all-bundles", async () => {
31722
+ try {
31723
+ const registry = getWidgetRegistry();
31724
+ const installedWidgets = registry.getWidgets();
31725
+ const results = [];
31715
31726
 
31716
- /**
31717
- * Update a published package's metadata.
31718
- *
31719
- * @param {string} scope - Package scope (e.g. "@trops")
31720
- * @param {string} name - Package name
31721
- * @param {Object} updates - Fields to update (displayName, description, category, tags, visibility)
31722
- * @returns {Promise<Object|null>} Updated package or null
31723
- */
31724
- async function updateRegistryPackage$1(scope, name, updates) {
31725
- const stored = getStoredToken$2();
31726
- if (!stored) return null;
31727
+ for (const widget of installedWidgets) {
31728
+ const widgetPath = widget.path;
31729
+ if (!widgetPath || !fs.existsSync(widgetPath)) continue;
31727
31730
 
31728
- try {
31729
- const response = await fetch(
31730
- `${REGISTRY_BASE_URL$1}/api/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`,
31731
- {
31732
- method: "PATCH",
31733
- headers: {
31734
- Authorization: `Bearer ${stored.token}`,
31735
- "Content-Type": "application/json",
31736
- },
31737
- body: JSON.stringify(updates),
31738
- },
31739
- );
31731
+ let bundlePath = findBundlePath(widgetPath);
31740
31732
 
31741
- if (response.status === 401) {
31742
- clearToken();
31743
- return null;
31744
- }
31745
- if (!response.ok) return null;
31733
+ // Auto-compile if no bundle exists
31734
+ if (!bundlePath) {
31735
+ try {
31736
+ const compiled = await compileWidget(widgetPath);
31737
+ if (compiled) {
31738
+ bundlePath = compiled;
31739
+ }
31740
+ } catch (compileError) {
31741
+ console.warn(
31742
+ `[WidgetRegistry] Could not compile ${widget.name}:`,
31743
+ compileError,
31744
+ );
31745
+ }
31746
+ }
31746
31747
 
31747
- return await response.json();
31748
- } catch {
31749
- return null;
31750
- }
31751
- }
31748
+ if (!bundlePath) {
31749
+ console.log(
31750
+ `[WidgetRegistry] No CJS bundle for ${widget.name}, skipping (will use config fallback)`,
31751
+ );
31752
+ continue;
31753
+ }
31752
31754
 
31753
- /**
31754
- * Delete a published package from the registry.
31755
- *
31756
- * @param {string} scope - Package scope (e.g. "@trops")
31757
- * @param {string} name - Package name
31758
- * @returns {Promise<Object|null>} Response or null
31759
- */
31760
- async function deleteRegistryPackage(scope, name) {
31761
- const stored = getStoredToken$2();
31762
- if (!stored) return null;
31755
+ try {
31756
+ const source = fs.readFileSync(bundlePath, "utf8");
31757
+ results.push({
31758
+ widgetName: widget.name,
31759
+ source,
31760
+ });
31761
+ } catch (readError) {
31762
+ console.error(
31763
+ `[WidgetRegistry] Error reading bundle for ${widget.name}:`,
31764
+ readError,
31765
+ );
31766
+ }
31767
+ }
31763
31768
 
31764
- try {
31765
- const response = await fetch(
31766
- `${REGISTRY_BASE_URL$1}/api/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}`,
31767
- {
31768
- method: "DELETE",
31769
- headers: {
31770
- Authorization: `Bearer ${stored.token}`,
31771
- },
31772
- },
31773
- );
31769
+ return results;
31770
+ } catch (error) {
31771
+ console.error("[WidgetRegistry] Error reading all bundles:", error);
31772
+ return [];
31773
+ }
31774
+ });
31774
31775
 
31775
- if (response.status === 401) {
31776
- clearToken();
31777
- return null;
31778
- }
31779
- if (!response.ok) return null;
31776
+ ipcMain.handle("widget:set-storage-path", (event, customPath) => {
31777
+ try {
31778
+ WidgetRegistry.initialize(customPath);
31779
+ console.log(`[WidgetRegistry] Storage path changed to: ${customPath}`);
31780
+ return { success: true, path: customPath };
31781
+ } catch (error) {
31782
+ console.error("[WidgetRegistry] Error setting storage path:", error);
31783
+ return { success: false, error: error.message };
31784
+ }
31785
+ });
31786
+ }
31780
31787
 
31781
- return await response.json();
31782
- } catch {
31783
- return null;
31784
- }
31785
- }
31788
+ module.exports = WidgetRegistry;
31789
+ module.exports.getWidgetRegistry = getWidgetRegistry;
31790
+ // For backward compatibility, provide widgetRegistry as a getter
31791
+ Object.defineProperty(module.exports, "widgetRegistry", {
31792
+ get: getWidgetRegistry,
31793
+ });
31794
+ module.exports.setupWidgetRegistryHandlers = setupWidgetRegistryHandlers;
31795
+ module.exports.validateZipEntries = validateZipEntries;
31796
+ } (widgetRegistry$1));
31786
31797
 
31787
- var registryAuthController$1 = {
31788
- initiateDeviceFlow: initiateDeviceFlow$1,
31789
- pollForToken: pollForToken$1,
31790
- getStoredToken: getStoredToken$2,
31791
- getAuthStatus: getAuthStatus$1,
31792
- getRegistryProfile: getRegistryProfile$2,
31793
- updateRegistryProfile: updateRegistryProfile$1,
31794
- getRegistryPackages: getRegistryPackages$1,
31795
- updateRegistryPackage: updateRegistryPackage$1,
31796
- deleteRegistryPackage,
31797
- clearToken,
31798
- };
31798
+ var widgetRegistryExports = widgetRegistry$1.exports;
31799
31799
 
31800
31800
  /**
31801
31801
  * registryApiController.js
@@ -31806,7 +31806,7 @@ var registryAuthController$1 = {
31806
31806
 
31807
31807
  const fs = require$$0$3;
31808
31808
  const path$3 = require$$1$2;
31809
- const { getStoredToken: getStoredToken$1 } = registryAuthController$1;
31809
+ const { getStoredToken: getStoredToken$2 } = registryAuthController$1;
31810
31810
 
31811
31811
  const REGISTRY_BASE_URL =
31812
31812
  process.env.DASH_REGISTRY_API_URL ||
@@ -31820,7 +31820,7 @@ const REGISTRY_BASE_URL =
31820
31820
  * @returns {Promise<Object>} { success, registryUrl, packageId, version, error? }
31821
31821
  */
31822
31822
  async function publishToRegistry$1(zipPath, manifest) {
31823
- const auth = getStoredToken$1();
31823
+ const auth = getStoredToken$2();
31824
31824
  if (!auth) {
31825
31825
  return {
31826
31826
  success: false,
@@ -31912,7 +31912,7 @@ const registryApiController$1 = registryApiController$2;
31912
31912
  const {
31913
31913
  getAuthStatus,
31914
31914
  getRegistryProfile: getRegistryProfile$1,
31915
- getStoredToken,
31915
+ getStoredToken: getStoredToken$1,
31916
31916
  } = registryAuthController$1;
31917
31917
 
31918
31918
  /**
@@ -32159,7 +32159,7 @@ async function installThemeFromRegistry$1(win, appId, packageName) {
32159
32159
 
32160
32160
  // Download the ZIP (with auth header)
32161
32161
  const headers = {};
32162
- const auth = getStoredToken();
32162
+ const auth = getStoredToken$1();
32163
32163
  if (auth?.token) {
32164
32164
  headers["Authorization"] = `Bearer ${auth.token}`;
32165
32165
  }
@@ -32324,6 +32324,7 @@ const {
32324
32324
  applyEventWiringToLayout,
32325
32325
  } = dashboardConfigUtils$1;
32326
32326
  const { searchRegistry, getPackage } = registryController$3;
32327
+ const { getStoredToken } = registryAuthController$1;
32327
32328
  const themeController$3 = themeController_1;
32328
32329
 
32329
32330
  const configFilename = "workspaces.json";
@@ -32947,7 +32948,13 @@ async function installDashboardFromRegistry$1(
32947
32948
  `[DashboardConfigController] Fetching dashboard from: ${downloadUrl}`,
32948
32949
  );
32949
32950
 
32950
- const response = await fetch(downloadUrl);
32951
+ // Download the ZIP (with auth header)
32952
+ const headers = {};
32953
+ const auth = getStoredToken();
32954
+ if (auth?.token) {
32955
+ headers["Authorization"] = `Bearer ${auth.token}`;
32956
+ }
32957
+ const response = await fetch(downloadUrl, { headers });
32951
32958
  if (!response.ok) {
32952
32959
  return {
32953
32960
  success: false,