Skip to content

Latest commit

 

History

History
520 lines (419 loc) · 16.7 KB

File metadata and controls

520 lines (419 loc) · 16.7 KB

Architecture Documentation

Overview

Loopi is built on Electron's multi-process architecture with a clear separation between main process (Node.js) and renderer process (React/browser). This document explains the design decisions and data flow patterns.

Process Architecture

Main Process (src/main/)

The main process runs Node.js and manages the application lifecycle. It's organized into modular services:

WindowManager

  • Purpose: Centralized window lifecycle management
  • Responsibilities:
    • Create main application window
    • Create browser automation window (on-demand)
    • Manage window references and cleanup
    • Configure security settings (preload scripts, context isolation)

AutomationExecutor

  • Purpose: Execute automation steps in browser windows
  • Key Methods:
    • executeStep(browserWindow, step): Runs a single step
    • evaluateConditional(browserWindow, config): Evaluates branching logic
    • substituteVariables(input): Template variable substitution {{varName}}
    • getVariableValue(path): Parse and access {{obj.prop[0].nested}}
    • parseValue(input): Auto-detect type (number, boolean, object, array, string)
  • State Management:
    • Maintains variables: Record<string, unknown> for all automation variables
    • Variables auto-type based on input: "42" → number, "true" → boolean, JSON → object
    • Token-based parser handles dot notation and array indexing
    • Stores currentNodeId for coordinating selector injection

SelectorPicker

  • Purpose: Interactive element selection from live pages
  • Flow:
    1. Injects navigation bar for URL changes
    2. Injects picker UI (hover highlights, click capture)
    3. Generates unique CSS selectors using structural paths
    4. Handles special cases (select elements → extract option data)
    5. Returns selector via IPC promise
  • Selector Generation Strategy:
    • Prefers structural selectors (tag:nth-of-type(n))
    • Builds full path from html root
    • Ensures uniqueness at each level

IPC Handlers

  • Purpose: Route IPC messages to appropriate services
  • Registered Channels:
    • browser:open, browser:close → WindowManager
    • browser:runStep, browser:runConditional → AutomationExecutor
    • pick-selector → SelectorPicker
    • browser:closed event → Main window notification
    • loopi:listTrees → TreeStore: List all saved automations
    • loopi:loadTrees → TreeStore: Load specific automation
    • loopi:saveTree → TreeStore: Save/update automation
    • loopi:loadExample → TreeStore: Load example from docs/examples/
    • loopi:deleteTree → TreeStore: Delete automation file from disk
    • loopi:loadSettings → SettingsStore: Load app settings
    • loopi:saveSettings → SettingsStore: Save app settings
    • dialog:selectFolder → Dialog: Open folder picker for download path

SettingsStore

  • Purpose: Persist application settings
  • Storage: ~/.config/loopi/settings.json
  • Manages:
    • Theme preference (light, dark, system)
    • Notifications toggle
    • Download path configuration
  • API:
    • loadSettings(): Returns AppSettings with defaults if file doesn't exist
    • saveSettings(settings): Persists to disk, triggers download handler update

DownloadManager

  • Purpose: Handle file downloads with configurable save path
  • Features:
    • Auto-creates download directory if it doesn't exist
    • Listens to will-download events on default session
    • Sets full file path (directory + filename) for each download
    • Falls back to system Downloads folder if custom path not set
  • Integration: Called on app startup and when settings change

Renderer Process (src/)

React-based UI running in Chromium with restricted privileges.

Component Hierarchy

