Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
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