luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

Sys.lua (41579B)


      1 #!/usr/bin/env luajit
      2 -- System Management Library
      3 -- Handles running applications, active window, and input routing
      4 
      5 local sys = {}
      6 
      7 -- Initialize global hook instance (Hook should be loaded as _G.Hook in init.lua)
      8 if _G.Hook then
      9     sys.hook = _G.Hook.newInstance()
     10 else
     11     osprint("Warning: Hook library not available, sys.hook not initialized\n")
     12 end
     13 
     14 -- Running applications table
     15 sys.applications = {}
     16 
     17 -- Sandbox environments table (indexed by PID)
     18 sys.environments = {}
     19 
     20 -- Prompt mode state (for admin password prompts, etc.)
     21 sys.promptMode = {
     22     active = false,
     23     window = nil,  -- The only window allowed to draw/receive events
     24     pid = nil      -- PID of the app that owns the prompt window
     25 }
     26 
     27 -- Active window for keyboard input
     28 sys.activeWindow = nil
     29 
     30 -- Application registry
     31 sys.appRegistry = {}
     32 
     33 -- Hotkey registry
     34 -- Format: hotkeys["scancode_modifiers_event"] = function
     35 -- Example: hotkeys["59_0_down"] = function() ... end  (F1 key down, no modifiers)
     36 -- Modifiers: 1=shift, 2=ctrl, 4=alt (can be combined)
     37 -- Events: "down", "up", "press"
     38 sys.hotkeys = {}
     39 
     40 -- Screen API
     41 sys.screen = {}
     42 sys.screen[1] = {
     43     width = DEFAULT_SCREEN_WIDTH or 1200,   -- Get from C constants
     44     height = DEFAULT_SCREEN_HEIGHT or 900,
     45     setResolution = function(self, w, h)
     46         -- Check if caller has display-settings permission
     47         local caller = debug.getinfo(2, "f").func
     48         local hasPermission = false
     49 
     50         -- Find the calling app's environment
     51         for pid, env in pairs(sys.environments) do
     52             if env._permissions and env._permissions["display-settings"] then
     53                 -- Check if this function was called from that environment
     54                 hasPermission = true
     55                 break
     56             end
     57         end
     58 
     59         if not hasPermission then
     60             error("Permission denied: 'display-settings' permission required to change screen resolution")
     61         end
     62 
     63         -- Validate resolution
     64         if type(w) ~= "number" or type(h) ~= "number" then
     65             error("setResolution requires numeric width and height")
     66         end
     67 
     68         if w <= 0 or h <= 0 then
     69             error("Resolution must be positive")
     70         end
     71 
     72         -- Call VESA to set the mode
     73         if VESASetMode then
     74             local result = VESASetMode(w, h, 32)
     75             if result == 0 then
     76                 self.width = w
     77                 self.height = h
     78 
     79                 -- Trigger ScreenResolutionChanged hook
     80                 if sys.hook then
     81                     sys.hook:run("ScreenResolutionChanged", self)
     82                 end
     83 
     84                 return true
     85             else
     86                 error("Failed to set resolution " .. w .. "x" .. h)
     87             end
     88         else
     89             error("VESASetMode not available")
     90         end
     91     end
     92 }
     93 
     94 -- Modifier key state
     95 local modifierState = {
     96     shift = false,
     97     ctrl = false,
     98     alt = false,
     99     meta = false  -- Windows/Meta key
    100 }
    101 
    102 -- Modifier constants
    103 sys.MOD_NONE = 0
    104 sys.MOD_SHIFT = 1
    105 sys.MOD_CTRL = 2
    106 sys.MOD_ALT = 4
    107 sys.MOD_META = 8
    108 sys.MOD_SHIFT_CTRL = 3
    109 sys.MOD_SHIFT_ALT = 5
    110 sys.MOD_CTRL_ALT = 6
    111 sys.MOD_SHIFT_META = 9
    112 sys.MOD_CTRL_META = 10
    113 sys.MOD_ALT_META = 12
    114 sys.MOD_ALL = 15
    115 
    116 -- Event constants
    117 sys.EVENT_DOWN = "down"
    118 sys.EVENT_UP = "up"
    119 sys.EVENT_PRESS = "press"
    120 
    121 -- Common key scancodes
    122 sys.KEY_ESC = 1
    123 sys.KEY_1 = 2
    124 sys.KEY_2 = 3
    125 sys.KEY_3 = 4
    126 sys.KEY_4 = 5
    127 sys.KEY_5 = 6
    128 sys.KEY_6 = 7
    129 sys.KEY_7 = 8
    130 sys.KEY_8 = 9
    131 sys.KEY_9 = 10
    132 sys.KEY_0 = 11
    133 sys.KEY_MINUS = 12
    134 sys.KEY_EQUALS = 13
    135 sys.KEY_BACKSPACE = 14
    136 sys.KEY_TAB = 15
    137 sys.KEY_Q = 16
    138 sys.KEY_W = 17
    139 sys.KEY_E = 18
    140 sys.KEY_R = 19
    141 sys.KEY_T = 20
    142 sys.KEY_Y = 21
    143 sys.KEY_U = 22
    144 sys.KEY_I = 23
    145 sys.KEY_O = 24
    146 sys.KEY_P = 25
    147 sys.KEY_LBRACKET = 26
    148 sys.KEY_RBRACKET = 27
    149 sys.KEY_ENTER = 28
    150 sys.KEY_LCTRL = 29
    151 sys.KEY_A = 30
    152 sys.KEY_S = 31
    153 sys.KEY_D = 32
    154 sys.KEY_F = 33
    155 sys.KEY_G = 34
    156 sys.KEY_H = 35
    157 sys.KEY_J = 36
    158 sys.KEY_K = 37
    159 sys.KEY_L = 38
    160 sys.KEY_SEMICOLON = 39
    161 sys.KEY_QUOTE = 40
    162 sys.KEY_BACKTICK = 41
    163 sys.KEY_LSHIFT = 42
    164 sys.KEY_BACKSLASH = 43
    165 sys.KEY_Z = 44
    166 sys.KEY_X = 45
    167 sys.KEY_C = 46
    168 sys.KEY_V = 47
    169 sys.KEY_B = 48
    170 sys.KEY_N = 49
    171 sys.KEY_M = 50
    172 sys.KEY_COMMA = 51
    173 sys.KEY_PERIOD = 52
    174 sys.KEY_SLASH = 53
    175 sys.KEY_RSHIFT = 54
    176 sys.KEY_KPASTERISK = 55
    177 sys.KEY_LALT = 56
    178 sys.KEY_SPACE = 57
    179 sys.KEY_CAPSLOCK = 58
    180 sys.KEY_F1 = 59
    181 sys.KEY_F2 = 60
    182 sys.KEY_F3 = 61
    183 sys.KEY_F4 = 62
    184 sys.KEY_F5 = 63
    185 sys.KEY_F6 = 64
    186 sys.KEY_F7 = 65
    187 sys.KEY_F8 = 66
    188 sys.KEY_F9 = 67
    189 sys.KEY_F10 = 68
    190 sys.KEY_NUMLOCK = 69
    191 sys.KEY_SCROLLLOCK = 70
    192 sys.KEY_F11 = 87
    193 sys.KEY_F12 = 88
    194 sys.KEY_LMETA = 91  -- Left Windows/Meta key (0x5B)
    195 sys.KEY_RMETA = 92  -- Right Windows/Meta key (0x5C)
    196 
    197 ---Convert scancode and modifiers to hotkey string format
    198 ---@param scancode number The scancode
    199 ---@param modifiers number The modifier bitmask
    200 ---@return string|nil The hotkey string in format "ctrl+alt+meta+shift+key" or nil if unknown
    201 function sys.scancodeToHotkeyString(scancode, modifiers)
    202     -- Build modifier prefix in canonical order: ctrl+alt+meta+shift
    203     local parts = {}
    204 
    205     if bit.band(modifiers, sys.MOD_CTRL) ~= 0 then
    206         table.insert(parts, "ctrl")
    207     end
    208     if bit.band(modifiers, sys.MOD_ALT) ~= 0 then
    209         table.insert(parts, "alt")
    210     end
    211     if bit.band(modifiers, sys.MOD_META) ~= 0 then
    212         table.insert(parts, "meta")
    213     end
    214     if bit.band(modifiers, sys.MOD_SHIFT) ~= 0 then
    215         table.insert(parts, "shift")
    216     end
    217 
    218     -- Map scancode to key name
    219     local keyName = nil
    220 
    221     -- Letters
    222     if scancode == 16 then keyName = "q"
    223     elseif scancode == 17 then keyName = "w"
    224     elseif scancode == 18 then keyName = "e"
    225     elseif scancode == 19 then keyName = "r"
    226     elseif scancode == 20 then keyName = "t"
    227     elseif scancode == 21 then keyName = "y"
    228     elseif scancode == 22 then keyName = "u"
    229     elseif scancode == 23 then keyName = "i"
    230     elseif scancode == 24 then keyName = "o"
    231     elseif scancode == 25 then keyName = "p"
    232     elseif scancode == 30 then keyName = "a"
    233     elseif scancode == 31 then keyName = "s"
    234     elseif scancode == 32 then keyName = "d"
    235     elseif scancode == 33 then keyName = "f"
    236     elseif scancode == 34 then keyName = "g"
    237     elseif scancode == 35 then keyName = "h"
    238     elseif scancode == 36 then keyName = "j"
    239     elseif scancode == 37 then keyName = "k"
    240     elseif scancode == 38 then keyName = "l"
    241     elseif scancode == 44 then keyName = "z"
    242     elseif scancode == 45 then keyName = "x"
    243     elseif scancode == 46 then keyName = "c"
    244     elseif scancode == 47 then keyName = "v"
    245     elseif scancode == 48 then keyName = "b"
    246     elseif scancode == 49 then keyName = "n"
    247     elseif scancode == 50 then keyName = "m"
    248     -- Numbers
    249     elseif scancode == 2 then keyName = "1"
    250     elseif scancode == 3 then keyName = "2"
    251     elseif scancode == 4 then keyName = "3"
    252     elseif scancode == 5 then keyName = "4"
    253     elseif scancode == 6 then keyName = "5"
    254     elseif scancode == 7 then keyName = "6"
    255     elseif scancode == 8 then keyName = "7"
    256     elseif scancode == 9 then keyName = "8"
    257     elseif scancode == 10 then keyName = "9"
    258     elseif scancode == 11 then keyName = "0"
    259     -- Special keys
    260     elseif scancode == 1 then keyName = "esc"
    261     elseif scancode == 12 then keyName = "minus"
    262     elseif scancode == 13 then keyName = "equals"
    263     elseif scancode == 14 then keyName = "backspace"
    264     elseif scancode == 15 then keyName = "tab"
    265     elseif scancode == 28 then keyName = "enter"
    266     elseif scancode == 57 then keyName = "space"
    267     -- Function keys
    268     elseif scancode == 59 then keyName = "f1"
    269     elseif scancode == 60 then keyName = "f2"
    270     elseif scancode == 61 then keyName = "f3"
    271     elseif scancode == 62 then keyName = "f4"
    272     elseif scancode == 63 then keyName = "f5"
    273     elseif scancode == 64 then keyName = "f6"
    274     elseif scancode == 65 then keyName = "f7"
    275     elseif scancode == 66 then keyName = "f8"
    276     elseif scancode == 67 then keyName = "f9"
    277     elseif scancode == 68 then keyName = "f10"
    278     elseif scancode == 87 then keyName = "f11"
    279     elseif scancode == 88 then keyName = "f12"
    280     end
    281 
    282     if not keyName then
    283         return nil  -- Unknown scancode
    284     end
    285 
    286     table.insert(parts, keyName)
    287     return table.concat(parts, "+")
    288 end
    289 
    290 ---Add a hotkey handler
    291 ---@param scancode number The key scancode (use sys.KEY_* constants)
    292 ---@param modifiers number Modifier flags (use sys.MOD_* constants)
    293 ---@param event string Event type (use sys.EVENT_* constants, default "down")
    294 ---@param handler function Handler function that receives (scancode, modifiers, event)
    295 ---@return string The hotkey ID for later removal
    296 function sys.addHotkey(scancode, modifiers, event, handler)
    297     -- Handle optional event parameter
    298     if type(event) == "function" then
    299         handler = event
    300         event = "down"
    301     end
    302 
    303     modifiers = modifiers or 0
    304     event = event or "down"
    305 
    306     local hotkeyId = scancode .. "_" .. modifiers .. "_" .. event
    307     sys.hotkeys[hotkeyId] = handler
    308 
    309     return hotkeyId
    310 end
    311 
    312 ---Remove a hotkey handler
    313 ---@param hotkeyId string The hotkey ID returned by addHotkey, or scancode/modifiers/event
    314 ---@param modifiers number Optional: modifier flags if first param is scancode
    315 ---@param event string Optional: event type if first param is scancode
    316 function sys.removeHotkey(hotkeyId, modifiers, event)
    317     -- Support both formats: removeHotkey(id) or removeHotkey(scancode, modifiers, event)
    318     if type(hotkeyId) == "number" then
    319         -- Construct ID from components
    320         modifiers = modifiers or 0
    321         event = event or "down"
    322         hotkeyId = hotkeyId .. "_" .. modifiers .. "_" .. event
    323     end
    324 
    325     sys.hotkeys[hotkeyId] = nil
    326 end
    327 
    328 ---Add a hotkey using string notation (e.g., "ctrl+alt+r", "meta+r")
    329 ---@param keyCombo string Key combination like "ctrl+r", "ctrl+alt+shift+f", "meta+r"
    330 ---@param handler function Handler function
    331 ---@param event string Optional event type (default "down")
    332 ---@return string The hotkey ID for later removal
    333 function sys.addHotkeyString(keyCombo, handler, event)
    334     event = event or "down"
    335 
    336     -- Parse the key combination
    337     local parts = {}
    338     for part in string.gmatch(keyCombo, "[^+]+") do
    339         table.insert(parts, string.lower(part))
    340     end
    341 
    342     -- Build modifiers
    343     local modifiers = 0
    344     local keyName = nil
    345 
    346     for _, part in ipairs(parts) do
    347         if part == "ctrl" or part == "control" then
    348             modifiers = bit.bor(modifiers, sys.MOD_CTRL)
    349         elseif part == "alt" then
    350             modifiers = bit.bor(modifiers, sys.MOD_ALT)
    351         elseif part == "shift" then
    352             modifiers = bit.bor(modifiers, sys.MOD_SHIFT)
    353         elseif part == "meta" or part == "win" or part == "super" then
    354             modifiers = bit.bor(modifiers, sys.MOD_META)
    355         else
    356             keyName = part
    357         end
    358     end
    359 
    360     if not keyName then
    361         error("No key specified in key combination: " .. keyCombo)
    362     end
    363 
    364     -- Map key name to scancode
    365     local scancode = nil
    366 
    367     -- Check for special key names first
    368     local keyUpper = string.upper(keyName)
    369     if keyUpper == "PLUS" then scancode = 13  -- + key
    370     elseif keyUpper == "MINUS" then scancode = 12  -- - key
    371     elseif keyUpper == "EQUALS" then scancode = 13  -- = key (same as +)
    372     elseif keyUpper == "SPACE" then scancode = 57  -- Space
    373     elseif keyUpper == "ENTER" or keyUpper == "RETURN" then scancode = 28  -- Enter
    374     elseif keyUpper == "BACKSPACE" then scancode = 14  -- Backspace
    375     elseif keyUpper == "TAB" then scancode = 15  -- Tab
    376     elseif keyUpper == "ESC" or keyUpper == "ESCAPE" then scancode = 1  -- Escape
    377     elseif #keyName == 1 then
    378         -- Single character - map to scancode
    379         local char = string.upper(keyName)
    380         -- Letters
    381         if char == "Q" then scancode = 16
    382         elseif char == "W" then scancode = 17
    383         elseif char == "E" then scancode = 18
    384         elseif char == "R" then scancode = 19
    385         elseif char == "T" then scancode = 20
    386         elseif char == "Y" then scancode = 21
    387         elseif char == "U" then scancode = 22
    388         elseif char == "I" then scancode = 23
    389         elseif char == "O" then scancode = 24
    390         elseif char == "P" then scancode = 25
    391         elseif char == "A" then scancode = 30
    392         elseif char == "S" then scancode = 31
    393         elseif char == "D" then scancode = 32
    394         elseif char == "F" then scancode = 33
    395         elseif char == "G" then scancode = 34
    396         elseif char == "H" then scancode = 35
    397         elseif char == "J" then scancode = 36
    398         elseif char == "K" then scancode = 37
    399         elseif char == "L" then scancode = 38
    400         elseif char == "Z" then scancode = 44
    401         elseif char == "X" then scancode = 45
    402         elseif char == "C" then scancode = 46
    403         elseif char == "V" then scancode = 47
    404         elseif char == "B" then scancode = 48
    405         elseif char == "N" then scancode = 49
    406         elseif char == "M" then scancode = 50
    407         -- Numbers
    408         elseif char == "1" then scancode = 2
    409         elseif char == "2" then scancode = 3
    410         elseif char == "3" then scancode = 4
    411         elseif char == "4" then scancode = 5
    412         elseif char == "5" then scancode = 6
    413         elseif char == "6" then scancode = 7
    414         elseif char == "7" then scancode = 8
    415         elseif char == "8" then scancode = 9
    416         elseif char == "9" then scancode = 10
    417         elseif char == "0" then scancode = 11
    418         -- Special characters (based on US keyboard layout)
    419         elseif char == "!" then scancode = 2  -- Shift+1
    420         elseif char == "@" then scancode = 3  -- Shift+2
    421         elseif char == "#" then scancode = 4  -- Shift+3
    422         elseif char == "$" then scancode = 5  -- Shift+4
    423         elseif char == "%" then scancode = 6  -- Shift+5
    424         elseif char == "^" then scancode = 7  -- Shift+6
    425         elseif char == "&" then scancode = 8  -- Shift+7
    426         elseif char == "*" then scancode = 9  -- Shift+8
    427         elseif char == "(" then scancode = 10  -- Shift+9
    428         elseif char == ")" then scancode = 11  -- Shift+0
    429         elseif char == "-" then scancode = 12  -- Minus
    430         elseif char == "_" then scancode = 12  -- Shift+Minus
    431         elseif char == "=" then scancode = 13  -- Equals
    432         elseif char == "+" then scancode = 13  -- Shift+Equals
    433         elseif char == "[" then scancode = 26  -- Left bracket
    434         elseif char == "{" then scancode = 26  -- Shift+Left bracket
    435         elseif char == "]" then scancode = 27  -- Right bracket
    436         elseif char == "}" then scancode = 27  -- Shift+Right bracket
    437         elseif char == ";" then scancode = 39  -- Semicolon
    438         elseif char == ":" then scancode = 39  -- Shift+Semicolon
    439         elseif char == "'" then scancode = 40  -- Quote
    440         elseif char == '"' then scancode = 40  -- Shift+Quote
    441         elseif char == "\\" then scancode = 43  -- Backslash
    442         elseif char == "|" then scancode = 43  -- Shift+Backslash
    443         elseif char == "," then scancode = 51  -- Comma
    444         elseif char == "<" then scancode = 51  -- Shift+Comma
    445         elseif char == "." then scancode = 52  -- Period
    446         elseif char == ">" then scancode = 52  -- Shift+Period
    447         elseif char == "/" then scancode = 53  -- Slash
    448         elseif char == "?" then scancode = 53  -- Shift+Slash
    449         elseif char == "`" then scancode = 41  -- Backtick
    450         elseif char == "~" then scancode = 41  -- Shift+Backtick
    451         elseif char == "£" then scancode = 4   -- UK keyboard Shift+3
    452         else
    453             error("Unsupported key: " .. keyName)
    454         end
    455     else
    456         error("Multi-character key names not yet supported: " .. keyName)
    457     end
    458 
    459     return sys.addHotkey(scancode, modifiers, event, handler)
    460 end
    461 
    462 ---Register a running application
    463 ---@param appInstance table Application instance
    464 function sys.registerApplication(appInstance)
    465     if not appInstance or not appInstance.pid then
    466         return false
    467     end
    468 
    469     sys.applications[appInstance.pid] = appInstance
    470 
    471     return true
    472 end
    473 
    474 ---Register a sandbox environment for an application
    475 ---@param pid number Process ID
    476 ---@param environment table Sandbox environment
    477 function sys.registerEnvironment(pid, environment)
    478     if not pid or not environment then
    479         return false
    480     end
    481 
    482     sys.environments[pid] = environment
    483 
    484     return true
    485 end
    486 
    487 ---Get sandbox environment for an application
    488 ---@param pid number Process ID
    489 ---@return table|nil Sandbox environment
    490 function sys.getEnvironment(pid)
    491     return sys.environments[pid]
    492 end
    493 
    494 ---Unregister a running application
    495 ---@param pid number Process ID
    496 function sys.unregisterApplication(pid)
    497     sys.applications[pid] = nil
    498     sys.environments[pid] = nil
    499 
    500     -- Clear active window if it belonged to this app
    501     if sys.activeWindow and sys.activeWindow.appInstance and sys.activeWindow.appInstance.pid == pid then
    502         sys.activeWindow = nil
    503     end
    504 end
    505 
    506 ---Get application by PID
    507 ---@param pid number Process ID
    508 ---@return table|nil Application instance
    509 function sys.getApplication(pid)
    510     return sys.applications[pid]
    511 end
    512 
    513 ---Get all running applications
    514 ---@return table List of application instances
    515 function sys.getAllApplications()
    516     local apps = {}
    517     for _, app in pairs(sys.applications) do
    518         table.insert(apps, app)
    519     end
    520     return apps
    521 end
    522 
    523 ---Set the active window for keyboard input
    524 ---@param window table Window object
    525 function sys.setActiveWindow(window)
    526     sys.activeWindow = window
    527 end
    528 
    529 ---Get the active window
    530 ---@return table|nil Active window
    531 function sys.getActiveWindow()
    532     return sys.activeWindow
    533 end
    534 
    535 ---Find and set the first available window as active
    536 ---@return boolean True if a window was found and set
    537 function sys.findAndSetActiveWindow()
    538     -- Iterate through all running applications
    539     -- Find the window with onInput that has the highest createdAt (topmost)
    540     local foundWindow = nil
    541     local highestTime = -1
    542     for pid, app in pairs(sys.applications) do
    543         if app.windows then
    544             for i, window in ipairs(app.windows) do
    545                 if window.onInput and not window.isBackground then
    546                     local windowTime = window.createdAt or 0
    547                     if windowTime > highestTime then
    548                         highestTime = windowTime
    549                         foundWindow = window
    550                     end
    551                 end
    552             end
    553         end
    554     end
    555     if foundWindow then
    556         sys.setActiveWindow(foundWindow)
    557         return true
    558     end
    559     return false
    560 end
    561 
    562 ---Send keyboard input to the active window
    563 ---@param key string The key character
    564 ---@param scancode number The scancode
    565 ---@return boolean True if input was sent successfully
    566 function sys.sendInput(key, scancode)
    567     -- Track modifier key state
    568     -- Scancodes: 42=LShift, 54=RShift, 29=LCtrl, 56=LAlt, 91=LMeta, 92=RMeta
    569     local isKeyDown = scancode < 128  -- High bit not set = key down
    570     local baseScancode = scancode % 128  -- Remove high bit for key up events
    571 
    572     -- Debug: Print the baseScancode for all keys
    573 
    574     if baseScancode == 42 or baseScancode == 54 then
    575         modifierState.shift = isKeyDown
    576     elseif baseScancode == 29 then
    577         modifierState.ctrl = isKeyDown
    578     elseif baseScancode == 56 then
    579         modifierState.alt = isKeyDown
    580     elseif baseScancode == 91 or baseScancode == 92 then
    581         modifierState.meta = isKeyDown
    582     end
    583 
    584     -- Calculate modifier bitmask
    585     local modifiers = 0
    586     if modifierState.shift then modifiers = modifiers + 1 end
    587     if modifierState.ctrl then modifiers = modifiers + 2 end
    588     if modifierState.alt then modifiers = modifiers + 4 end
    589     if modifierState.meta then modifiers = modifiers + 8 end
    590 
    591 
    592     -- Determine event type
    593     local event = isKeyDown and "down" or "up"
    594 
    595     -- Check for hotkey match (down events)
    596     if isKeyDown then
    597         local hotkeyDown = baseScancode .. "_" .. modifiers .. "_down"
    598         if sys.hotkeys[hotkeyDown] then
    599             local success, shouldCancel = pcall(sys.hotkeys[hotkeyDown])
    600             if success and shouldCancel == true then
    601                 -- Hotkey handled and requested cancellation
    602                 return true
    603             end
    604         end
    605 
    606         -- Also check the new hotkey manager
    607         if sys.hotkey then
    608             local hotkeyStr = sys.scancodeToHotkeyString(baseScancode, modifiers)
    609             if hotkeyStr then
    610                 local hasHotkey = sys.hotkey.has(hotkeyStr)
    611                 if hasHotkey then
    612                     local success, result = pcall(sys.hotkey.trigger, hotkeyStr)
    613                     if success then
    614                         -- Hotkey was triggered successfully, don't send input to window
    615                         return true
    616                     end
    617                 end
    618             end
    619         end
    620     end
    621 
    622     -- Check for hotkey match (up events)
    623     if not isKeyDown then
    624         local hotkeyUp = baseScancode .. "_" .. modifiers .. "_up"
    625         if sys.hotkeys[hotkeyUp] then
    626             local success, shouldCancel = pcall(sys.hotkeys[hotkeyUp])
    627             if success and shouldCancel == true then
    628                 -- Hotkey handled and requested cancellation
    629                 return true
    630             end
    631         end
    632     end
    633 
    634     -- Check for press event (any key with modifiers)
    635     local hotkeyPress = baseScancode .. "_" .. modifiers .. "_press"
    636     if sys.hotkeys[hotkeyPress] then
    637         local success, shouldCancel = pcall(sys.hotkeys[hotkeyPress])
    638         if success and shouldCancel == true then
    639             -- Hotkey handled and requested cancellation
    640             return true
    641         end
    642     end
    643 
    644     -- Don't send modifier key presses/releases to windows
    645     -- Modifier scancodes: 42,54=Shift, 29=Ctrl, 56=Alt, 91,92=Meta
    646     local isModifierKey = (baseScancode == 42 or baseScancode == 54 or
    647                           baseScancode == 29 or baseScancode == 56 or
    648                           baseScancode == 91 or baseScancode == 92)
    649 
    650     if isModifierKey then
    651         -- Modifier keys don't get sent to windows, only tracked for hotkeys
    652         return false
    653     end
    654 
    655     -- Don't send key release events to windows (only key down)
    656     if not isKeyDown then
    657         return false
    658     end
    659 
    660     -- If no active window, try to find one
    661     if not sys.activeWindow then
    662         if not sys.findAndSetActiveWindow() then
    663             -- No window available to receive input
    664             return false
    665         end
    666     end
    667 
    668     -- Check if there's an active selection and a printable key was pressed
    669     local win = sys.activeWindow
    670     local hasSelection = win.selection and win.selection.start and win.selection.finish and
    671                          win.selection.content and win.selection.content ~= ""
    672 
    673     -- Check if this is a printable character or special editing key (backspace, delete, enter)
    674     local isPrintable = key and #key == 1 and key:byte() >= 32
    675     local isBackspace = key == "\b"
    676     local isEnter = key == "\n"
    677     local isDelete = baseScancode == 83
    678 
    679     if hasSelection and (isPrintable or isBackspace or isEnter or isDelete) then
    680         -- Selection is active and an editing key was pressed
    681         if win.onSelectionEditted then
    682             local newContent = ""
    683             if isPrintable then
    684                 newContent = key
    685             elseif isEnter then
    686                 newContent = "\n"
    687             end
    688             -- Backspace and Delete replace selection with empty string
    689 
    690             -- Convert selection indices to x,y coordinates
    691             local startText = win.selectableText and win.selectableText[win.selection.start.index]
    692             local finishText = win.selectableText and win.selectableText[win.selection.finish.index]
    693 
    694             local point1, point2
    695             if startText then
    696                 local charWidth = 8 * (startText.scale or 1)
    697                 point1 = {
    698                     x = startText.x + (win.selection.start.pos - 1) * charWidth,
    699                     y = startText.y
    700                 }
    701             end
    702             if finishText then
    703                 local charWidth = 8 * (finishText.scale or 1)
    704                 point2 = {
    705                     x = finishText.x + (win.selection.finish.pos - 1) * charWidth,
    706                     y = finishText.y
    707                 }
    708             end
    709 
    710             if not point1 or not point2 then
    711                 -- Fall back to passing the raw selection if we can't get coordinates
    712                 point1 = win.selection.start
    713                 point2 = win.selection.finish
    714             end
    715 
    716             local success, err = pcall(function()
    717                 win.onSelectionEditted(point1, point2, newContent)
    718             end)
    719 
    720             if not success and osprint then
    721                 osprint("ERROR: onSelectionEditted callback failed: " .. tostring(err) .. "\n")
    722             end
    723 
    724             -- Clear selection after edit
    725             win.selection = nil
    726             win.dirty = true
    727             return true
    728         end
    729     end
    730 
    731     -- Call the window's input callback if it exists
    732     if win.onInput then
    733         local success, err = pcall(function()
    734             win.onInput(key, scancode)
    735         end)
    736 
    737         if not success and osprint then
    738             osprint("ERROR: Input callback failed: " .. tostring(err) .. "\n")
    739             return false
    740         end
    741 
    742         return true
    743     else
    744         return false
    745     end
    746 end
    747 
    748 ---Register an application in the app registry
    749 ---@param appName string Application name
    750 ---@param appPath string Path to application
    751 function sys.registerAppInRegistry(appName, appPath)
    752     sys.appRegistry[appName] = appPath
    753 end
    754 
    755 ---Get application path from registry
    756 ---@param appName string Application name
    757 ---@return string|nil Application path
    758 function sys.getAppPath(appName)
    759     return sys.appRegistry[appName]
    760 end
    761 
    762 ---Add a permission to a running application (admin only)
    763 ---@param pid number Process ID
    764 ---@param permission string Permission to add
    765 ---@return boolean True if successful
    766 function sys.ADMIN_AppAddPermission(pid, permission)
    767     local app = sys.applications[pid]
    768     if not app then
    769         error("Application with PID " .. pid .. " not found")
    770     end
    771 
    772     if not app.permissions then
    773         app.permissions = {}
    774     end
    775 
    776     -- Check if permission already exists
    777     for _, perm in ipairs(app.permissions) do
    778         if perm == permission then
    779             -- Already has this permission
    780             return true
    781         end
    782     end
    783 
    784     -- Add the permission
    785     table.insert(app.permissions, permission)
    786 
    787 
    788     -- Re-apply permissions to the sandbox environment if it exists
    789     local env = sys.environments[pid]
    790     if env and env._reapplyPermissions then
    791         env._reapplyPermissions()
    792     end
    793 
    794     return true
    795 end
    796 
    797 ---Add an allowed path to a running application (admin only)
    798 ---@param pid number Process ID
    799 ---@param path string Path pattern to add (e.g., "$/data/*", "/tmp/*")
    800 ---@return boolean True if successful
    801 function sys.ADMIN_AppAddPath(pid, path)
    802     local app = sys.applications[pid]
    803     if not app then
    804         error("Application with PID " .. pid .. " not found")
    805     end
    806 
    807     if not app.allowedPaths then
    808         app.allowedPaths = {}
    809     end
    810 
    811     -- Check if path already exists
    812     for _, p in ipairs(app.allowedPaths) do
    813         if p == path then
    814             -- Already has this path
    815             return true
    816         end
    817     end
    818 
    819     -- Add the path
    820     table.insert(app.allowedPaths, path)
    821 
    822 
    823     -- Update the SafeFS instance if it exists in the environment
    824     local env = sys.environments[pid]
    825     if env and env.fs and env.fs._updateAllowedPaths then
    826         env.fs._updateAllowedPaths(app.allowedPaths)
    827     end
    828 
    829     return true
    830 end
    831 
    832 ---Start exclusive prompt mode (admin only)
    833 ---Only the specified window will be drawn and receive events
    834 ---All other apps are frozen until ADMIN_FinishPrompt is called
    835 ---@param window table The window to display exclusively
    836 ---@return boolean True if successful
    837 function sys.ADMIN_StartPrompt(window)
    838     if not window then
    839         error("ADMIN_StartPrompt: window parameter required")
    840     end
    841 
    842     -- Verify window has an appInstance with PID
    843     if not window.appInstance or not window.appInstance.pid then
    844         error("ADMIN_StartPrompt: window must have valid appInstance with PID")
    845     end
    846 
    847     sys.promptMode.active = true
    848     sys.promptMode.window = window
    849     sys.promptMode.pid = window.appInstance.pid
    850 
    851     -- Force the prompt window to be visible and on top
    852     window.visible = true
    853     window.alwaysOnTop = true
    854     window.dirty = true
    855 
    856 
    857     return true
    858 end
    859 
    860 ---Finish exclusive prompt mode (admin only)
    861 ---Re-enables drawing and events for all applications
    862 ---@return boolean True if successful
    863 function sys.ADMIN_FinishPrompt()
    864     if not sys.promptMode.active then
    865         return false
    866     end
    867 
    868     local prev_pid = sys.promptMode.pid
    869 
    870     sys.promptMode.active = false
    871     sys.promptMode.window = nil
    872     sys.promptMode.pid = nil
    873 
    874 
    875     -- Mark all windows as dirty to force redraw
    876     for pid, app in pairs(sys.applications) do
    877         if app.windows then
    878             for _, window in ipairs(app.windows) do
    879                 window.dirty = true
    880             end
    881         end
    882     end
    883 
    884     return true
    885 end
    886 
    887 ---Internal function to add allowed path (used by Dialog system only)
    888 ---This is not exposed to sandboxed apps
    889 ---@param app table Application instance
    890 ---@param path string Path pattern to add
    891 ---@param fs table SafeFS instance to update
    892 ---@return boolean True if successful
    893 function sys._internal_addAllowedPath(app, path, fs)
    894     if not app then
    895         return false, "Invalid app instance"
    896     end
    897 
    898     if not app.allowedPaths then
    899         app.allowedPaths = {}
    900     end
    901 
    902     -- Check if path already exists
    903     for _, p in ipairs(app.allowedPaths) do
    904         if p == path then
    905             -- Already has this path
    906             return true
    907         end
    908     end
    909 
    910     -- Add the path
    911     table.insert(app.allowedPaths, path)
    912 
    913 
    914     -- Update the SafeFS instance by adding the new path to its allowed roots
    915     if fs then
    916         -- Load SafeFS module to access the new() function
    917         local SafeFS = require or package.loaded.SafeFS
    918         if not SafeFS and _G.SafeFS then
    919             SafeFS = _G.SafeFS
    920         end
    921 
    922         if SafeFS and fs.rootNode and fs.allowedRoots then
    923             -- Get the filesystem root
    924             local fsRoot = fs.rootNode
    925 
    926             -- Resolve $ in path
    927             local app_dir = app.appPath or ""
    928             local resolved_path = string.gsub(path, "^%$", app_dir)
    929 
    930             -- Remove /* suffix if present
    931             local cleanPath = resolved_path
    932             if cleanPath:sub(-2) == "/*" then
    933                 cleanPath = cleanPath:sub(1, -3)
    934             end
    935 
    936             -- Find node in original filesystem using traverse or manual traversal
    937             local function findNode(rootNode, targetPath)
    938                 -- Check if rootNode has a traverse function (C-based filesystem)
    939                 if type(rootNode) == "table" and type(rootNode.traverse) == "function" then
    940                     local node, err = rootNode:traverse(targetPath)
    941                     return node, err
    942                 end
    943                 return nil, "Cannot traverse filesystem"
    944             end
    945 
    946             local node, err = findNode(fsRoot, cleanPath)
    947             if node and node.type == "directory" then
    948                 -- Deep copy the node (simplified version)
    949                 local function deepCopyNode(srcNode, parentNode)
    950                     if not srcNode then return nil end
    951 
    952                     local copy = {
    953                         name = srcNode.name,
    954                         type = srcNode.type,
    955                         parent = parentNode
    956                     }
    957 
    958                     if srcNode.type == "directory" then
    959                         copy.dirs = {}
    960                         copy.files = {}
    961 
    962                         if srcNode.dirs then
    963                             for _, dir in ipairs(srcNode.dirs) do
    964                                 local dirCopy = deepCopyNode(dir, copy)
    965                                 if dirCopy then
    966                                     table.insert(copy.dirs, dirCopy)
    967                                 end
    968                             end
    969                         end
    970 
    971                         if srcNode.files then
    972                             for _, file in ipairs(srcNode.files) do
    973                                 local fileCopy = {
    974                                     name = file.name,
    975                                     type = "file",
    976                                     parent = copy,
    977                                     content = file.content
    978                                 }
    979                                 table.insert(copy.files, fileCopy)
    980                             end
    981                         end
    982                     elseif srcNode.type == "file" then
    983                         copy.content = srcNode.content
    984                     end
    985 
    986                     return copy
    987                 end
    988 
    989                 local isolatedNode = deepCopyNode(node, nil)
    990                 if isolatedNode then
    991                     isolatedNode.name = cleanPath
    992                     fs.roots[cleanPath] = isolatedNode
    993                     table.insert(fs.allowedRoots, cleanPath)
    994                     return true
    995                 else
    996                     return false, "Failed to copy directory node"
    997                 end
    998             else
    999                 return false, "Directory not found: " .. cleanPath
   1000             end
   1001         end
   1002     end
   1003 
   1004     return true
   1005 end
   1006 
   1007 ---Open a file using the registered file handler for its extension
   1008 ---If the handler app is not running, it will be launched
   1009 ---@param filepath string The full path to the file to open
   1010 ---@return boolean success True if handler was found and called
   1011 ---@return string|nil error Error message if failed
   1012 function sys.openFile(filepath)
   1013     if not filepath or type(filepath) ~= "string" then
   1014         return false, "Invalid filepath"
   1015     end
   1016 
   1017     -- Extract extension from filepath
   1018     local extension = filepath:match("%.([^%.]+)$")
   1019     if not extension then
   1020         return false, "No file extension found"
   1021     end
   1022 
   1023     extension = extension:lower()
   1024 
   1025     -- Get the SafeFS module to access the file handler registry
   1026     -- SafeFS should be loaded globally or we need to load it
   1027     local SafeFS = _G.SafeFS
   1028     if not SafeFS then
   1029         -- Try to load SafeFS module
   1030         local safefs_path = "/os/libs/SafeFS.lua"
   1031         if CRamdiskOpen and CRamdiskRead and CRamdiskClose then
   1032             local handle = CRamdiskOpen(safefs_path, "r")
   1033             if handle then
   1034                 local code = CRamdiskRead(handle)
   1035                 CRamdiskClose(handle)
   1036                 if code then
   1037                     local safefs_func, err = loadstring(code, safefs_path)
   1038                     if safefs_func then
   1039                         local success, module = pcall(safefs_func)
   1040                         if success then
   1041                             SafeFS = module
   1042                             _G.SafeFS = module  -- Cache it globally
   1043                         end
   1044                     end
   1045                 end
   1046             end
   1047         end
   1048     end
   1049 
   1050     if not SafeFS or not SafeFS.getFileHandler then
   1051         return false, "SafeFS module not available"
   1052     end
   1053 
   1054     -- Look up the handler for this extension
   1055     local handler = SafeFS.getFileHandler(extension)
   1056     if not handler then
   1057         return false, "No handler registered for ." .. extension
   1058     end
   1059 
   1060     local appId = handler.appId
   1061     local functionName = handler.functionName
   1062 
   1063     if osprint then
   1064         osprint("[sys.openFile] Opening " .. filepath .. " with " .. appId .. ":" .. functionName .. "\n")
   1065     end
   1066 
   1067     -- Find the app in running applications
   1068     local targetApp = nil
   1069     local targetEnv = nil
   1070 
   1071     for pid, app in pairs(sys.applications) do
   1072         -- Check if app path contains the app ID
   1073         if app.appPath and app.appPath:find(appId, 1, true) then
   1074             targetApp = app
   1075             targetEnv = sys.environments[pid]
   1076             break
   1077         end
   1078     end
   1079 
   1080     -- If app is not running, launch it
   1081     if not targetApp then
   1082         if osprint then
   1083             osprint("[sys.openFile] App " .. appId .. " not running, launching...\n")
   1084         end
   1085 
   1086         -- Use the run module to launch the app
   1087         if _G.run and _G.run.execute then
   1088             local success, app = _G.run.execute(appId, _G.fsRoot)
   1089             if success and app then
   1090                 targetApp = app
   1091                 targetEnv = sys.environments[app.pid]
   1092             else
   1093                 return false, "Failed to launch app: " .. appId
   1094             end
   1095         else
   1096             return false, "Run module not available to launch app"
   1097         end
   1098     end
   1099 
   1100     -- Now call the registered function on the app's environment
   1101     if targetEnv then
   1102         local func = targetEnv[functionName]
   1103         if func and type(func) == "function" then
   1104             local success, err = pcall(func, filepath)
   1105             if not success then
   1106                 return false, "Handler function error: " .. tostring(err)
   1107             end
   1108             return true
   1109         else
   1110             return false, "Function '" .. functionName .. "' not found or not a function in app " .. appId
   1111         end
   1112     else
   1113         return false, "Could not get environment for app " .. appId
   1114     end
   1115 end
   1116 
   1117 -- Browser interface for HTML windows
   1118 -- Loaded on demand when first accessed
   1119 sys.browser = {
   1120     _htmlWindowModule = nil,
   1121 
   1122     -- Load HTMLWindow module on demand
   1123     _loadModule = function(self)
   1124         if self._htmlWindowModule then
   1125             return self._htmlWindowModule
   1126         end
   1127 
   1128         local htmlWindowPath = "/os/libs/HTMLWindow.luac"
   1129         local htmlWindowCode = nil
   1130 
   1131         if CRamdiskExists and CRamdiskExists(htmlWindowPath) then
   1132             local handle = CRamdiskOpen(htmlWindowPath, "r")
   1133             if handle then
   1134                 htmlWindowCode = CRamdiskRead(handle)
   1135                 CRamdiskClose(handle)
   1136             end
   1137         end
   1138 
   1139         if not htmlWindowCode then
   1140             htmlWindowPath = "/os/libs/HTMLWindow.lua"
   1141             if CRamdiskExists and CRamdiskExists(htmlWindowPath) then
   1142                 local handle = CRamdiskOpen(htmlWindowPath, "r")
   1143                 if handle then
   1144                     htmlWindowCode = CRamdiskRead(handle)
   1145                     CRamdiskClose(handle)
   1146                 end
   1147             end
   1148         end
   1149 
   1150         if not htmlWindowCode then
   1151             return nil, "HTMLWindow module not found"
   1152         end
   1153 
   1154         local env = setmetatable({}, { __index = _G, __metatable = false })
   1155         local func, err = loadstring(htmlWindowCode, "HTMLWindow")
   1156         if not func then
   1157             return nil, "Failed to compile HTMLWindow: " .. tostring(err)
   1158         end
   1159         setfenv(func, env)
   1160         local ok, module = pcall(func)
   1161         if not ok then
   1162             return nil, "Failed to execute HTMLWindow: " .. tostring(module)
   1163         end
   1164 
   1165         self._htmlWindowModule = module
   1166         return module
   1167     end,
   1168 
   1169     --- Create a new HTML window for an app
   1170     -- @param options Table with:
   1171     --   - app: Application instance (required)
   1172     --   - html: HTML string to render (either html or file required)
   1173     --   - file: Path to HTML file (either html or file required)
   1174     --   - fs: SafeFS instance for file access
   1175     --   - width: Window width (default 800)
   1176     --   - height: Window height (default 600)
   1177     --   - title: Window title
   1178     --   - Dialog: Dialog module for alert/confirm/prompt
   1179     --   - loadstring: loadstring function for onclick handlers
   1180     --   - showStatusBar: Show status bar (default false)
   1181     --   - onSubmit: Callback for form submissions
   1182     -- @return HTMLWindow object or nil, error
   1183     newHTMLWindow = function(self, options)
   1184         local module, err = self:_loadModule()
   1185         if not module then
   1186             return nil, err
   1187         end
   1188         return module.new(options)
   1189     end,
   1190 }
   1191 
   1192 -- Cursor API (sys.cursor.add, sys.cursor.remove, etc.)
   1193 sys.cursor = {
   1194     -- Add a global cursor drawing function to the stack
   1195     -- @param draw_func function(state, gfx) - Called with cursor state and SafeGfx for 30x30 cursor buffer
   1196     --   state: "cursor", "left-click", "right-click", "grab", "loading", "denied"
   1197     --   gfx: SafeGfx with clear(), fillRect(), drawRect(), drawPixel(), drawLine(), getWidth(), getHeight()
   1198     --   Use color 0x00FF00 (green) for transparent pixels
   1199     -- @return number Index in the cursor stack for removal
   1200     add = function(draw_func)
   1201         if _G._cursor_api and _G._cursor_api.add then
   1202             return _G._cursor_api.add(draw_func)
   1203         end
   1204         error("Cursor API not available")
   1205     end,
   1206 
   1207     -- Remove a global cursor drawing function from stack
   1208     -- @param index number The index returned by add()
   1209     -- @return boolean True if removed successfully
   1210     remove = function(index)
   1211         if _G._cursor_api and _G._cursor_api.remove then
   1212             return _G._cursor_api.remove(index)
   1213         end
   1214         return false
   1215     end,
   1216 
   1217     -- Pop the top global cursor from the stack
   1218     -- @return boolean True if a cursor was popped
   1219     pop = function()
   1220         if _G._cursor_api and _G._cursor_api.pop then
   1221             return _G._cursor_api.pop()
   1222         end
   1223         return false
   1224     end,
   1225 
   1226     -- Set the current cursor mode (triggers redraw)
   1227     -- @param mode string One of: "cursor", "left-click", "right-click", "grab", "loading", "denied"
   1228     setMode = function(mode)
   1229         if _G._cursor_api and _G._cursor_api.setMode then
   1230             _G._cursor_api.setMode(mode)
   1231         end
   1232     end,
   1233 
   1234     -- Get the current cursor mode
   1235     -- @return string Current cursor mode
   1236     getMode = function()
   1237         if _G.cursor_state then
   1238             return _G.cursor_state.mode or "cursor"
   1239         end
   1240         return "cursor"
   1241     end
   1242 }
   1243 
   1244 return sys