├── .github
└── workflows
│ └── lint.yml
├── .luarc.json
├── LICENSE
├── LuaRepl.app
└── Contents
│ ├── Info.plist
│ ├── MacOS
│ └── main.lua
│ └── Resources
│ ├── lua.icns
│ ├── luajit
│ ├── objc.lua
│ └── repl.lua
└── README.md
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Lint Code Base
3 | on:
4 | pull_request: ~
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | build:
11 | name: Lint Code Base
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout Code
16 | uses: actions/checkout@v4
17 |
18 | - name: Lint Code Base
19 | uses: mrcjkb/lua-typecheck-action@v0.2.0
20 | with:
21 | configpath: ".luarc.json"
22 | directories: "."
23 |
--------------------------------------------------------------------------------
/.luarc.json:
--------------------------------------------------------------------------------
1 | {
2 | "diagnostics":
3 | {
4 | "globals": [],
5 | "neededFileStatus": { "codestyle-check": "Any" },
6 | },
7 | "runtime.version": "Lua 5.1",
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Michael Mogenson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/LuaRepl.app/Contents/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleExecutable
6 | main.lua
7 | CFBundleIdentifier
8 | com.example.LuaREPL
9 | CFBundleIconFile
10 | lua
11 |
12 |
13 |
--------------------------------------------------------------------------------
/LuaRepl.app/Contents/MacOS/main.lua:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | --[[ 2>/dev/null
3 | resources="$(dirname "$0")/../Resources"
4 | export LUA_PATH="$resources/?.lua;$resources/?/init.lua"
5 | exec "$resources/luajit" "$0" "$@"
6 | --]]
7 |
8 | local objc = require("objc")
9 | local repl = require("repl")
10 | local ffi = require("ffi")
11 | local bit = require("bit")
12 |
13 | objc.loadFramework("AppKit")
14 |
15 | -- Foundation types
16 | ffi.cdef([[
17 | typedef struct NSEdgeInsets {
18 | CGFloat top;
19 | CGFloat left;
20 | CGFloat bottom;
21 | CGFloat right;
22 | } NSEdgeInsets;
23 | ]])
24 |
25 | -- AppKit constants
26 | local NSApplicationActivationPolicyRegular = ffi.new("NSInteger", 0)
27 | local NSBackingStoreBuffered = ffi.new("NSUInteger", 2)
28 |
29 | local NSWindowStyleMaskTitled = ffi.new("NSUInteger", bit.lshift(1, 0))
30 | local NSWindowStyleMaskClosable = ffi.new("NSUInteger", bit.lshift(1, 1))
31 | local NSWindowStyleMaskMiniaturizable = ffi.new("NSUInteger", bit.lshift(1, 2))
32 | local NSWindowStyleMaskResizable = ffi.new("NSUInteger", bit.lshift(1, 3))
33 |
34 | local NSStackViewGravityTop = ffi.new("NSInteger", 1)
35 | local NSStackViewGravityLeading = ffi.new("NSInteger", 1)
36 | local NSStackViewGravityCenter = ffi.new("NSInteger", 2)
37 | local NSStackViewGravityBottom = ffi.new("NSInteger", 3)
38 | local NSStackViewGravityTrailing = ffi.new("NSInteger", 3)
39 |
40 | local NSUserInterfaceLayoutOrientationHorizontal = ffi.new("NSInteger", 0)
41 | local NSUserInterfaceLayoutOrientationVertical = ffi.new("NSInteger", 1)
42 |
43 | local NO = ffi.new("BOOL", 0)
44 | local YES = ffi.new("BOOL", 1)
45 |
46 | local function NSString(str)
47 | return objc.NSString:stringWithUTF8String(str)
48 | end
49 |
50 | local function runRepl(textField, textView)
51 | local input = ffi.string(textField.stringValue:UTF8String())
52 | textField.stringValue = NSString("")
53 |
54 | local output = {}
55 | table.insert(output, "> ")
56 | table.insert(output, input)
57 | table.insert(output, "\n")
58 |
59 | local result, err = repl:eval(input)
60 | if result then
61 | table.insert(output, result)
62 | table.insert(output, "\n")
63 | end
64 | if err then
65 | table.insert(output, err)
66 | table.insert(output, "\n")
67 | end
68 |
69 | textView.string = textView.string:stringByAppendingString(NSString(table.concat(output)))
70 | textView:scrollToEndOfDocument(textView)
71 | end
72 |
73 |
74 | local function makeAppMenu(app)
75 | local menubar = objc.NSMenu:alloc():init()
76 | menubar:autorelease()
77 |
78 | local appMenuItem = objc.NSMenuItem:alloc():init()
79 | appMenuItem:autorelease()
80 | menubar:addItem(appMenuItem)
81 | app:setMainMenu(menubar)
82 |
83 | local appMenu = objc.NSMenu:alloc():init()
84 | appMenu:autorelease()
85 |
86 | local quitMenuItem = objc.NSMenuItem:alloc():initWithTitle_action_keyEquivalent(NSString("Quit"), "terminate:",
87 | NSString("q"))
88 | quitMenuItem:autorelease()
89 | appMenu:addItem(quitMenuItem)
90 |
91 | local closeMenuItem = objc.NSMenuItem:alloc():initWithTitle_action_keyEquivalent(NSString("Close"), "performClose:",
92 | NSString("w"))
93 | closeMenuItem:autorelease()
94 | appMenu:addItem(closeMenuItem)
95 |
96 | appMenuItem:setSubmenu(appMenu)
97 | end
98 |
99 | local function makeAppDelegate(app, textField, textView)
100 | local buttonClicked = "buttonClicked:"
101 |
102 | local AppDelegateClass = objc.newClass("AppDelegate")
103 | objc.addMethod(AppDelegateClass, "applicationShouldTerminateAfterLastWindowClosed:", "B@:",
104 | function(self, cmd)
105 | print("quitting...")
106 | return YES
107 | end)
108 | objc.addMethod(AppDelegateClass, buttonClicked, "v@:@",
109 | function(self, cmd, sender)
110 | runRepl(textField, textView)
111 | end)
112 |
113 | local appDelegate = objc.AppDelegate:alloc():init()
114 | appDelegate:autorelease()
115 | app:setDelegate(appDelegate)
116 |
117 | return objc.NSButton:buttonWithTitle_target_action(NSString("Eval"), appDelegate, buttonClicked)
118 | end
119 |
120 |
121 | local function main()
122 | local pool = objc.NSAutoreleasePool:alloc():init()
123 |
124 | local NSApp = objc.NSApplication:sharedApplication()
125 | NSApp:setActivationPolicy(NSApplicationActivationPolicyRegular)
126 | makeAppMenu(NSApp)
127 |
128 | local scrollView = objc.NSTextView:scrollableTextView()
129 | scrollView:autorelease()
130 |
131 | local textView = scrollView.documentView
132 | textView.editable = NO
133 |
134 | local textField = objc.NSTextField:alloc():init()
135 | textField.placeholderString = NSString("Enter Lua Code...")
136 |
137 | local button = makeAppDelegate(NSApp, textField, textView)
138 |
139 | local hStack = objc.NSStackView:alloc():init()
140 | hStack:autorelease()
141 | hStack:addView_inGravity(textField, NSStackViewGravityLeading)
142 | hStack:addView_inGravity(button, NSStackViewGravityTrailing)
143 |
144 | local vStack = objc.NSStackView:alloc():init()
145 | vStack:autorelease()
146 | vStack.orientation = NSUserInterfaceLayoutOrientationVertical
147 | vStack.edgeInsets = ffi.new("NSEdgeInsets", { top = 10, left = 10, bottom = 10, right = 10 })
148 | vStack:addView_inGravity(scrollView, NSStackViewGravityTop)
149 | vStack:addView_inGravity(hStack, NSStackViewGravityBottom)
150 |
151 | local rect = ffi.new("CGRect", { origin = { x = 0, y = 0 }, size = { width = 200, height = 300 } })
152 | local styleMask = bit.bor(NSWindowStyleMaskTitled, NSWindowStyleMaskClosable, NSWindowStyleMaskMiniaturizable,
153 | NSWindowStyleMaskResizable)
154 |
155 | local window = objc.NSWindow:alloc():initWithContentRect_styleMask_backing_defer(rect, styleMask,
156 | NSBackingStoreBuffered, NO)
157 | window:autorelease()
158 | window.contentView = vStack
159 | window:setTitle(NSString("LuaREPL"))
160 | window:makeKeyAndOrderFront(window)
161 |
162 | NSApp:run()
163 | pool:drain()
164 | end
165 |
166 | main()
167 |
--------------------------------------------------------------------------------
/LuaRepl.app/Contents/Resources/lua.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mogenson/lua-macos-app/bfd432457074bfe51a6715448a187199f0356edc/LuaRepl.app/Contents/Resources/lua.icns
--------------------------------------------------------------------------------
/LuaRepl.app/Contents/Resources/luajit:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mogenson/lua-macos-app/bfd432457074bfe51a6715448a187199f0356edc/LuaRepl.app/Contents/Resources/luajit
--------------------------------------------------------------------------------
/LuaRepl.app/Contents/Resources/objc.lua:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env luajit
2 |
3 | local ffi = require("ffi")
4 | local C = ffi.C
5 |
6 | ---@alias cdata userdata C types returned from FFI
7 | ---@alias id cdata Objective-C object
8 | ---@alias Class cdata Objective-C Class
9 | ---@alias SEL cdata Objective-C Selector
10 | ---@alias Method cdata Objective-C Method
11 |
12 | ffi.cdef([[
13 | // types
14 | typedef signed char BOOL;
15 | typedef double CGFloat;
16 | typedef long NSInteger;
17 | typedef unsigned long NSUInteger;
18 | typedef struct objc_class *Class;
19 | typedef struct objc_object *id;
20 | typedef struct objc_selector *SEL;
21 | typedef struct objc_method *Method;
22 | typedef struct objc_property *objc_property_t;
23 | typedef id (*IMP) (id, SEL, ...);
24 | typedef struct CGPoint { CGFloat x; CGFloat y; } CGPoint;
25 | typedef struct CGSize { CGFloat width; CGFloat height; } CGSize;
26 | typedef struct CGRect { CGPoint origin; CGSize size; } CGRect;
27 |
28 | // API
29 | BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types);
30 | Class objc_allocateClassPair(Class superclass, const char *name, size_t extraBytes);
31 | Class objc_lookUpClass(const char *name);
32 | Class object_getClass(id obj);
33 | Method class_getClassMethod(Class cls, SEL name);
34 | Method class_getInstanceMethod(Class cls, SEL name);
35 | SEL sel_registerName(const char *str);
36 | char * method_copyArgumentType(Method m, unsigned int index);
37 | char * method_copyReturnType(Method m);
38 | const char * class_getName(Class cls);
39 | const char * object_getClassName(id obj);
40 | const char * sel_getName(SEL sel);
41 | objc_property_t class_getProperty(Class cls, const char *name);
42 | unsigned int method_getNumberOfArguments(Method m);
43 | void free(void *ptr);
44 | void objc_msgSend(void);
45 | void objc_registerClassPair(Class cls);
46 | ]])
47 |
48 | ffi.load("/usr/lib/libobjc.A.dylib", true)
49 |
50 | local type_encoding = setmetatable({
51 | ["c"] = "char",
52 | ["i"] = "int",
53 | ["s"] = "short",
54 | ["l"] = "long",
55 | ["q"] = "NSInteger",
56 | ["C"] = "unsigned char",
57 | ["I"] = "unsigned int",
58 | ["S"] = "unsigned short",
59 | ["L"] = "unsigned long",
60 | ["Q"] = "NSUInteger",
61 | ["f"] = "float",
62 | ["d"] = "double",
63 | ["B"] = "BOOL",
64 | ["v"] = "void",
65 | ["*"] = "char*",
66 | ["@"] = "id",
67 | ["#"] = "Class",
68 | [":"] = "SEL",
69 | ["^"] = "void*",
70 | ["?"] = "void",
71 | ["r*"] = "char*",
72 | }, {
73 | __index = function(_, k)
74 | assert(type(k) == "string" and #k > 2)
75 | local first_letter = k:sub(1, 1)
76 | if first_letter == "{" or first_letter == "(" then -- named struct or union
77 | return assert(select(3, k:find("%" .. first_letter .. "(%a+)=")))
78 | end
79 | end,
80 | __newindex = nil, -- read only table
81 | })
82 |
83 | ---convert a NULL pointer to nil
84 | ---@param p cdata pointer
85 | ---@return cdata | nil
86 | local function ptr(p)
87 | if p == nil then return nil else return p end
88 | end
89 |
90 | ---return a Class from name or object
91 | ---@param name string | Class | id
92 | ---@return Class
93 | local function cls(name)
94 | assert(name)
95 | if ffi.istype("id", name) then
96 | return assert(ptr(C.object_getClass(name))) -- get class from object
97 | end
98 | if type(name) == "cdata" and ffi.istype("Class", name) then
99 | return name -- already a Class
100 | end
101 | assert(type(name) == "string")
102 | return assert(ptr(C.objc_lookUpClass(name)))
103 | end
104 |
105 | ---return SEL from name
106 | ---@param name string | SEL
107 | ---@param num_args? integer
108 | ---@return SEL
109 | local function sel(name, num_args)
110 | assert(name)
111 | if type(name) == "cdata" and ffi.istype("SEL", name) then
112 | return name -- already a SEL
113 | end
114 | assert(type(name) == "string")
115 | if num_args and num_args > 0 and name:sub(-1) ~= "_" then
116 | name = name .. "_"
117 | end
118 | local name, count = name:gsub("_", ":")
119 | if num_args then assert(count == num_args) end
120 | return C.sel_registerName(name) -- pointer is never NULL
121 | end
122 |
123 | ---call a method for a SEL on a Class or object
124 | ---@param receiver string | Class | id the class or object
125 | ---@param selector string | SEL name of method
126 | ---@param ...? any additional method parameters
127 | ---@return any
128 | local function msgSend(receiver, selector, ...)
129 | ---return Method for Class or object and SEL
130 | ---@param receiver Class | id
131 | ---@param selector SEL
132 | ---@return Method?
133 | local function getMethod(receiver, selector)
134 | -- return method for Class or object and SEL
135 | if ffi.istype("Class", receiver) then
136 | return assert(ptr(C.class_getClassMethod(receiver, selector)))
137 | elseif ffi.istype("id", receiver) then
138 | return assert(ptr(C.class_getInstanceMethod(cls(receiver), selector)))
139 | end
140 | assert(false, "receiver not a Class or object")
141 | end
142 |
143 | ---convert a Lua variable to a C type if needed
144 | ---@param lua_var any
145 | ---@param c_type string
146 | ---@return cdata | any
147 | local function convert(lua_var, c_type)
148 | if type(lua_var) == "string" then
149 | if c_type == "SEL" then
150 | -- print("creating SEL from " .. lua_var)
151 | return sel(lua_var)
152 | elseif c_type == "char*" then
153 | -- print("creating char* from " .. lua_var)
154 | return ffi.cast(c_type, lua_var)
155 | end
156 | elseif type(lua_var) == "cdata" and c_type == "id" and ffi.istype("Class", lua_var) then
157 | -- sometimes method signatures use id instead of Class
158 | -- print("casting " .. tostring(lua_var) .. " to id")
159 | return ffi.cast(c_type, lua_var)
160 | end
161 | return lua_var -- no conversion necessary
162 | end
163 |
164 | if type(receiver) == "string" then receiver = cls(receiver) end
165 | local selector = sel(selector)
166 | local method = getMethod(receiver, selector)
167 | local call_args = { receiver, selector, ... }
168 | local char_ptr = assert(ptr(C.method_copyReturnType(method)))
169 | local objc_type = ffi.string(char_ptr)
170 | C.free(char_ptr)
171 | local c_type = assert(type_encoding[objc_type])
172 | local signature = {}
173 | table.insert(signature, c_type)
174 | table.insert(signature, "(*)(")
175 |
176 | local num_method_args = C.method_getNumberOfArguments(method)
177 | assert(num_method_args == #call_args)
178 | for i = 1, num_method_args do
179 | char_ptr = assert(ptr(C.method_copyArgumentType(method, i - 1)))
180 | objc_type = ffi.string(char_ptr)
181 | C.free(char_ptr)
182 | c_type = assert(type_encoding[objc_type])
183 | table.insert(signature, c_type)
184 | call_args[i] = convert(call_args[i], c_type)
185 | if i < num_method_args then table.insert(signature, ",") end
186 | end
187 | table.insert(signature, ")")
188 | local signature = table.concat(signature)
189 |
190 | -- print(receiver, selector, signature)
191 | return ffi.cast(signature, C.objc_msgSend)(unpack(call_args))
192 | end
193 |
194 | ---load a Framework
195 | ---@param framework string framework name without the '.framework' extension
196 | local function loadFramework(framework)
197 | -- on newer versions of MacOS this is a broken symbolic link, but dlopen() still succeeds
198 | ffi.load(string.format("/System/Library/Frameworks/%s.framework/%s", framework, framework), true)
199 | end
200 |
201 | ---create a new custom class from an optional base class
202 | ---@param name string name of new class
203 | ---@param super_class? string | Class parent class, or NSObject if omitted
204 | ---@return Class
205 | local function newClass(name, super_class)
206 | assert(name and type(name) == "string")
207 | local super_class = cls(super_class or "NSObject")
208 | local class = assert(ptr(C.objc_allocateClassPair(super_class, name, 0)))
209 | C.objc_registerClassPair(class)
210 | return class
211 | end
212 |
213 | ---add a method to a custom class
214 | ---@param class string | Class class created with newClass()
215 | ---@param selector string | SEL name of method
216 | ---@types string Objective-C type encoded method arguments and return type
217 | ---@func function lua callback function for method implementation
218 | local function addMethod(class, selector, types, func)
219 | assert(type(func) == "function")
220 | assert(type(types) == "string")
221 |
222 | local class = cls(class)
223 | local selector = sel(selector)
224 |
225 | local signature = {}
226 | table.insert(signature, type_encoding[types:sub(1, 1)]) -- return type
227 | table.insert(signature, "(*)(") -- anonymous function
228 | for i = 2, #types do
229 | table.insert(signature, type_encoding[types:sub(i, i)])
230 | if i < #types then table.insert(signature, ",") end
231 | end
232 | table.insert(signature, ")")
233 | local signature = table.concat(signature)
234 | -- print(class, selector, signature, types)
235 |
236 | local imp = ffi.cast("IMP", ffi.cast(signature, func))
237 | assert(C.class_addMethod(class, selector, imp, types) == 1)
238 | end
239 |
240 |
241 | local objc = setmetatable({
242 | Class = cls,
243 | SEL = sel,
244 | addMethod = addMethod,
245 | loadFramework = loadFramework,
246 | msgSend = msgSend,
247 | newClass = newClass,
248 | ptr = ptr,
249 | }, {
250 | __index = function(_, name)
251 | -- use key to lookup class by name
252 | return cls(name)
253 | end
254 | })
255 |
256 | ffi.metatype("struct objc_selector", {
257 | __tostring = function(selector)
258 | return ffi.string(assert(ptr(C.sel_getName(selector))))
259 | end
260 | })
261 |
262 | ffi.metatype("struct objc_class", {
263 | __tostring = function(class)
264 | return ffi.string(assert(ptr(C.class_getName(class))))
265 | end,
266 | __index = function(class, selector)
267 | return function(self, ...)
268 | assert(class == self)
269 | return msgSend(self, sel(selector, select("#", ...)), ...)
270 | end
271 | end
272 | })
273 |
274 |
275 | ffi.metatype("struct objc_object", {
276 | __tostring = function(class)
277 | return ffi.string(assert(ptr(C.object_getClassName(class))))
278 | end,
279 | __index = function(object, selector)
280 | if ptr(C.class_getProperty(cls(object), selector)) then
281 | return msgSend(object, sel(selector))
282 | end
283 |
284 | return function(self, ...)
285 | assert(object == self)
286 | return msgSend(self, sel(selector, select("#", ...)), ...)
287 | end
288 | end,
289 | __newindex = function(object, selector, value)
290 | selector = string.format('set%s%s:', selector:sub(1, 1):upper(), selector:sub(2))
291 | msgSend(object, sel(selector), value) -- propertyName to setPropertyName
292 | end
293 | })
294 |
295 | return objc
296 |
--------------------------------------------------------------------------------
/LuaRepl.app/Contents/Resources/repl.lua:
--------------------------------------------------------------------------------
1 | local repl = { buffer = "" }
2 |
3 | ---evaluate a chunk of lua code
4 | ---@param text string code chunk
5 | ---@return string | nil result returned values of chunk in comma separated string
6 | ---@return string | nil error message if eval failed
7 | function repl:eval(text)
8 | local chunk = self.buffer .. text
9 |
10 | local func, err = loadstring(chunk)
11 |
12 | -- check if multi-line or expression
13 | if err and err:match("%p*$") then
14 | self.buffer = chunk .. "\n"
15 | err = nil
16 | end
17 |
18 | local ret = nil
19 | if func then
20 | self.buffer = ""
21 | local results = { pcall(func) }
22 | if results[1] then
23 | ret = tostring(results[2])
24 | for i = 3, #results do
25 | ret = ret .. ", " .. tostring(results[i])
26 | end
27 | else
28 | err = results[2]
29 | end
30 | end
31 |
32 | return ret, err
33 | end
34 |
35 | return repl
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Creating a MacOS app with Lua
2 | And nothing else! No compiled code. No XCode.
3 |
4 | https://medium.com/@michael.mogenson/write-a-macos-app-with-lua-342148381e25
5 |
6 | Here is a small project that helped me explore the following topics:
7 |
8 | I was curious to learn more about a few niche topics:
9 | - How MacOS apps are packaged
10 | - How to bundle a self-contained scripting language
11 | - How to compile and run Lua code on the fly
12 | - How to create a GUI app with Lua
13 | - How LuaJIT's FFI interface works
14 |
15 | Here's the app that we're going to make:
16 |
17 |
18 |
19 | Below is a small project that helped me explore these questions. I'm going to walk through the process of making a Lua REPL (named for the interactive read-evaluate-print-loop of interpreted languages like Lua and Python. Our app will (creatively) be named LuaREPL. It is a MacOS app with a single text entry line, an Eval button, and a multi-line text output area. User entered code is fed from the text entry, evaluated by the Lua interpreter, and printed to the text output. This app is so simple, it's better used as a template for creating a more substantial app.
20 | Some knowledge of Lua, C, Objective-C, and MacOS/iOS app development is useful but not required. If there are components that do not make sense when initially presented, hopefully they will when we put everything together at the end.
21 |
22 | ## Why?
23 |
24 | I don't know. I'm so bad at answering the question why. Ask me how. The next 500 lines of text are about how.
25 | I saw a video about writing an Android app in C. The author explored the Android OS and determined the minimal setup required to draw to the screen. I wanted to do something similar with Lua, my favorite small language. I own a MacBook. Could I create a native MacOS app without first downloading an 12GB XCode installation image? Could I do everything from scratch, instead of using (fabulous) projects like LÖVE (a C++ game engine with Lua bindings) or Libui (a cross platform UI library for C)?
26 | I suppose this would be good for an internal tool at a company. Distribute a single Lua text file that employees would run as an app. Update that text file and the app updates. No need to go through Apple's App Store for distribution.
27 |
28 | ### Continuing on…
29 |
30 | This article will go through the above topics of learning in reverse order. Starting with…
31 |
32 | > Note: for clarity, some error handling and extended features are removed from the code snippets presented in this article.
33 |
34 | ## LuaJIT
35 |
36 | First, we need Lua to build our app, but we're going to use a special version of Lua called LuaJIT. What is LuaJIT? [LuaJIT](https://luajit.org/index.html) is a Just-In-Time compiler and drop in replacement for the standard Lua interpreter. There are numerous ways to install LuaJIT on MacOS. However, since we will eventually want to package LuaJIT into our final, self-contained, MacOS app, let's build it from source.
37 |
38 | You said we wouldn't need to compile anything? Ya I lied. You can brew install luajit if you want. But for bundling luajit with our app, it's not hard to build. I promise.
39 |
40 | No dependencies besides make and a C compiler are required. Clone the LuaJIT repo and build with:
41 |
42 | ```bash
43 | $ git clone https://github.com/LuaJIT/LuaJIT
44 | $ cd LuaJIT
45 | $ MACOSX_DEPLOYMENT_TARGET=$(sw_vers --productVersion) make
46 | ```
47 |
48 | The MACOSX_DEPLOYMENT_TARGET environmental variable must be set during building. I'm only targeting my own computer. At the time of writing, the output of sw_vers --productVersion is "14.1.1".
49 |
50 | The resulting `luajit` binary executable will be in the src directory. This stand-alone executable will eventually be copied into our MacOS app. The Lua interpreter, Lua standard library, and LuaJIT specific libraries are all included in the single `luajit` file. Compared to the hundreds of files in specific locations that are needed for the Python interpreter and standard library, this portable Lua interpreter is easy to include inside our app.
51 |
52 | > You can also build LuaJIT as a universal binary for x86_64 and arm64 with the following commands! A universal `luajit` binary is commited to this repo's app bundle.
53 | > ```
54 | > export MACOSX_DEPLOYMENT_TARGET=14
55 | > make LUAJIT_T=luajit-x86_64 HOST_CC='clang -target arm64-apple-macos14' STATIC_CC='clang -target x86_64-apple-macos14' DYNAMIC_CC='clang -target x86_64-apple-macos14 -fPIC' TARGET_LD='clang -target x86_64-apple-macos14' TARGET_AR='ar -r'
56 | > make clean
57 | > make LUAJIT_T=luajit-arm64 HOST_CC='clang -target arm64-apple-macos14' STATIC_CC='clang -target arm64-apple-macos14' DYNAMIC_CC='clang -target arm64-apple-macos14 -fPIC' TARGET_LD='clang -target arm64-apple-macos14' TARGET_AR='ar -r'
58 | > lipo -create -output src/luajit src/luajit-x86_64 src/luajit-arm64
59 | > ```
60 |
61 | ## LuaJIT's FFI interface
62 |
63 | Besides being very fast at running Lua code, LuaJIT has a fantastic foreign function intervace [(or FFI)](https://luajit.org/ext_ffi_api.html), that lets it call into code written in other languages, using the C function calling convention. LuaJIT will parse declarations from a C header, generate bindings, and do some type conversion automatically. This makes it really easy to call C code from LuaJIT. We will use the FFI module to call into native MacOS libraries to construct and show our app. Here's a simple FFI example:
64 |
65 | ```lua
66 | local ffi = require("ffi")
67 |
68 | ffi.cdef([[
69 | int printf(const char *fmt, ...);
70 | ]])
71 |
72 | ffi.C.printf("Hello %s!\n", "world")
73 | ```
74 |
75 | The multi-line string passed to `ffi.cdef` contains the prototype for libc's `printf` function. LuaJIT's FFI parser can recognize that this is a variadic C function. A Lua binding is created and added to the `ffi.C` namespace. When this `ffi.C.printf` function is called, LuaJIT knows that the string arguments need to be converted into `\0` terminated char pointers, and does so!
76 |
77 | This is fun, but we want to do something MacOS specific to reach our end goal of creating a MacOS app. But, most of the MacOS core libraries are written in [Objective-C](https://en.wikipedia.org/wiki/Objective-C), not C. Objective-C is a message passing language, where named objects are operated on by sending formatted messages to object methods. Here's an Objective-C example to write to the MacOS clipboard.
78 |
79 | ```objective-c
80 | #import
81 |
82 | int main() {
83 | NSPasteboard *pboard = [NSPasteboard generalPasteboard];
84 | [pboard clearContents];
85 | [pboard setString:@"Hello from Objective-C!"
86 | forType:NSPasteboardTypeString];
87 | return 0;
88 | }
89 | ```
90 |
91 | However, Objective-C is a strict superset of C. All of the message passing is done with a few C functions and a lot of type casting. Here is the same example in C.
92 |
93 | ```c
94 | #include
95 | #include
96 |
97 | extern id const NSPasteboardTypeString;
98 |
99 | int main() {
100 | const char *str = "Hello from C!";
101 |
102 | Class NSPasteboard = objc_getClass("NSPasteboard");
103 | id pboard = ((id (*)(Class, SEL))objc_msgSend)(NSPasteboard, sel_registerName("generalPasteboard"));
104 |
105 | ((void (*)(id, SEL))objc_msgSend)(pboard, sel_registerName("clearContents"));
106 |
107 | Class NSString = objc_getClass("NSString");
108 | id nsStr = ((id (*)(Class, SEL, const char *))objc_msgSend)(NSString, sel_registerName("stringWithUTF8String:"), str);
109 |
110 | ((bool (*)(id, SEL, id, id))objc_msgSend)(pboard, sel_registerName("setString:forType:"), nsStr, NSPasteboardTypeString);
111 |
112 | return 0;
113 | }
114 | ```
115 |
116 | Let's go over the C functions used above. We use `objc_getClass` to get an object by name, `sel_registerName` to get a selector (which is a handle) to an object method by name, and `objc_msgSend` to send a message to an object's selector.
117 |
118 | We could compile this C program with some glue code to use as a Lua module. But with LuaJIT's FFI interface, we can call these C functions directly from Lua. The result of translating the C example above into Lua looks like this:
119 |
120 | ```lua
121 | local ffi = require("ffi")
122 | local C = ffi.C
123 |
124 | ffi.cdef([[
125 | typedef struct objc_object *id;
126 | typedef struct objc_selector *SEL;
127 | id objc_getClass(const char*);
128 | SEL sel_registerName(const char*);
129 | id objc_msgSend(id,SEL);
130 | id NSPasteboardTypeString;
131 | ]])
132 |
133 | ffi.load("/System/Library/Frameworks/AppKit.framework/AppKit")
134 |
135 | function main()
136 | local str = ffi.cast("char*", "Hello from Lua!")
137 |
138 | local pboard = C.objc_msgSend(C.objc_getClass("NSPasteboard"),
139 | C.sel_registerName("generalPasteboard"))
140 |
141 | C.objc_msgSend(pboard, C.sel_registerName("clearContents"))
142 |
143 | local nsStr = ffi.cast("id(*)(id,SEL,char*)", C.objc_msgSend)(
144 | C.objc_getClass("NSString"),
145 | C.sel_registerName("stringWithUTF8String:"),
146 | str)
147 |
148 | local ret = ffi.cast("bool(*)(id,SEL,id,id)", C.objc_msgSend)(pboard,
149 | C.sel_registerName("setString:forType:"),
150 | nsStr,
151 | C.NSPasteboardTypeString)
152 |
153 | return ret
154 | end
155 |
156 | local ret = main()
157 | os.exit(ret)
158 | ```
159 |
160 | Instead of including headers and linking libraries at compile time. We load the `AppKit` shared library (or framework in Apple language) at runtime. Loading `AppKit` is enough to also load the Objective-C runtime. We define our C functions, a few C types for `id` and `SEL`, and the external constant `NSPasteboardTypeString`. For each step, we use `ffi.cast` to cast the `objc_msgSend` function into the required form. Since LuaJIT can no longer determine the correct argument type from the top level C definitions, we help it out by casting the "Hello from Lua!" string into a char pointer.
161 |
162 | ## Objective-C Introspection
163 |
164 | The process for calling any method in Objective-C is the same: get the receiver class or object, get the selector, collect the arguments, cast `objc_msgSend` to the right signature, and call. However, manually casting each types back and forth is tedious and very verbose.
165 |
166 | Objective-C provides some functions for checking if a method exists for a class or object and looking up the call signature of a method. This is called introspection. We can wrap these method introspection functions in some Lua helper functions that will automatically cast `objc_msgSend` and the provided arguments to the right signature based on the method name.
167 |
168 | Our app contains a module named [`objc.lua`](https://github.com/mogenson/lua-macos-app/blob/main/LuaRepl.app/Contents/Resources/objc.lua) to handle the Lua to Objective-C dispatching. We'll have to add some functionality to this module before we can build our app's interface. Let's look at how the module's `msgSend` function looks up method information to call Objective-C methods:
169 |
170 | ```lua
171 | local function msgSend(receiver, selector, ...)
172 | local method = getMethod(receiver, selector)
173 |
174 | local char_ptr = C.method_copyReturnType(method)
175 | local objc_type = ffi.string(char_ptr)
176 | C.free(char_ptr)
177 | local c_type = type_encoding[objc_type]
178 | local signature = {}
179 | table.insert(signature, c_type)
180 | table.insert(signature, "(*)(")
181 |
182 | local num_method_args = C.method_getNumberOfArguments(method)
183 | for i = 1, num_method_args do
184 | char_ptr = C.method_copyArgumentType(method, i - 1)
185 | objc_type = ffi.string(char_ptr)
186 | C.free(char_ptr)
187 | c_type = type_encoding[objc_type]
188 | table.insert(signature, c_type)
189 | if i < num_method_args then table.insert(signature, ",") end
190 | end
191 | table.insert(signature, ")")
192 | local signature = table.concat(signature)
193 |
194 | return ffi.cast(signature, C.objc_msgSend)(receiver, selector, ...)
195 | end
196 | ```
197 |
198 | First, we get a pointer to a method of type struct `objc_method`. Using `getMethod`. This is a Lua wrapper that calls either `objc_getClassMethod` if the receiver is a class or `objc_getInstanceMethod` if the receiver is an object. We use this method pointer to query properties about the method, starting with it's return type.
199 |
200 | The `method_copyReturnType` function returns a C string (that we have to free later) with the method return type. But this string doesn't include the C type, it uses the [Objective-C type encoding notation](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100). For example "c" represents a char, "i" represents an int and, "@" means an Objective-C object. We use a table to map the Objective-C encoded type to the C type:
201 |
202 | ```lua
203 | local type_encoding = {
204 | ["c"] = "char",
205 | ["i"] = "int",
206 | ["s"] = "short",
207 | ["l"] = "long",
208 | ["q"] = "NSInteger",
209 | ["C"] = "unsigned char",
210 | ["I"] = "unsigned int",
211 | ["S"] = "unsigned short",
212 | ["L"] = "unsigned long",
213 | ["Q"] = "NSUInteger",
214 | ["f"] = "float",
215 | ["d"] = "double",
216 | ["B"] = "BOOL",
217 | ["v"] = "void",
218 | ["*"] = "char*",
219 | ["@"] = "id",
220 | ["#"] = "Class",
221 | [":"] = "SEL",
222 | }
223 | ```
224 |
225 | The rest of `msgSend` is querying the number of method arguments with `methodGetNumberOfArguments`, looking up each argument's type with `method_copyArgumentType`, converting the encoded type to a C type, and appending to a C function signature table. After we concatenate the table of C types, we have the function signature string that we can use to cast the `objc_msgSend` function. For example, calling `msgSend` with `NSApplication` for the receiver and [`setActivationPolicy:`](https://developer.apple.com/documentation/appkit/nsapplication/1428621-setactivationpolicy?language=objc) for the selector generates an anonymous C function signature of `BOOL(*)(Class,SEL,NSInteger)`.
226 |
227 | ## Metatables
228 |
229 | The Lua language uses a feature called [metatables](https://www.lua.org/pil/13.html) to set the behavior of tables and other types (similar to [dunder methods](https://mathspp.com/blog/pydonts/dunder-methods) in Python). We can use metatables to fit our helper functions into some convenient Lua syntax and continue to make our code less verbose. Our `objc.lua` module has an `objc` table to hold the Lua functions we've created so far. We use setmetatable to create a custom `__index` function that calls `objc_lookUpClass`. This function is called with an argument for the table and key every time the `objc` table is indexed but an entry is not found. We'll treat the key argument for this function as an Objective-C class name to look up. Now we can do `objc.NSApplication` to get a pointer of type `struct objc_class` for the `NSApplication` class.
230 |
231 | ```lua
232 | local objc = setmetatable({
233 | msgSend = msgSend,
234 | }, {
235 | __index = function(_, name)
236 | return C.objc_lookUpClass(name)
237 | end
238 | })
239 | ```
240 |
241 | We can also set a metatable with a custom `__index` function for the `struct objc_class` type. This time, we'll treat the key argument as the selector for a method to call on the class. Instead of a value, we create and return a function with `self` as the first argument. This matches Lua's normal syntax for calling a Lua method by using the `()` operator to call the function returned by `__index` and Lua's `:` syntax to pass `self` as the first argument.
242 |
243 | Since Objective-C uses `:` as part of selector names, but that's a reserved token for Lua, we'll write our selector names in Lua with `_` and substitute the characters before calling `sel_registerName`. If the selector name doesn't end with `_`, we'll add one so all selector names have a final `:`.
244 |
245 | ```lua
246 | ffi.metatype("struct objc_class", {
247 | __index = function(class, selector)
248 | return function(self, ...)
249 | assert(class == self)
250 | if selector:sub(-1) ~= "_" then
251 | selector = selector .. "_"
252 | end
253 | selector = selector:gsub("_", ":")
254 | return msgSend(self, C.sel_registerName(selector), ...)
255 | end
256 | end
257 | })
258 | ```
259 |
260 | With this we can do `objc.NSApplication:setActivationPolicy(1)` to lookup the `NSApplication` class and call it's `setActivationPolicy:` method. Way more concise than:
261 |
262 | ```lua
263 | local class = C.objc_getClass("NSApplication")
264 | local selector = C.sel_registerName("setActivationPolicy:")
265 | ffi.cast("BOOL(*)(Class,SEL,NSInteger)", C.objc_msgSend)(class, selector, 1)
266 | ```
267 |
268 | ## Delegates
269 |
270 | There's one last important part to the way that Objective-C frameworks like AppKit work: delegates. If you want to receive events and notifications from a class, you need to create another class with some predefined methods and set it as the delegate for the first class.
271 |
272 | Here's how to make a button and use a delegate to get a callback every time the button is clicked. We'll use this for the Eval button of our app.
273 |
274 | ```lua
275 | local ButtonDelegateClass = objc.newClass("ButtonDelegate")
276 |
277 | objc.addMethod(ButtonDelegateClass, "buttonClicked:", "v@:@",
278 | function(self, cmd, sender)
279 | print("button clicked!")
280 | end)
281 |
282 | local buttonDelegate = objc.ButtonDelegate:alloc():init()
283 |
284 | local button = objc.NSButton:buttonWithTitle_target_action("Button Title",
285 | buttonDelegate, "buttonClicked:")
286 | ```
287 |
288 | We use `newClass` to create a new delegate class and `addMethod` to register a Lua function as a callback for the `buttonClicked:` selector. Then we allocate an instance of our delegate class and create a new `NSButton` with our delegate instance as the target and the `buttonClicked:` selector as the action.
289 |
290 | Now we just need to implement `newClass` and `addMethod`. First, `newClass` creates a new class with the provided name, that inherits from the base `NSObject` class, and registers it with the Objective-C runtime.
291 |
292 | ```lua
293 | local function newClass(name)
294 | local super_class = C.objc_lookUpClass("NSObject")
295 | local class = C.objc_allocateClassPair(super_class, name, 0)
296 | C.objc_registerClassPair(class)
297 | return class
298 | end
299 | ```
300 |
301 | Next, `addMethod` accepts the class, a selector name, a string that defines the method signature in Objective-C type encoding format, and the Lua callback function. Similar to `msgSend`, we generate the C function signature from the Objective-C type encoding. We then cast the Lua callback function into a C function pointer, then into a generic `typedef id (*IMP)(id, SEL, ...)` implementation function pointer type that Objective-C expects. The class, selector, implementation, and encoded types are used to register the method for the class with `class_addMethod`.
302 |
303 | ```lua
304 | local function addMethod(class, selector, types, func)
305 | local selector = C.sel_registerName(selector)
306 |
307 | local signature = {}
308 | table.insert(signature, type_encoding[types:sub(1, 1)])
309 | table.insert(signature, "(*)(")
310 | for i = 2, #types do
311 | table.insert(signature, type_encoding[types:sub(i, i)])
312 | if i < #types then table.insert(signature, ",") end
313 | end
314 | table.insert(signature, ")")
315 | local signature = table.concat(signature)
316 |
317 | local imp = ffi.cast("IMP", ffi.cast(signature, func))
318 | C.class_addMethod(class, selector, imp, types)
319 | end
320 | ```
321 |
322 | ## App Layout
323 |
324 | We now have all the Objective-C pieces we need to create our app. The main function of our LuaREPL app can be seen below:
325 |
326 | ```lua
327 | local function main()
328 | local NSApp = objc.NSApplication:sharedApplication()
329 | NSApp:setActivationPolicy(NSApplicationActivationPolicyRegular)
330 | makeAppMenu(NSApp)
331 |
332 | local scrollView = objc.NSTextView:scrollableTextView()
333 |
334 | local textView = scrollView.documentView
335 |
336 | local textField = objc.NSTextField:alloc():init()
337 | textField.placeholderString = NSString("Enter Lua Code...")
338 |
339 | local button = makeAppDelegate(NSApp, textField, textView)
340 |
341 | local hStack = objc.NSStackView:alloc():init()
342 | hStack:addView_inGravity(textField, NSStackViewGravityLeading)
343 | hStack:addView_inGravity(button, NSStackViewGravityTrailing)
344 |
345 | local vStack = objc.NSStackView:alloc():init()
346 | vStack.orientation = NSUserInterfaceLayoutOrientationVertical
347 | vStack.edgeInsets = ffi.new("NSEdgeInsets", { top = 10, left = 10, bottom = 10, right = 10 })
348 | vStack:addView_inGravity(scrollView, NSStackViewGravityTop)
349 | vStack:addView_inGravity(hStack, NSStackViewGravityBottom)
350 |
351 | local rect = ffi.new("CGRect", { origin = { x = 0, y = 0 }, size = { width = 200, height = 300 } })
352 | local styleMask = bit.bor(NSWindowStyleMaskTitled, NSWindowStyleMaskClosable, NSWindowStyleMaskMiniaturizable,
353 | NSWindowStyleMaskResizable)
354 |
355 | local window = objc.NSWindow:alloc():initWithContentRect_styleMask_backing_defer(rect, styleMask,
356 | NSBackingStoreBuffered, NO)
357 | window.contentView = vStack
358 | window:setTitle(NSString("LuaREPL"))
359 | window:makeKeyAndOrderFront(window)
360 |
361 | NSApp:run()
362 | end
363 | ```
364 |
365 | The layout is pretty simple. There is vertical stack that contains a text view within a scroll view (for showing Lua REPL output) and a horizontal stack. The horizontal stack contains an editable text field and an button for code evaluation. The end result looks like the image at the beginning of this article.
366 |
367 | A delegate is set up to call the following runRepl function on a click of the eval button. This function takes the text field and text view. It collects the input string in the text field and echos it to the text view after a ">" character, so the user can see their input history. It also calls the `repl:eval` method with the input string and adds either the result or error message to the text view. Finally, the text view is scrolled to the bottom.
368 |
369 | ```lua
370 | local function runRepl(textField, textView)
371 | local input = ffi.string(textField.stringValue:UTF8String())
372 | textField.stringValue = NSString("")
373 |
374 | local output = {}
375 | table.insert(output, "> ")
376 | table.insert(output, input)
377 | table.insert(output, "\n")
378 |
379 | local result, err = repl:eval(input)
380 | if result then
381 | table.insert(output, result)
382 | table.insert(output, "\n")
383 | end
384 | if err then
385 | table.insert(output, err)
386 | table.insert(output, "\n")
387 | end
388 |
389 | textView.string = textView.string:stringByAppendingString(NSString(table.concat(output)))
390 | textView:scrollToEndOfDocument(textView)
391 | end
392 | ```
393 |
394 | ## REPL
395 |
396 | Since Lua is an interpreted language, it can evaluate new chunks of code at runtime. As seen below, Lua provides a few utilities to accomplish this:
397 |
398 | ```lua
399 | function repl:eval(chunk)
400 | local func, err = loadstring(chunk)
401 |
402 | local ret = nil
403 | if func then
404 | local results = { pcall(func) }
405 | if results[1] then
406 | ret = tostring(results[2])
407 | for i = 3, #results do
408 | ret = ret .. ", " .. tostring(results[i])
409 | end
410 | else
411 | err = results[2]
412 | end
413 | end
414 |
415 | return ret, err
416 | end
417 | ```
418 |
419 | The `loadstring` function will take a string, compile it to bytecode, and return a function to run this bytecode. If there is a compilation error the returned function is nil, and the second returned argument is an error string.
420 |
421 | Since user entered code may have mistakes or errors that could cause a crash, we will run the compiled function in a protected environment, with the `pcall` function. This is Lua's equivalent of Python's `try` and `except`.
422 |
423 | On success `pcall` returns `true`, then some number of return arguments from the compiled function. On failure `pcall` returns `false` then an error message. Since we don't know how many arguments the user's code will return, we collect all the return arguments into a table so we can count and iterate over them. On success, we concatenate all of the compiled function's return arguments into a comma-separated string to print to the text view area.
424 |
425 | Only values returned by the compiled function will be printed to the text output. Code such as `a = 1` or `2 + 3` will not produce a printed output. Explicitly returning an expression with `a = 1 return a` or `return 2 + 3` will print the intended output.
426 |
427 | ## Packaging
428 |
429 | Now we have a fully functional LuaREPL app. Let's package everything into a MacOS app bundle. This is actually easy to do manually. MacOS apps are just directories and files within a top level directory that ends in `.app`. Here's our app:
430 |
431 |
432 |
433 | The main executable of an app goes into the `Contents/MacOS` directory and any other code or assets goes into the `Contents/Resources` directory. Finally, `Info.plist` is an xml file that sets some properties of the app, such as the name of the main executable, and app icon. This app has the absolute minimal Info.plist contents:
434 |
435 | ```xml
436 |
437 |
438 |
439 |
440 | CFBundleExecutable
441 | main.lua
442 | CFBundleIdentifier
443 | com.example.LuaREPL
444 | CFBundleIconFile
445 | lua
446 |
447 |
448 | ```
449 |
450 | ## Shebang
451 |
452 | The `Info.plist` file tells MacOS to launch main.lua when the app is opened. However, MacOS does not know the location of the `luajit` binary or how to run a Lua file. We use a neat trick to first run the `main.lua` file as a shell script, then switch to a Lua script part way through:
453 |
454 | ```lua
455 | #!/bin/sh
456 | --[[ 2>/dev/null
457 | resources="$(dirname "$0")/../Resources"
458 | export LUA_PATH="$resources/?.lua;$resources/?/init.lua"
459 | exec "$resources/luajit" "$0" "$@"
460 | --]]
461 |
462 | local objc = require("objc")
463 | local repl = require("repl")
464 | local ffi = require("ffi")
465 | local bit = require("bit")
466 |
467 | objc.loadFramework("AppKit")
468 | ... continued ...
469 | ```
470 |
471 | The `#!/bin/sh` line is known as a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) and calls `/bin/sh` to run the script. The next few lines are interpreted as shell code. The first line, `--[[` is an invalid shell command, but `2>/dev/null` silences the error message produced. The next line takes the absolute path to the `$0` argument, which is the name of the script being run, `main.lua`, appends the relative path `../Resources`, and sets the `resources` variable. The next line sets the `LUA_PATH` environmental variable using the previously defined `resources` variable. Lua searches `LUA_PATH` for modules to load when require is called to load a Lua module (like `objc.lua`). This means that no matter where our app is located, Lua will be able to load modules from the apps Resources directory. Finally, the next line replaces the shell process with an invocation of `luajit`, at the path `../Resources/luajit`, passing the name of the `main.lua` file, and using `$@` to pass any extra command line arguments.
472 |
473 | Now `luajit` runs the `main.lua` file starting from the top again. The `#!/bin/sh` line is ignored by Lua, and the following shell code is wrapped in a multi-line Lua comment via `--[[` `]]--` so it is not evaluated. The rest of the `main.lua` file is regular Lua that starts our app!
474 |
475 | ## Conclusion
476 |
477 | Thanks for following along with this journey to create a MacOS app using only Lua. You should be able to clone and run the app directly from the GitHub repo.
478 |
479 | If you share this app with a friend, they can edit `main.lua` in a text editor and create a complete new app. No recompling or downloading developer tools necessary. Also the only dependences are the single `luajit` binary, at 609 kB, and `objc.lua`, at 286 lines. That's way smaller than bundling a Python interpreter and all of the required modules!
480 |
481 | Where to next? Using the Lua to Objective-C approach from this article, we can use any part of Apple's frameworks. This app could be ported to run on an iPhone or iPad with [UIKit](https://developer.apple.com/documentation/uikit?language=objc), or we could add GPU accelerated graphics with the [Metal](https://developer.apple.com/documentation/metal/metal_sample_code_library/rendering_a_scene_with_deferred_lighting_in_objective-c?language=objc) framework!
482 |
483 | ## Acknowledgements
484 |
485 | I'd like to thank the authors of [fjolnir/TLC](https://github.com/fjolnir/TLC) and and [luapower/objc](https://github.com/luapower/objc) for their Objective-C Lua implementations. Unfortunately neither of these projects still run on modern versions of MacOS, but their designs and ideas were immensely helpful.
486 |
487 | Thanks to Nathan Craddock for showing the relationship between Objective-C and C in the [NSPasteboard example](https://nathancraddock.com/blog/2023/writing-to-the-clipboard-the-hard-way/).
488 |
489 | Thanks to A. Wilcox and Chloé Vulquin for the LuaJIT universal binary [build instructions](https://github.com/mogenson/lua-macos-app/issues/2).
490 |
491 | ## Appendix
492 |
493 | There are two other pieces of the Objective-C runtime that are no longer fully functional on modern versions of MacOS: protocols and bridgesupport.
494 |
495 | A protocol is a set of methods a delegate should implement. You can lookup a protocol via name with `objc_getProtocol` and get type information for a method, similar to how we can lookup a method from a class or object in `msgSend`. Unfortunately, Objective-C will now only generate a protocol if it is used at compile time. Since we're not compiling any Objective-C code, `objc_getProtocol` returns `NULL`.
496 |
497 | Bridgesupport files are xml files shipped with Apple frameworks that contain class names, method names, protocol methods, and type encodings. These files can be parsed to generate the correct selector name or method type encoding. However, these files have not been updated in years and now have errors and incomplete data.
498 |
499 | The result is that for a Lua function like `addMethod`, the user needs to provide the correct Objective-C type encoding string. There's no longer a way to look this information up for an existing method at runtime.
500 |
--------------------------------------------------------------------------------