App (root)
├── Dashboard (src/components/Dashboard.tsx)
│   ├── Tabs: "Your Automations" | "Examples"
│   ├── YourAutomations (src/components/dashboard/YourAutomations.tsx)
│   │   └── Edit/Delete/Export actions
│   └── Examples (src/components/dashboard/Examples.tsx)
│       └── Load example automations
│
└── AutomationBuilder (src/components/AutomationBuilder.tsx)
    ├── BuilderHeader
    │   ├── Settings dialog (name, description, schedule)
    │   └── Execution controls (run, pause, stop)
    ├── BuilderCanvas (ReactFlow)
    │   ├── AutomationNode (visual representation)
    │   ├── AddStepPopup (step type picker)
    │   └── NodeDetails
    │       ├── NodeHeader (title + delete)
    │       ├── StepEditor (routes to step-specific forms)
    │       └── ConditionEditor (branching config)
    └── Hooks
        ├── useNodeActions (node CRUD, edge validation)
        └── useExecution (automation orchestration)

Custom Hooks

useNodeActions

  • Manages node/edge operations in the graph
  • Enforces edge constraints:
    • Regular nodes: max 1 outgoing edge
    • Conditional nodes: max 2 (if/else branches)
    • Root node (id="1"): cannot be deleted
  • Creates type-specific initial step values via discriminated union switch

useExecution

  • Orchestrates automation execution
  • Implements recursive graph traversal:
    executeGraph(nodeId) {
      visit node
      execute step/condition
      determine next nodes (based on edges + branch result)
      recurse for each next node
    }
  • Manages visual feedback (nodeRunning state)
  • Handles browser lifecycle (auto-open if needed)

Data Flow Patterns

Node Editing Flow

User edits field in NodeDetails
  ↓
onUpdate(nodeId, "update", { step: { ...step, field: newValue } })
  ↓
useNodeActions.handleNodeAction
  ↓
setNodes((nodes) => nodes.map(n => n.id === nodeId ? { ...n, data: updates } : n))
  ↓
ReactFlow re-renders updated node

Selector Picking Flow

1. User clicks "Pick Element" button
   ↓
2. NodeDetails.handlePickSelector()
   ↓
3. window.electronAPI.pickSelector(recentUrl)  [IPC invoke]
   ↓
4. Main: ipcHandlers → SelectorPicker.pickSelector()
   ↓
5. Open/focus browser window, inject picker script
   ↓
6. User hovers (highlight) + clicks element
   ↓
7. Injected script generates CSS selector
   ↓
8. window.electronAPI.sendSelector(selector)  [IPC send]
   ↓
9. Main: receive "selector-picked", resolve promise
   ↓
10. Renderer: receives selector, updates step data

Automation Execution Flow

1. User clicks "Run Automation"
   ↓
2. useExecution.runAutomation()
   ↓
3. Open browser if not already open
   ↓
4. executeGraph("1")  // Start at root node
   ↓
5. For each node:
   - Mark as running (visual feedback)
   - If step: window.electronAPI.runStep(step)
     → Main: AutomationExecutor.executeStep()
     → webContents.executeJavaScript(...)
   - If conditional: window.electronAPI.runConditional(config)
     → Main: AutomationExecutor.evaluateConditional()
     → Check condition, return boolean + loop state
   - Determine next nodes from edges
   - Recurse
   ↓
6. Complete: reset state, show success message

Delete Automation Flow

1. User clicks Delete button in YourAutomations
   ↓
2. Confirmation dialog: "Are you sure?"
   ↓
3. If confirmed:
   window.electronAPI.tree.delete(automationId)  [IPC invoke]
   ↓
4. Main: ipcHandlers.handle("loopi:deleteTree")
   ↓
5. TreeStore.deleteAutomation(id, folder)
   - Build file path: ~/.config/[AppName]/.trees/tree_[id].json
   - fs.unlinkSync(filePath)  // Permanent deletion
   - Return true/false
   ↓
6. If success:
   - Renderer updates local state
   - Remove automation from automations array
   - UI re-renders without deleted item
   ↓
7. If error:
   - User sees alert: "Failed to delete automation"
   - Automation remains in list (no data loss)

Load Example Flow

1. User clicks "Load Example" button on example card
   ↓
2. handleLoadExample(example)
   ↓
3. window.electronAPI.tree.loadExample(fileName)  [IPC invoke]
   ↓
4. Main: ipcHandlers.handle("loopi:loadExample")
   ↓
