Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
1--[[
2Copyright 2020 megagrump@pm.me
3
4Permission is hereby granted, free of charge, to any person obtaining a copy of
5this software and associated documentation files (the "Software"), to deal in
6the Software without restriction, including without limitation the rights to
7use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
8of the Software, and to permit persons to whom the Software is furnished to do
9so, subject to the following conditions:
10
11The above copyright notice and this permission notice shall be included in all
12copies or substantial portions of the Software.
13
14THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20SOFTWARE.
21]]--
22
23local ffi, bit = require('ffi'), require('bit')
24local C = ffi.C
25
26local File = {
27 getBuffer = function(self) return self._bufferMode, self._bufferSize end,
28 getFilename = function(self) return self._name end,
29 getMode = function(self) return self._mode end,
30 isOpen = function(self) return self._mode ~= 'c' and self._handle ~= nil end,
31}
32
33local fopen, getcwd, chdir, unlink, mkdir, rmdir
34local BUFFERMODE, MODEMAP
35local ByteArray = ffi.typeof('unsigned char[?]')
36local function _ptr(p) return p ~= nil and p or nil end -- NULL pointer to nil
37
38function File:open(mode)
39 if self._mode ~= 'c' then return false, "File " .. self._name .. " is already open" end
40 if not MODEMAP[mode] then return false, "Invalid open mode for " .. self._name .. ": " .. mode end
41
42 local handle = _ptr(fopen(self._name, MODEMAP[mode]))
43 if not handle then return false, "Could not open " .. self._name .. " in mode " .. mode end
44
45 self._handle, self._mode = ffi.gc(handle, C.fclose), mode
46 self:setBuffer(self._bufferMode, self._bufferSize)
47
48 return true
49end
50
51function File:close()
52 if self._mode == 'c' then return false, "File is not open" end
53 C.fclose(ffi.gc(self._handle, nil))
54 self._handle, self._mode = nil, 'c'
55 return true
56end
57
58function File:setBuffer(mode, size)
59 local bufferMode = BUFFERMODE[mode]
60 if not bufferMode then
61 return false, "Invalid buffer mode " .. mode .. " (expected 'none', 'full', or 'line')"
62 end
63
64 if mode == 'none' then
65 size = math.max(0, size or 0)
66 else
67 size = math.max(2, size or 2) -- Windows requires buffer to be at least 2 bytes
68 end
69
70 local success = self._mode == 'c' or C.setvbuf(self._handle, nil, bufferMode, size) == 0
71 if not success then
72 self._bufferMode, self._bufferSize = 'none', 0
73 return false, "Could not set buffer mode"
74 end
75
76 self._bufferMode, self._bufferSize = mode, size
77 return true
78end
79
80function File:getSize()
81 -- NOTE: The correct way to do this would be a stat() call, which requires a
82 -- lot more (system-specific) code. This is a shortcut that requires the file
83 -- to be readable.
84 local mustOpen = not self:isOpen()
85 if mustOpen and not self:open('r') then return 0 end
86
87 local pos = mustOpen and 0 or self:tell()
88 C.fseek(self._handle, 0, 2)
89 local size = self:tell()
90 if mustOpen then
91 self:close()
92 else
93 self:seek(pos)
94 end
95 return size
96end
97
98function File:read(containerOrBytes, bytes)
99 if self._mode ~= 'r' then return nil, 0 end
100
101 local container = bytes ~= nil and containerOrBytes or 'string'
102 if container ~= 'string' and container ~= 'data' then
103 error("Invalid container type: " .. container)
104 end
105
106 bytes = not bytes and containerOrBytes or 'all'
107 bytes = bytes == 'all' and self:getSize() - self:tell() or math.min(self:getSize() - self:tell(), bytes)
108
109 if bytes <= 0 then
110 local data = container == 'string' and '' or love.data.newFileData('', self._name)
111 return data, 0
112 end
113
114 local data = love.data.newByteData(bytes)
115 local r = tonumber(C.fread(data:getFFIPointer(), 1, bytes, self._handle))
116
117 local str = data:getString()
118 data:release()
119 data = container == 'data' and love.filesystem.newFileData(str, self._name) or str
120 return data, r
121end
122
123local function lines(file, autoclose)
124 local BUFFERSIZE = 4096
125 local buffer, bufferPos = ByteArray(BUFFERSIZE), 0
126 local bytesRead = tonumber(C.fread(buffer, 1, BUFFERSIZE, file._handle))
127
128 local offset = file:tell()
129 return function()
130 file:seek(offset)
131
132 local line = {}
133 while bytesRead > 0 do
134 for i = bufferPos, bytesRead - 1 do
135 if buffer[i] == 10 then -- end of line
136 bufferPos = i + 1
137 return table.concat(line)
138 end
139
140 if buffer[i] ~= 13 then -- ignore CR
141 table.insert(line, string.char(buffer[i]))
142 end
143 end
144
145 bytesRead = tonumber(C.fread(buffer, 1, BUFFERSIZE, file._handle))
146 offset, bufferPos = offset + bytesRead, 0
147 end
148
149 if not line[1] then
150 if autoclose then file:close() end
151 return nil
152 end
153 return table.concat(line)
154 end
155end
156
157function File:lines()
158 if self._mode ~= 'r' then error("File is not opened for reading") end
159 return lines(self)
160end
161
162function File:write(data, size)
163 if self._mode ~= 'w' and self._mode ~= 'a' then
164 return false, "File " .. self._name .. " not opened for writing"
165 end
166
167 local toWrite, writeSize
168 if type(data) == 'string' then
169 writeSize = (size == nil or size == 'all') and #data or size
170 toWrite = data
171 else
172 writeSize = (size == nil or size == 'all') and data:getSize() or size
173 toWrite = data:getFFIPointer()
174 end
175
176 if tonumber(C.fwrite(toWrite, 1, writeSize, self._handle)) ~= writeSize then
177 return false, "Could not write data"
178 end
179 return true
180end
181
182function File:seek(pos)
183 return self._handle and C.fseek(self._handle, pos, 0) == 0
184end
185
186function File:tell()
187 if not self._handle then return nil, "Invalid position" end
188 return tonumber(C.ftell(self._handle))
189end
190
191function File:flush()
192 if self._mode ~= 'w' and self._mode ~= 'a' then
193 return nil, "File is not opened for writing"
194 end
195 return C.fflush(self._handle) == 0
196end
197
198function File:isEOF()
199 return not self:isOpen() or C.feof(self._handle) ~= 0 or self:tell() == self:getSize()
200end
201
202function File:release()
203 if self._mode ~= 'c' then self:close() end
204 self._handle = nil
205end
206
207function File:type() return 'File' end
208
209function File:typeOf(t) return t == 'File' end
210
211File.__index = File
212
213-----------------------------------------------------------------------------
214
215local nativefs = {}
216local loveC = ffi.os == 'Windows' and ffi.load('love') or C
217
218function nativefs.newFile(name)
219 if type(name) ~= 'string' then
220 error("bad argument #1 to 'newFile' (string expected, got " .. type(name) .. ")")
221 end
222 return setmetatable({
223 _name = name,
224 _mode = 'c',
225 _handle = nil,
226 _bufferSize = 0,
227 _bufferMode = 'none'
228 }, File)
229end
230
231function nativefs.newFileData(filepath)
232 local f = nativefs.newFile(filepath)
233 local ok, err = f:open('r')
234 if not ok then return nil, err end
235
236 local data, err = f:read('data', 'all')
237 f:close()
238 return data, err
239end
240
241function nativefs.mount(archive, mountPoint, appendToPath)
242 return loveC.PHYSFS_mount(archive, mountPoint, appendToPath and 1 or 0) ~= 0
243end
244
245function nativefs.unmount(archive)
246 return loveC.PHYSFS_unmount(archive) ~= 0
247end
248
249function nativefs.read(containerOrName, nameOrSize, sizeOrNil)
250 local container, name, size
251 if sizeOrNil then
252 container, name, size = containerOrName, nameOrSize, sizeOrNil
253 elseif not nameOrSize then
254 container, name, size = 'string', containerOrName, 'all'
255 else
256 if type(nameOrSize) == 'number' or nameOrSize == 'all' then
257 container, name, size = 'string', containerOrName, nameOrSize
258 else
259 container, name, size = containerOrName, nameOrSize, 'all'
260 end
261 end
262
263 local file = nativefs.newFile(name)
264 local ok, err = file:open('r')
265 if not ok then return nil, err end
266
267 local data, size = file:read(container, size)
268 file:close()
269 return data, size
270end
271
272local function writeFile(mode, name, data, size)
273 local file = nativefs.newFile(name)
274 local ok, err = file:open(mode)
275 if not ok then return nil, err end
276
277 ok, err = file:write(data, size or 'all')
278 file:close()
279 return ok, err
280end
281
282function nativefs.write(name, data, size)
283 return writeFile('w', name, data, size)
284end
285
286function nativefs.append(name, data, size)
287 return writeFile('a', name, data, size)
288end
289
290function nativefs.lines(name)
291 local f = nativefs.newFile(name)
292 local ok, err = f:open('r')
293 if not ok then return nil, err end
294 return lines(f, true)
295end
296
297function nativefs.load(name)
298 local chunk, err = nativefs.read(name)
299 if not chunk then return nil, err end
300 return loadstring(chunk, name)
301end
302
303function nativefs.getWorkingDirectory()
304 return getcwd()
305end
306
307function nativefs.setWorkingDirectory(path)
308 if not chdir(path) then return false, "Could not set working directory" end
309 return true
310end
311
312function nativefs.getDriveList()
313 if ffi.os ~= 'Windows' then return { '/' } end
314 local drives, bits = {}, C.GetLogicalDrives()
315 for i = 0, 25 do
316 if bit.band(bits, 2 ^ i) > 0 then
317 table.insert(drives, string.char(65 + i) .. ':/')
318 end
319 end
320 return drives
321end
322
323function nativefs.createDirectory(path)
324 local current = path:sub(1, 1) == '/' and '/' or ''
325 for dir in path:gmatch('[^/\\]+') do
326 current = current .. dir .. '/'
327 local info = nativefs.getInfo(current, 'directory')
328 if not info and not mkdir(current) then return false, "Could not create directory " .. current end
329 end
330 return true
331end
332
333function nativefs.remove(name)
334 local info = nativefs.getInfo(name)
335 if not info then return false, "Could not remove " .. name end
336 if info.type == 'directory' then
337 if not rmdir(name) then return false, "Could not remove directory " .. name end
338 return true
339 end
340 if not unlink(name) then return false, "Could not remove file " .. name end
341 return true
342end
343
344local function withTempMount(dir, fn, ...)
345 local mountPoint = _ptr(loveC.PHYSFS_getMountPoint(dir))
346 if mountPoint then return fn(ffi.string(mountPoint), ...) end
347 if not nativefs.mount(dir, '__nativefs__temp__') then return false, "Could not mount " .. dir end
348 local a, b = fn('__nativefs__temp__', ...)
349 nativefs.unmount(dir)
350 return a, b
351end
352
353function nativefs.getDirectoryItems(dir)
354 if type(dir) ~= "string" then
355 error("bad argument #1 to 'getDirectoryItems' (string expected, got " .. type(dir) .. ")")
356 end
357 local result, err = withTempMount(dir, love.filesystem.getDirectoryItems)
358 return result or {}
359end
360
361local function getDirectoryItemsInfo(path, filtertype)
362 local items = {}
363 local files = love.filesystem.getDirectoryItems(path)
364 for i = 1, #files do
365 local filepath = string.format('%s/%s', path, files[i])
366 local info = love.filesystem.getInfo(filepath, filtertype)
367 if info then
368 info.name = files[i]
369 table.insert(items, info)
370 end
371 end
372 return items
373end
374
375function nativefs.getDirectoryItemsInfo(path, filtertype)
376 if type(path) ~= "string" then
377 error("bad argument #1 to 'getDirectoryItemsInfo' (string expected, got " .. type(path) .. ")")
378 end
379 local result, err = withTempMount(path, getDirectoryItemsInfo, filtertype)
380 return result or {}
381end
382
383local function getInfo(path, file, filtertype)
384 local filepath = string.format('%s/%s', path, file)
385 return love.filesystem.getInfo(filepath, filtertype)
386end
387
388local function leaf(p)
389 p = p:gsub('\\', '/')
390 local last, a = p, 1
391 while a do
392 a = p:find('/', a + 1)
393 if a then
394 last = p:sub(a + 1)
395 end
396 end
397 return last
398end
399
400function nativefs.getInfo(path, filtertype)
401 if type(path) ~= 'string' then
402 error("bad argument #1 to 'getInfo' (string expected, got " .. type(path) .. ")")
403 end
404 local dir = path:match("(.*[\\/]).*$") or './'
405 local file = leaf(path)
406 local result, err = withTempMount(dir, getInfo, file, filtertype)
407 return result or nil
408end
409
410-----------------------------------------------------------------------------
411
412MODEMAP = { r = 'rb', w = 'wb', a = 'ab' }
413local MAX_PATH = 4096
414
415ffi.cdef([[
416 int PHYSFS_mount(const char* dir, const char* mountPoint, int appendToPath);
417 int PHYSFS_unmount(const char* dir);
418 const char* PHYSFS_getMountPoint(const char* dir);
419
420 typedef struct FILE FILE;
421
422 FILE* fopen(const char* path, const char* mode);
423 size_t fread(void* ptr, size_t size, size_t nmemb, FILE* stream);
424 size_t fwrite(const void* ptr, size_t size, size_t nmemb, FILE* stream);
425 int fclose(FILE* stream);
426 int fflush(FILE* stream);
427 size_t fseek(FILE* stream, size_t offset, int whence);
428 size_t ftell(FILE* stream);
429 int setvbuf(FILE* stream, char* buffer, int mode, size_t size);
430 int feof(FILE* stream);
431]])
432
433if ffi.os == 'Windows' then
434 ffi.cdef([[
435 int MultiByteToWideChar(unsigned int cp, uint32_t flags, const char* mb, int cmb, const wchar_t* wc, int cwc);
436 int WideCharToMultiByte(unsigned int cp, uint32_t flags, const wchar_t* wc, int cwc, const char* mb,
437 int cmb, const char* def, int* used);
438 int GetLogicalDrives(void);
439 int CreateDirectoryW(const wchar_t* path, void*);
440 int _wchdir(const wchar_t* path);
441 wchar_t* _wgetcwd(wchar_t* buffer, int maxlen);
442 FILE* _wfopen(const wchar_t* path, const wchar_t* mode);
443 int _wunlink(const wchar_t* path);
444 int _wrmdir(const wchar_t* path);
445 ]])
446
447 BUFFERMODE = { full = 0, line = 64, none = 4 }
448
449 local function towidestring(str)
450 local size = C.MultiByteToWideChar(65001, 0, str, #str, nil, 0)
451 local buf = ffi.new('wchar_t[?]', size + 1)
452 C.MultiByteToWideChar(65001, 0, str, #str, buf, size)
453 return buf
454 end
455
456 local function toutf8string(wstr)
457 local size = C.WideCharToMultiByte(65001, 0, wstr, -1, nil, 0, nil, nil)
458 local buf = ffi.new('char[?]', size + 1)
459 C.WideCharToMultiByte(65001, 0, wstr, -1, buf, size, nil, nil)
460 return ffi.string(buf)
461 end
462
463 local nameBuffer = ffi.new('wchar_t[?]', MAX_PATH + 1)
464
465 fopen = function(path, mode) return C._wfopen(towidestring(path), towidestring(mode)) end
466 getcwd = function() return toutf8string(C._wgetcwd(nameBuffer, MAX_PATH)) end
467 chdir = function(path) return C._wchdir(towidestring(path)) == 0 end
468 unlink = function(path) return C._wunlink(towidestring(path)) == 0 end
469 mkdir = function(path) return C.CreateDirectoryW(towidestring(path), nil) ~= 0 end
470 rmdir = function(path) return C._wrmdir(towidestring(path)) == 0 end
471else
472 BUFFERMODE = { full = 0, line = 1, none = 2 }
473
474 ffi.cdef([[
475 char* getcwd(char *buffer, int maxlen);
476 int chdir(const char* path);
477 int unlink(const char* path);
478 int mkdir(const char* path, int mode);
479 int rmdir(const char* path);
480 ]])
481
482 local nameBuffer = ByteArray(MAX_PATH)
483
484 fopen = C.fopen
485 unlink = function(path) return ffi.C.unlink(path) == 0 end
486 chdir = function(path) return ffi.C.chdir(path) == 0 end
487 mkdir = function(path) return ffi.C.mkdir(path, 0x1ed) == 0 end
488 rmdir = function(path) return ffi.C.rmdir(path) == 0 end
489
490 getcwd = function()
491 local cwd = _ptr(C.getcwd(nameBuffer, MAX_PATH))
492 return cwd and ffi.string(cwd) or nil
493 end
494end
495
496return nativefs