--[[ Copyright 2019 ARATA Mizuki This file is part of ClutTeX. ClutTeX is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. ClutTeX is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with ClutTeX. If not, see . ]] local ffi = require "ffi" local bitlib = assert(bit32 or bit, "Neither bit32 (Lua 5.2) nor bit (LuaJIT) found") -- Lua 5.2 or LuaJIT ffi.cdef[[ typedef int BOOL; typedef unsigned int UINT; typedef uint32_t DWORD; typedef void *HANDLE; typedef uintptr_t ULONG_PTR; typedef uint16_t WCHAR; typedef struct _OVERLAPPED { ULONG_PTR Internal; ULONG_PTR InternalHigh; union { struct { DWORD Offset; DWORD OffsetHigh; }; void *Pointer; }; HANDLE hEvent; } OVERLAPPED; typedef struct _FILE_NOTIFY_INFORMATION { DWORD NextEntryOffset; DWORD Action; DWORD FileNameLength; WCHAR FileName[?]; } FILE_NOTIFY_INFORMATION; typedef void (__stdcall *LPOVERLAPPED_COMPLETION_ROUTINE)(DWORD dwErrorCode, DWORD dwNumberOfBytesTransfered, OVERLAPPED *lpOverlapped); DWORD GetLastError(); BOOL CloseHandle(HANDLE hObject); HANDLE CreateFileA(const char *lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, void *lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile); HANDLE CreateIoCompletionPort(HANDLE fileHandle, HANDLE existingCompletionPort, ULONG_PTR completionKey, DWORD numberOfConcurrentThreads); BOOL ReadDirectoryChangesW(HANDLE hDirectory, void *lpBuffer, DWORD nBufferLength, BOOL bWatchSubtree, DWORD dwNotifyFilter, DWORD *lpBytesReturned, OVERLAPPED *lpOverlapped, LPOVERLAPPED_COMPLETION_ROUTINE lpOverlappedCompletionRoutine); BOOL GetQueuedCompletionStatus(HANDLE CompletionPort, DWORD *lpNumberOfBytes, ULONG_PTR *lpCompletionKey, OVERLAPPED **lpOverlapped, DWORD dwMilliseconds); int MultiByteToWideChar(UINT CodePage, DWORD dwFlags, const char *lpMultiByteStr, int cbMultiByte, WCHAR *lpWideCharStr, int cchWideChar); int WideCharToMultiByte(UINT CodePage, DWORD dwFlags, const WCHAR *lpWideCharStr, int cchWideChar, char *lpMultiByteStr, int cbMultiByte, const char *lpDefaultChar, BOOL *lpUsedDefaultChar); DWORD GetFullPathNameA(const char *lpFileName, DWORD nBufferLength, char *lpBuffer, char **lpFilePart); uint64_t GetTickCount64(); ]] -- LuaTeX's FFI does not equate a null pointer with nil. -- On LuaJIT, ffi.NULL is just nil. local NULL = ffi.NULL -- GetLastError local ERROR_FILE_NOT_FOUND = 0x0002 local ERROR_PATH_NOT_FOUND = 0x0003 local ERROR_ACCESS_DENIED = 0x0005 local ERROR_INVALID_PARAMETER = 0x0057 local ERROR_INSUFFICIENT_BUFFER = 0x007A local WAIT_TIMEOUT = 0x0102 local ERROR_ABANDONED_WAIT_0 = 0x02DF local ERROR_NOACCESS = 0x03E6 local ERROR_INVALID_FLAGS = 0x03EC local ERROR_NOTIFY_ENUM_DIR = 0x03FE local ERROR_NO_UNICODE_TRANSLATION = 0x0459 local KnownErrors = { [ERROR_FILE_NOT_FOUND] = "ERROR_FILE_NOT_FOUND", [ERROR_PATH_NOT_FOUND] = "ERROR_PATH_NOT_FOUND", [ERROR_ACCESS_DENIED] = "ERROR_ACCESS_DENIED", [ERROR_INVALID_PARAMETER] = "ERROR_INVALID_PARAMETER", [ERROR_INSUFFICIENT_BUFFER] = "ERROR_INSUFFICIENT_BUFFER", [ERROR_ABANDONED_WAIT_0] = "ERROR_ABANDONED_WAIT_0", [ERROR_NOACCESS] = "ERROR_NOACCESS", [ERROR_INVALID_FLAGS] = "ERROR_INVALID_FLAGS", [ERROR_NOTIFY_ENUM_DIR] = "ERROR_NOTIFY_ENUM_DIR", [ERROR_NO_UNICODE_TRANSLATION] = "ERROR_NO_UNICODE_TRANSLATION", } -- CreateFile local FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 local FILE_FLAG_OVERLAPPED = 0x40000000 local OPEN_EXISTING = 3 local FILE_SHARE_READ = 0x00000001 local FILE_SHARE_WRITE = 0x00000002 local FILE_SHARE_DELETE = 0x00000004 local FILE_LIST_DIRECTORY = 0x1 local INVALID_HANDLE_VALUE = ffi.cast("void *", -1) -- ReadDirectoryChangesW / FILE_NOTIFY_INFORMATION local FILE_NOTIFY_CHANGE_FILE_NAME = 0x00000001 local FILE_NOTIFY_CHANGE_DIR_NAME = 0x00000002 local FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x00000004 local FILE_NOTIFY_CHANGE_SIZE = 0x00000008 local FILE_NOTIFY_CHANGE_LAST_WRITE = 0x00000010 local FILE_NOTIFY_CHANGE_LAST_ACCESS = 0x00000020 local FILE_NOTIFY_CHANGE_CREATION = 0x00000040 local FILE_NOTIFY_CHANGE_SECURITY = 0x00000100 local FILE_ACTION_ADDED = 0x00000001 local FILE_ACTION_REMOVED = 0x00000002 local FILE_ACTION_MODIFIED = 0x00000003 local FILE_ACTION_RENAMED_OLD_NAME = 0x00000004 local FILE_ACTION_RENAMED_NEW_NAME = 0x00000005 -- WideCharToMultiByte / MultiByteToWideChar local CP_ACP = 0 local CP_UTF8 = 65001 local C = ffi.C local function format_error(name, lasterror, extra) local errorname = KnownErrors[lasterror] or string.format("error code %d", lasterror) if extra then return string.format("%s failed with %s (0x%04x) [%s]", name, errorname, lasterror, extra) else return string.format("%s failed with %s (0x%04x)", name, errorname, lasterror) end end local function wcs_to_mbs(wstr, wstrlen, codepage) -- wstr: FFI uint16_t[?] -- wstrlen: length of wstr, or -1 if NUL-terminated if wstrlen == 0 then return "" end codepage = codepage or CP_ACP local dwFlags = 0 local result = C.WideCharToMultiByte(codepage, dwFlags, wstr, wstrlen, nil, 0, nil, nil) if result <= 0 then -- Failed local lasterror = C.GetLastError() -- Candidates: ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_UNICODE_TRANSLATION return nil, format_error("WideCharToMultiByte", lasterror) end local mbsbuf = ffi.new("char[?]", result) result = C.WideCharToMultiByte(codepage, dwFlags, wstr, wstrlen, mbsbuf, result, nil, nil) if result <= 0 then -- Failed local lasterror = C.GetLastError() -- Candidates: ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_UNICODE_TRANSLATION return nil, format_error("WideCharToMultiByte", lasterror) end return ffi.string(mbsbuf, result) end local function mbs_to_wcs(str, codepage) -- str: Lua string if str == "" then return ffi.new("WCHAR[0]") end codepage = codepage or CP_ACP local dwFlags = 0 local result = C.MultiByteToWideChar(codepage, dwFlags, str, #str, nil, 0) if result <= 0 then local lasterror = C.GetLastError() -- ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_UNICODE_TRANSLATION return nil, format_error("MultiByteToWideChar", lasterror) end local wcsbuf = ffi.new("WCHAR[?]", result) result = C.MultiByteToWideChar(codepage, dwFlags, str, #str, wcsbuf, result) if result <= 0 then local lasterror = C.GetLastError() return nil, format_error("MultiByteToWideChar", lasterror) end return wcsbuf, result end -- TEST CODE do local ws = {0x3042} local resultstr = wcs_to_mbs(ffi.new("WCHAR[1]", ws), 1, CP_UTF8) assert(#resultstr == 3) assert(resultstr == "\xE3\x81\x82") -- \u{XXXX} notation is not available on LuaJIT end -- END TEST CODE local function get_full_path_name(filename) local bufsize = 1024 local buffer local filePartPtr = ffi.new("char*[1]") local result repeat buffer = ffi.new("char[?]", bufsize) result = C.GetFullPathNameA(filename, bufsize, buffer, filePartPtr) if result == 0 then local lasterror = C.GetLastError() return nil, format_error("GetFullPathNameA", lasterror, filename) elseif bufsize < result then -- result: buffer size required to hold the path + terminating NUL bufsize = result end until result < bufsize local fullpath = ffi.string(buffer, result) local filePart = ffi.string(filePartPtr[0]) local dirPart = ffi.string(buffer, ffi.cast("intptr_t", filePartPtr[0]) - ffi.cast("intptr_t", buffer)) -- LuaTeX's FFI doesn't support pointer subtraction return fullpath, filePart, dirPart end --[[ dirwatche.dirname : string dirwatcher._rawhandle : cdata HANDLE dirwatcher._overlapped : cdata OVERLAPPED dirwatcher._buffer : cdata char[?] ]] local dirwatcher_meta = {} dirwatcher_meta.__index = dirwatcher_meta function dirwatcher_meta:close() if self._rawhandle ~= nil then C.CloseHandle(ffi.gc(self._rawhandle, nil)) self._rawhandle = nil end end local function open_directory(dirname) local dwShareMode = bitlib.bor(FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE) local dwFlagsAndAttributes = bitlib.bor(FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED) local handle = C.CreateFileA(dirname, FILE_LIST_DIRECTORY, dwShareMode, nil, OPEN_EXISTING, dwFlagsAndAttributes, nil) if handle == INVALID_HANDLE_VALUE then local lasterror = C.GetLastError() print("Failed to open "..dirname) return nil, format_error("CreateFileA", lasterror, dirname) end return setmetatable({ dirname = dirname, _rawhandle = ffi.gc(handle, C.CloseHandle), _overlapped = ffi.new("OVERLAPPED"), _buffer = ffi.new("char[?]", 1024), }, dirwatcher_meta) end function dirwatcher_meta:start_watch(watchSubtree) local dwNotifyFilter = bitlib.bor(FILE_NOTIFY_CHANGE_FILE_NAME, FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_ATTRIBUTES, FILE_NOTIFY_CHANGE_SIZE, FILE_NOTIFY_CHANGE_LAST_WRITE, FILE_NOTIFY_CHANGE_LAST_ACCESS, FILE_NOTIFY_CHANGE_CREATION, FILE_NOTIFY_CHANGE_SECURITY) local buffer = self._buffer local bufferSize = ffi.sizeof(buffer) local result = C.ReadDirectoryChangesW(self._rawhandle, buffer, bufferSize, watchSubtree, dwNotifyFilter, nil, self._overlapped, nil) if result == 0 then local lasterror = C.GetLastError() return nil, format_error("ReadDirectoryChangesW", lasterror, self.dirname) end return true end local ActionTable = { [FILE_ACTION_ADDED] = "added", [FILE_ACTION_REMOVED] = "removed", [FILE_ACTION_MODIFIED] = "modified", [FILE_ACTION_RENAMED_OLD_NAME] = "rename_from", [FILE_ACTION_RENAMED_NEW_NAME] = "rename_to", } function dirwatcher_meta:process(numberOfBytes) -- self._buffer received `numberOfBytes` bytes local buffer = self._buffer numberOfBytes = math.min(numberOfBytes, ffi.sizeof(buffer)) local ptr = ffi.cast("char *", buffer) local structSize = ffi.sizeof("FILE_NOTIFY_INFORMATION", 1) local t = {} while numberOfBytes >= structSize do local notifyInfo = ffi.cast("FILE_NOTIFY_INFORMATION*", ptr) local nextEntryOffset = notifyInfo.NextEntryOffset local action = notifyInfo.Action local fileNameLength = notifyInfo.FileNameLength local fileName = notifyInfo.FileName local u = { action = ActionTable[action], filename = wcs_to_mbs(fileName, fileNameLength / 2) } table.insert(t, u) if nextEntryOffset == 0 or numberOfBytes <= nextEntryOffset then break end numberOfBytes = numberOfBytes - nextEntryOffset ptr = ptr + nextEntryOffset end return t end --[[ watcher._rawport : cdata HANDLE watcher._pending : array of { action = ..., filename = ... } watcher._directories[dirname] = { dir = directory watcher, dirname = dirname, files = { [filename] = user-supplied path } -- files to watch } watcher[i] = i-th directory (_directories[dirname] for some dirname) ]] local fswatcher_meta = {} fswatcher_meta.__index = fswatcher_meta local function new_watcher() local port = C.CreateIoCompletionPort(INVALID_HANDLE_VALUE, nil, 0, 0) if port == NULL then local lasterror = C.GetLastError() return nil, format_error("CreateIoCompletionPort", lasterror) end return setmetatable({ _rawport = ffi.gc(port, C.CloseHandle), -- ? _pending = {}, _directories = {}, }, fswatcher_meta) end local function add_directory(self, dirname) local t = self._directories[dirname] if not t then local dirwatcher, err = open_directory(dirname) if not dirwatcher then return dirwatcher, err end t = { dirwatcher = dirwatcher, dirname = dirname, files = {} } table.insert(self, t) local i = #self local result = C.CreateIoCompletionPort(dirwatcher._rawhandle, self._rawport, i, 0) if result == NULL then local lasterror = C.GetLastError() return nil, format_error("CreateIoCompletionPort", lasterror, dirname) end self._directories[dirname] = t local result, err = dirwatcher:start_watch(false) if not result then return result, err end end return t end function fswatcher_meta:add_file(path, ...) local fullpath, filename, dirname = get_full_path_name(path) local t, err = add_directory(self, dirname) if not t then return t, err end t.files[filename] = path return true end local INFINITE = 0xFFFFFFFF local function get_queued(self, timeout) local startTime = C.GetTickCount64() local timeout_ms if timeout == nil then timeout_ms = INFINITE else timeout_ms = timeout * 1000 end local numberOfBytesPtr = ffi.new("DWORD[1]") local completionKeyPtr = ffi.new("ULONG_PTR[1]") local lpOverlapped = ffi.new("OVERLAPPED*[1]") repeat local result = C.GetQueuedCompletionStatus(self._rawport, numberOfBytesPtr, completionKeyPtr, lpOverlapped, timeout_ms) if result == 0 then local lasterror = C.GetLastError() if lasterror == WAIT_TIMEOUT then return nil, "timeout" else return nil, format_error("GetQueuedCompletionStatus", lasterror) end end local numberOfBytes = numberOfBytesPtr[0] local completionKey = tonumber(completionKeyPtr[0]) local dir_t = assert(self[completionKey], "invalid completion key: " .. tostring(completionKey)) local t = dir_t.dirwatcher:process(numberOfBytes) dir_t.dirwatcher:start_watch(false) local found = false for i,v in ipairs(t) do local path = dir_t.files[v.filename] if path then found = true table.insert(self._pending, {path = path, action = v.action}) end end if found then return true end if timeout_ms ~= INFINITE then local tt = C.GetTickCount64() timeout_ms = timeout_ms - (tt - startTime) startTime = tt end until timeout_ms < 0 return nil, "timeout" end function fswatcher_meta:next(timeout) if #self._pending > 0 then local result = table.remove(self._pending, 1) get_queued(self, 0) -- ignore error return result else local result, err = get_queued(self, timeout) if result == nil then return nil, err end return table.remove(self._pending, 1) end end function fswatcher_meta:close() if self._rawport ~= nil then for i,v in ipairs(self) do v.dirwatcher:close() end C.CloseHandle(ffi.gc(self._rawport, nil)) self._rawport = nil end end --[[ local watcher = require("fswatcher_windows").new() assert(watcher:add_file("rdc-sync.c")) assert(watcher:add_file("sub2/hoge")) for i = 1, 10 do local result, err = watcher:next(2) if err == "timeout" then print(os.date(), "timeout") else assert(result, err) print(os.date(), result.path, result.action) end end watcher:close() ]] return { new = new_watcher, }