5. Read file: docs/examples/[fileName].json
   ↓
6. Return parsed automation JSON
   ↓
7. Renderer:
   - Generate new ID: Date.now().toString()
   - window.electronAPI.tree.save(automation)  [IPC invoke]
   - Add to automations array
   - Switch to "Your Automations" tab

Type System Design

Variables System

Loopi uses an auto-typed unified variable system where all variables are stored as unknown type and auto-detect their type based on input.

Storage Structure (in AutomationExecutor):

variables: Record<string, unknown>

Type Detection (parseValue() method):

  • "42" or "3.14"number
  • "true" or "false"boolean
  • Valid JSON starting with { or [object or array
  • Everything else → string

Example:

this.variables["count"] = this.parseValue("42");        // number: 42
this.variables["user"] = this.parseValue('{"name":"John"}'); // object
this.variables["name"] = this.parseValue("John");        // string: "John"

Variable Access (getVariableValue() method):

Uses token-based parser to handle dot notation and array indexing:

// Parses path into tokens: ["users", 0, "name"]
// Then navigates: variables["users"] → [0] → .name
path: "users[0].name"
result: "John" (if users is array of objects)

// Supported syntax:
{{username}}                    // Simple: string value
{{user.name}}                   // Nested: property access
{{users[0]}}                    // Array: index access
{{users[0].email}}              // Mixed: combined access
{{matrix[0][1]}}                // Multi-dimensional arrays
{{response.data.items[2].id}}   // Deep nesting

Variable Substitution (substituteVariables() method):

Replaces {{varName}} tokens in strings with actual values:

// Input: "https://example.com/user/{{userId}}"
// Result: "https://example.com/user/123"

// Input: "Hello {{user.name}}, your balance is {{account.balance}}"
// Result: "Hello John, your balance is 1000"

// Regex: /\{\{\s*([a-zA-Z0-9_\[\].]+)\s*\}\}/g

Usage in Steps:

  1. API Call - Stores raw response as object

    this.variables[step.storeKey] = dataOut; // Already typed
  2. Set Variable - Auto-types input

    this.variables[name] = this.parseValue(rawValue); // Auto-detect
  3. Modify Variable - Type-aware operations

    if (typeof current === "number") {
      this.variables[name] = current + increment;
    }
  4. Navigate/Type/Extract - Substitutes variables

    const url = this.substituteVariables(step.value);

For detailed variable documentation, see VARIABLES.md.

Discriminated Unions (Step Types)

All step types are modeled as a discriminated union with type as the discriminant:

type AutomationStep = 
  | { type: "navigate"; id: string; description: string; value: string }
  | { type: "click"; id: string; description: string; selector: string }
  | { type: "type"; id: string; description: string; selector: string; value: string; credentialId?: string }
  | { type: "extract"; id: string; description: string; selector: string; storeKey?: string }
  | { type: "setVariable"; id: string; description: string; variableName: string; value: string }
  | { type: "modifyVariable"; id: string; description: string; variableName: string; operation: ModifyOp; value: string }
  | ...

// TypeScript narrows automatically based on type field
function execute(step: AutomationStep) {
  switch (step.type) {
    case "navigate":
      // step.value is known to exist
      return loadURL(step.value);
    case "click":
      // step.selector is known to exist
      return click(step.selector);
    case "extract":
      // step.selector and step.storeKey are known
      return extractText(step.selector, step.storeKey);
  }
}

Benefits:

  • Compile-time exhaustiveness checking
  • Prevents accessing fields that don't exist on a variant
  • Self-documenting via type definitions

Schedule Type

Similarly, schedules use discriminated unions:

type Schedule =
  | { type: "manual" }
  | { type: "interval"; interval: number; unit: "minutes" | "hours" | "days" }
  | { type: "fixed"; value: string }  // "HH:MM" format

This prevents bugs like accessing schedule.interval when schedule.type === "fixed".

Security Model

Context Isolation

Renderer process runs with:

webPreferences: {
  contextIsolation: true,    // Isolate renderer from preload
  nodeIntegration: false,    // No direct Node.js access
  preload: PRELOAD_PATH      // Only bridge via preload
}

IPC Surface

Preload script exposes minimal API via contextBridge:

// ✅ Allowed: Renderer can invoke these IPC channels
electronAPI.openBrowser(url)
electronAPI.runStep(step)
electronAPI.pickSelector(url)

// ❌ Blocked: Renderer cannot access
require('fs')
require('child_process')
ipcRenderer.send('arbitrary-channel')

Browser Window Security

Automation browser windows use same preload + context isolation to allow navigation bar script access to controlled IPC for URL loading.

State Management

React State Organization

  • App-level state: automations[], credentials[], currentView
  • Builder-level state: nodes[], edges[], selectedNodeId, schedule
  • Execution state: isBrowserOpen, isAutomationRunning, currentNodeId

No global state library (Redux, Zustand) needed—props drilling is minimal due to focused component hierarchy.

Main Process State

  • WindowManager: mainWindow, browserWindow references
  • AutomationExecutor: loopIndices, currentNodeId for loop coordination

Performance Considerations

ReactFlow Optimization

  • Uses useNodesState and useEdgesState for memoized updates
  • Node types registered once at module level
  • Callbacks wrapped in useCallback to prevent re-renders

IPC Patterns

  • Uses invoke/handle for request-response (promises)
  • Uses send/on for one-way events (browser:closed)
  • Avoids sending large data structures; passes IDs and fetch on demand

Browser Automation

  • Steps execute sequentially with await to ensure order
  • Each step completes before moving to next (no parallelization within flow)
  • Visual feedback (500ms delay) between steps for debugging

Extension Points

Adding New Step Types

For detailed step-by-step instructions, see NEW_STEP_TEMPLATE.md.

Quick overview:

  1. Define type in src/types/steps.ts
  2. Create editor component in src/components/.../stepTypes/
  3. Add execution logic in src/main/automationExecutor.ts
  4. Add initial values in src/hooks/useNodeActions.ts
  5. Wire up in StepEditor.tsx
  6. Update docs/STEPS_REFERENCE.md with examples
  7. Create example in docs/examples/

Custom Conditional Types

  1. Add to ConditionType union in flow.ts
  2. Update ConditionEditor UI
  3. Implement logic in AutomationExecutor.evaluateConditional()

IPC Channels

  1. Add handler in ipcHandlers.ts
  2. Expose method in preload.ts
  3. Invoke from renderer via window.electronAPI.*

Testing Strategy

Unit Tests (Recommended)

  • Utilities: automationIO.ts (import/export validation)
  • Hooks: useNodeActions, useExecution (with mock IPC)
  • Main Services: AutomationExecutor, SelectorPicker (with mock BrowserWindow)

Integration Tests

  • End-to-end flow: Create automation → Save → Load → Execute
  • Selector picker: Inject → Highlight → Click → Receive selector
  • Import/Export: Round-trip JSON serialization

Manual Testing Checklist

  • Create new automation with multiple step types
  • Add conditional node with if/else branches
  • Test variable-driven loops using Set Variable / Modify Variable and {{index}} substitution
  • Pick selectors from live page
  • Execute automation and verify steps
  • Import/export workflows
  • Schedule configuration (all types)

Known Limitations

  1. Loop/Variable State Persistence: Runtime variables (e.g. loop indices) are not persisted across restarts unless stored in automation data
  2. Credential Encryption: Placeholder implementation (use electron-store + crypto for production)
  3. Error Recovery: Limited retry logic in automation execution
  4. Concurrency: One automation runs at a time
  5. Step Validation: Minimal validation before execution (e.g., invalid selectors)

Future Improvements

  • Persistent execution logs with screenshots

  • Subflow/reusable component support

  • Headless execution mode (no browser UI)

  • Cloud sync for workflows

  • Collaborative editing

  • Plugin system for custom step types

  • Advanced scheduling (cron expressions)