Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
at main 598 lines 22 kB view raw
1-- A general architecture for free-wheeling, live programs: 2-- on startup: 3-- scan both the app directory and the save directory for files with numeric prefixes 4-- load files in order 5-- 6-- then start drawing frames on screen and reacting to events 7-- 8-- events from keyboard and mouse are handled as the app desires 9-- 10-- on incoming messages to a specific file, the app must: 11-- determine the definition name from the first word 12-- execute the value, returning any errors 13-- look up the filename for the definition or define a new filename for it 14-- save the message's value to the filename 15-- 16-- if a game encounters a run-time error, send it to the driver and await 17-- further instructions. The app will go unresponsive in the meantime, that 18-- is expected. To shut it down cleanly, type C-q in the driver. 19 20-- We try to save new definitions in the source directory, but this is not 21-- possible if the app lives in a .love file. In that case new definitions 22-- go in the save dir. 23 24local json = require 'json' 25local Major_version, _ = love.getVersion() 26Major_version = tonumber(Major_version) 27if Major_version < 12 then 28 local nativefs = require 'nativefs' 29else 30 love.filesystem.mountFullPath(love.filesystem.getSource()..'/defs', 'mnt', 'readwrite') 31end 32 33-- namespace for these functions 34local live = {} 35local I = {} 36live.internal = I 37 38-- state for these functions 39local Live = {} 40 41-- a namespace of frameworky callbacks 42-- these will be modified live 43on = {} 44 45-- === on startup, load all files with numeric prefix 46 47function live.load() 48 if Live.frozen_definitions == nil then -- a second run due to initialization errors will contain definitions we don't want to freeze 49 live.freeze_all_existing_definitions() 50 end 51 52 -- some hysteresis 53 Live.previous_read = 0 54 55 -- version control 56 Live.filenames_to_load = {} -- filenames in order of numeric prefix 57 Live.filename = {} -- map from definition name to filename (including numeric prefix) 58 Live.final_prefix = 0 59 live.load_files_so_far() 60end 61 62function live.load_files_so_far() 63 for _,filename in ipairs(love.filesystem.getDirectoryItems('defs')) do 64 local numeric_prefix, root = filename:match('^(%d+)-(.+)') 65 if numeric_prefix and tonumber(numeric_prefix) > 0 then -- skip 0000 66 Live.filename[root] = filename 67 table.insert(Live.filenames_to_load, filename) 68 Live.final_prefix = math.max(Live.final_prefix, tonumber(numeric_prefix)) 69 end 70 end 71 table.sort(Live.filenames_to_load) 72 -- load files from save dir 73 for _,filename in ipairs(Live.filenames_to_load) do 74--? print('loading', filename) 75 local buf = love.filesystem.read('defs/'..filename) 76 assert(buf and buf ~= '') 77 local _, definition_name = filename:match('^(%d+)-(.+)') 78 local status, err = live.eval(buf, definition_name) 79 if not status then 80 error(err) 81 end 82 end 83end 84 85 86local APP = 'fw_app' 87 88-- === on each frame, check for messages and alter the app as needed 89 90function live.update(dt) 91 if Current_time - Live.previous_read > 0.1 then 92 local buf = live.receive_from_driver() 93 if buf then 94 local possibly_mutated = live.run(buf) 95 if possibly_mutated then 96 if on.code_change then on.code_change() end 97 end 98 end 99 Live.previous_read = Current_time 100 end 101end 102 103-- look for a message from outside, and return nil if there's nothing 104function live.receive_from_driver() 105 local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app') 106 if f == nil then return nil end 107 local result = f:read('*a') 108 f:close() 109 if result == '' then return nil end -- empty file == no message 110 print('<=' .. I.color_escape(--[[bold]]1, --[[blue]]4)) 111 print(result) 112 print(I.reset_terminal()) 113 os.remove(love.filesystem.getAppdataDirectory()..'/_love_akkartik_driver_app') 114 return result 115end 116 117function live.send_to_driver(msg) 118 local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver', 'w') 119 if f == nil then return end 120 f:write(msg) 121 f:close() 122 print('=>' .. I.color_escape(0, --[[green]]2)) 123 print(msg) 124 print(I.reset_terminal()) 125end 126 127function live.send_run_time_error_to_driver(msg) 128 local f = io.open(love.filesystem.getAppdataDirectory()..'/_love_akkartik_app_driver_run_time_error', 'w') 129 if f == nil then return end 130 f:write(msg) 131 f:close() 132 print('=>' .. I.color_escape(0, --[[red]]1)) 133 print(msg) 134 print(I.reset_terminal()) 135end 136 137-- args: 138-- format: 0 for normal, 1 for bold 139-- color: 0-15 140function I.color_escape(format, color) 141 return ('\027[%d;%dm'):format(format, 30+color) 142end 143 144function I.reset_terminal() 145 return '\027[m' 146end 147 148-- returns true if we might have mutated the app, by either creating or deleting a definition 149function live.run(buf) 150 local cmd = live.get_cmd_from_buffer(buf) 151 assert(cmd) 152 print('command is '..cmd) 153 if cmd == 'QUIT' then 154 love.event.quit(1) 155 elseif cmd == 'RESTART' then 156 restart() 157 elseif cmd == 'MANIFEST' then 158 Live.filename[APP] = love.filesystem.getIdentity() 159 live.send_to_driver(json.encode(Live.filename)) 160 elseif cmd == 'DELETE' then 161 local definition_name = buf:match('^%s*%S+%s+(%S+)') 162 if Live.frozen_definitions[definition_name] then 163 live.send_to_driver('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be deleted.') 164 return 165 end 166 if Live.filename[definition_name] then 167 local index = table.find(Live.filenames_to_load, Live.filename[definition_name]) 168 table.remove(Live.filenames_to_load, index) 169 live.eval(definition_name..' = nil', 'driver') -- ignore errors which will likely be from keywords like `function = nil` 170 -- try to remove the file from both source_dir and save_dir 171 -- this won't work for files inside .love files 172 if Major_version < 12 then 173 nativefs.remove(love.filesystem.getSource()..'/defs/'..Live.filename[definition_name]) 174 else 175 love.filesystem.remove('mnt/'..Live.filename[definition_name]) -- source dir 176 end 177 love.filesystem.remove('defs/'..Live.filename[definition_name]) -- save dir 178 Live.filename[definition_name] = nil 179 end 180 live.send_to_driver('{}') 181 return true 182 elseif cmd == 'GET' then 183 local definition_name = buf:match('^%s*%S+%s+(%S+)') 184 local val, _ = live.get_binding(definition_name) 185 if val then 186 live.send_to_driver(val) 187 else 188 live.send_to_driver('ERROR no such value') 189 end 190 elseif cmd == 'GET*' then 191 -- batch version of GET 192 local result = {} 193 for definition_name in buf:gmatch('%s+(%S+)') do 194 print(definition_name) 195 local val, _ = live.get_binding(definition_name) 196 if val then 197 table.insert(result, val) 198 end 199 end 200 local delimiter = '\n==fw: definition boundary==\n' 201 live.send_to_driver(table.concat(result, delimiter)..delimiter) -- send a final delimiter to simplify the driver's task 202 elseif cmd == 'DEFAULT_MAP' then 203 local contents = love.filesystem.read('default_map') 204 if contents == nil then contents = '{}' end 205 live.send_to_driver(contents) 206 -- other commands go here 207 else 208 local definition_name = live.get_definition_name_from_buffer(buf) 209 if definition_name == nil then 210 -- contents are all Lua comments; we don't currently have a plan for them 211 live.send_to_driver('ERROR empty definition') 212 return 213 end 214 print('definition name is '..definition_name) 215 if Live.frozen_definitions[definition_name] then 216 live.send_to_driver('ERROR definition '..definition_name..' is part of Freewheeling infrastructure and cannot be safely edited live.') 217 return 218 end 219 local status, err = live.eval(buf, definition_name) 220 if not status then 221 -- throw an error 222 live.send_to_driver('ERROR ' .. I.cleaned_up_frame(tostring(err))) 223 return 224 end 225 -- eval succeeded without errors; persist the definition 226 local filename = Live.filename[definition_name] 227 if filename == nil then 228 Live.final_prefix = Live.final_prefix+1 229 filename = ('%04d-%s'):format(Live.final_prefix, definition_name) 230 table.insert(Live.filenames_to_load, filename) 231 Live.filename[definition_name] = filename 232 end 233 -- try to write to source dir 234 local status, err 235 if Major_version < 12 then 236 status, err = nativefs.write(love.filesystem.getSource()..'/defs/'..filename, buf) 237 else 238 status, err = love.filesystem.write('mnt/'..filename, buf) 239 end 240 if err then 241 -- not possible; perhaps it's a .love file 242 -- try to write to save dir 243 local success = love.filesystem.createDirectory('defs/') 244 local status, err2 = love.filesystem.write('defs/'..filename, buf) 245 if err2 then 246 -- throw an error 247 live.send_to_driver('ERROR '..tostring(err..'\n\n'..err2)) 248 return true 249 end 250 end 251 -- no support for tests 252 live.send_to_driver('{}') 253 return true 254 end 255end 256 257function live.get_cmd_from_buffer(buf) 258 -- return the first word 259 return buf:match('^%s*(%S+)') 260end 261 262function live.get_definition_name_from_buffer(buf) 263 return I.first_noncomment_word(buf) 264end 265 266-- return the first word (separated by whitespace) that's not in a Lua comment 267-- or empty string if there's nothing 268-- ignore strings; we don't expect them to be the first word in a program 269function I.first_noncomment_word(str) 270 local pos = 1 271 while pos <= #str do -- not Unicode-aware; hopefully it doesn't need to be 272 if str:sub(pos,pos) == '-' then 273 -- skip any comments 274 if str:sub(pos+1,pos+1) == '-' then 275 -- definitely start of a comment 276 local long_comment_header = str:match('^%[=*%[', pos+2) 277 if long_comment_header then 278 -- long comment 279 local long_comment_trailer = long_comment_header:gsub('%[', ']') 280 pos = str:find(long_comment_trailer, pos, --[[plain]]true) 281 if pos == nil then return '' end -- incomplete comment; no first word 282 pos = pos + #long_comment_trailer 283 else 284 -- line comment 285 pos = str:find('\n', pos) 286 if pos == nil then return '' end -- incomplete comment; no first word 287 end 288 end 289 end 290 -- any non-whitespace that's not a comment is the first word 291 if str:sub(pos,pos):match('%s') then 292 pos = pos+1 293 else 294 return str:match('^%S*', pos) 295 end 296 end 297 return '' 298end 299 300-- TODO: currently never runs 301function I.test_first_noncomment_word() 302 check_eq(I.first_noncomment_word(''), '', 'empty string') 303 check_eq(I.first_noncomment_word('abc'), 'abc', 'single word') 304 check_eq(I.first_noncomment_word('abc def'), 'abc', 'stop at space') 305 check_eq(I.first_noncomment_word('abc\tdef'), 'abc', 'stop at tab') 306 check_eq(I.first_noncomment_word('abc\ndef'), 'abc', 'stop at newline') 307 check_eq(I.first_noncomment_word('-- abc\ndef'), 'def', 'ignore line comment') 308 check_eq(I.first_noncomment_word('--[[abc]] def'), 'def', 'ignore block comment') 309 check_eq(I.first_noncomment_word('--[[abc\n]] def'), 'def', 'ignore multi-line block comment') 310 check_eq(I.first_noncomment_word('--[[abc\n--]] def'), 'def', 'ignore comment leader before block comment trailer') 311 check_eq(I.first_noncomment_word('--[=[abc]=] def'), 'def', 'ignore long comment') 312 check_eq(I.first_noncomment_word('--[=[abc]] def ]=] ghi'), 'ghi', 'ignore long comment containing block comment trailer') 313 check_eq(I.first_noncomment_word('--[===[abc\n\ndef ghi\njkl]===]mno\npqr'), 'mno', 'ignore long comment containing block comment trailer') 314 check_eq(I.first_noncomment_word('-'), '-', 'incomplete comment token') 315 check_eq(I.first_noncomment_word('--abc'), '', 'incomplete line comment') 316 check_eq(I.first_noncomment_word('--abc\n'), '', 'just a line comment') 317 check_eq(I.first_noncomment_word('--abc\n '), '', 'just a line comment 2') 318 check_eq(I.first_noncomment_word('--[ab\n'), '', 'incomplete block comment token is a line comment') 319 check_eq(I.first_noncomment_word('--[[ab'), '', 'incomplete block comment') 320 check_eq(I.first_noncomment_word('--[[ab\n]'), '', 'incomplete block comment 2') 321 check_eq(I.first_noncomment_word('--[=[ab\n]] ]='), '', 'incomplete block comment 3') 322 check_eq(I.first_noncomment_word('--[=[ab\n]] ]=]'), '', 'just a block comment') 323 check_eq(I.first_noncomment_word('--[=[ab\n]] ]=] \n \n '), '', 'just a block comment 2') 324end 325 326function live.get_binding(name) 327 if Live.filename[name] then 328 return love.filesystem.read('defs/'..Live.filename[name]) 329 end 330end 331 332function table.find(h, x) 333 for k,v in pairs(h) do 334 if v == x then 335 return k 336 end 337 end 338end 339 340-- Wrapper for Lua's weird evaluation model. 341-- Lua is persnickety about expressions vs statements, so we need to do some 342-- extra work to get the result of an evaluation. 343-- filename will show up in call stacks for any error messages 344-- return values: 345-- all well -> true, ... 346-- load failed -> nil, error message 347-- run (pcall) failed -> false, error message 348function live.eval(buf, filename) 349 -- We assume a program is either correct with 'return' prefixed xor not. 350 -- Is this correct? Who knows! But the Lua REPL does this as well. 351 local f = load('return '..buf, filename or 'REPL') 352 if f then 353 return pcall(f) 354 end 355 local f, err = load(buf, filename or 'REPL') 356 if f then 357 return pcall(f) 358 else 359 return nil, err 360 end 361end 362 363-- === infrastructure for performing safety checks on any new definition 364 365-- Everything that exists before we start loading the live files is frozen and 366-- can't be edited live. 367function live.freeze_all_existing_definitions() 368 Live.frozen_definitions = {on=true} -- special case for version 1 369 local done = {} 370 done[Live.frozen_definitions]=true 371 live.freeze_all_existing_definitions_in(_G, {}, done) 372end 373 374function live.freeze_all_existing_definitions_in(tab, scopes, done) 375 -- track duplicates to avoid cycles like _G._G, _G._G._G, etc. 376 if done[tab] then return end 377 done[tab] = true 378 for name,binding in pairs(tab) do 379 local full_name = live.full_name(scopes, name) 380--? print(full_name) 381 Live.frozen_definitions[full_name] = true 382 if type(binding) == 'table' and full_name ~= 'package' then -- var 'package' contains copies of all modules, but not the best name; rely on people to not modify package.loaded.io.open, etc. 383 table.insert(scopes, name) 384 live.freeze_all_existing_definitions_in(binding, scopes, done) 385 table.remove(scopes) 386 end 387 end 388end 389 390function live.full_name(scopes, name) 391 local ns = table.concat(scopes, '.') 392 if #ns == 0 then return name end 393 return ns..'.'..name 394end 395 396-- === on error, pause the app and wait for messages 397 398local main_run_frame = nil 399 400-- one iteration of the event loop when showing an error 401-- return nil to continue the event loop, non-nil to quit 402-- We don't run this within handle_error because a second error in 403-- handle_error will crash. 404local error_frame_keys_down = {} 405function error_run_frame() 406 if love.event then 407 love.event.pump() 408 for name, a,b,c,d,e,f in love.event.poll() do 409 if name == 'quit' then 410 return a or 0 411 elseif name == 'keypressed' then 412 error_frame_keys_down[a] = true 413 -- C-c 414 if a == 'c' and (error_frame_keys_down.lctrl or error_frame_keys_down.rctrl) then 415 love.system.setClipboardText(Error_message) 416 end 417 elseif name == 'keyreleased' then 418 if not Disallow_error_recovery_on_key_release then 419 error_frame_keys_down[a] = nil 420 run_frame = main_run_frame 421 return 422 end 423 end 424 end 425 end 426 427 local dt = love.timer.step() 428 Current_time = Current_time + dt 429 if Current_time - Live.previous_read > 0.1 then 430 local buf = live.receive_from_driver() 431 if buf then 432 local maybe_modified = live.run(buf) 433 if maybe_modified then 434 -- retry 435 run_frame = main_run_frame 436 return 437 end 438 end 439 Live.previous_read = Current_time 440 end 441 442 love.graphics.origin() 443 love.graphics.clear(0,0,1) 444 love.graphics.setColor(1,1,1) 445 love.graphics.printf(Error_message, 40,40, 600) 446 love.graphics.present() 447 448 love.timer.sleep(0.001) 449 450 -- returning nil continues the loop 451end 452 453-- return nil to continue the event loop, non-nil to quit 454function live.handle_error(err) 455 love.graphics.setCanvas() -- undo any canvas we happened to be within, otherwise LÖVE seizes up 456 local cleaned_up_error = err 457 if not err:match('stack overflow') then 458 local callstack = debug.traceback('', --[[stack frame]]2) 459 cleaned_up_error = 'Error: ' .. I.cleaned_up_frame(tostring(err)) .. '\n' 460 .. I.cleaned_up_callstack(callstack) 461 else 462 -- call only primitive functions when we're out of stack space 463 end 464 live.send_run_time_error_to_driver(cleaned_up_error) 465 love.graphics.setFont(love.graphics.newFont(20)) 466 Error_message = 'Something is wrong. Sorry!\n\n'..cleaned_up_error..'\n\n'.. 467 'Options:\n'.. 468 '- press "ctrl+c" (without the quotes) to copy this message to your clipboard to send to me: ak@akkartik.com\n'.. 469 '- press any other key to retry, see if things start working again\n'.. 470 '- run driver.love to try to fix it yourself. As you do, feel free to ask me questions: ak@akkartik.com\n' 471 Error_count = Error_count+1 472 if Error_count > 1 then 473 Error_message = Error_message..('\n\nThis is error #%d in this session; things will probably not improve in this session. Please copy the message and send it to me: ak@akkartik.com.'):format(Error_count) 474 end 475 print(Error_message) 476 love.graphics.setCanvas() -- can't pump event loop while drawing to a canvas 477 error_frame_keys_down = {} 478 if main_run_frame == nil then 479 main_run_frame = run_frame 480 end 481 run_frame = error_run_frame 482end 483 484-- one iteration of the event loop when showing an error 485-- return nil to continue the event loop, non-nil to quit 486-- We don't run this within handle_error because a second error in 487-- handle_error will crash. 488function initialization_error_run_frame() 489 if love.event then 490 love.event.pump() 491 for name, a,b,c,d,e,f in love.event.poll() do 492 if name == 'quit' then 493 return a or 0 494 elseif name == 'keypressed' then 495 error_frame_keys_down[a] = true 496 -- C-c 497 if a == 'c' and (error_frame_keys_down.lctrl or error_frame_keys_down.rctrl) then 498 love.system.setClipboardText(Error_message) 499 end 500 elseif name == 'keyreleased' then 501 -- don't try to recover from initialization errors 502 error_frame_keys_down[a] = nil 503 end 504 end 505 end 506 507 local dt = love.timer.step() 508 Current_time = Current_time + dt 509 if Current_time - Live.previous_read > 0.1 then 510 local buf = live.receive_from_driver() 511 if buf then 512 local maybe_modified = live.run(buf) 513 if maybe_modified then 514 -- retry after initialization 515 local success = xpcall(function() love.load(love.arg.parseGameArguments(arg), arg) end, live.handle_initialization_error) 516 if success then 517 run_frame = main_run_frame 518 return 519 end 520 end 521 end 522 Live.previous_read = Current_time 523 end 524 525 love.graphics.origin() 526 love.graphics.clear(0,0,1) 527 love.graphics.setColor(1,1,1) 528 love.graphics.printf(Error_message, 40,40, 600) 529 love.graphics.present() 530 531 love.timer.sleep(0.001) 532 533 -- returning nil continues the loop 534end 535 536function live.handle_initialization_error(err) 537 local cleaned_up_error = err 538 if not err:match('stack overflow') then 539 local callstack = debug.traceback('', --[[stack frame]]2) 540 cleaned_up_error = 'Error: ' .. I.cleaned_up_frame(tostring(err)) .. '\n' 541 .. I.cleaned_up_callstack(callstack) 542 else 543 -- call only primitive functions when we're out of stack space 544 end 545 live.send_run_time_error_to_driver(cleaned_up_error) 546 love.graphics.setFont(love.graphics.newFont(20)) 547 Error_message = 'Something is wrong. Sorry!\n\n'..cleaned_up_error..'\n\n'.. 548 'Options:\n'.. 549 '- press "ctrl+c" (without the quotes) to copy this message to your clipboard to send to me: ak@akkartik.com\n'.. 550 '- run driver.love to try to fix it yourself. As you do, feel free to ask me questions: ak@akkartik.com\n' 551 Error_count = Error_count+1 552 if Error_count > 1 then 553 Error_message = Error_message..('\n\nThis is error #%d in this session; things will probably not improve in this session. Please copy the message and send it to me: ak@akkartik.com.'):format(Error_count) 554 end 555 print(Error_message) 556 love.graphics.setCanvas() -- can't pump event loop while drawing to a canvas 557 error_frame_keys_down = {} 558 if main_run_frame == nil then 559 main_run_frame = run_frame 560 end 561 run_frame = initialization_error_run_frame 562end 563 564-- I tend to read code from files myself (say using love.filesystem calls) 565-- rather than offload that to load(). 566-- Functions compiled in this manner have ugly filenames of the form [string "filename"] 567-- This function cleans out this cruft from error callstacks. 568-- It also strips out the numeric prefixes we introduce in filenames. 569function I.cleaned_up_callstack(callstack) 570 local frames = {} 571 for frame in string.gmatch(callstack, '[^\n]+\n*') do 572 table.insert(frames, I.cleaned_up_frame(frame)) 573 end 574 -- the initial "stack traceback:" line was unindented and remains so 575 return table.concat(frames, '\n\t') 576end 577 578function I.cleaned_up_frame(frame) 579 local line = frame:gsub('^%s*(.-)\n?$', '%1') 580 local filename, rest = line:match('([^:]*):(.*)') 581 if filename then 582 return I.cleaned_up_filename(filename)..':'..rest 583 else 584 return line 585 end 586end 587 588function I.cleaned_up_filename(filename) 589 -- pass through frames that don't match this format 590 -- this includes the initial line "stack traceback:" 591 local core_filename = filename:match('^%[string "(.*)"%]$') 592 if core_filename == nil then return filename end 593 -- strip out the numeric prefixes we introduce in filenames 594 local _, core_filename2 = core_filename:match('^(%d+)-(.+)') 595 return core_filename2 or core_filename 596end 597 598return live