init.lua
  1  --- === SpoonInstall ===
  2  ---
  3  --- Install and manage Spoons and Spoon repositories
  4  ---
  5  --- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/SpoonInstall.spoon.zip)
  6  
  7  local obj={}
  8  obj.__index = obj
  9  
 10  -- Metadata
 11  obj.name = "SpoonInstall"
 12  obj.version = "0.1"
 13  obj.author = "Diego Zamboni <diego@zzamboni.org>"
 14  obj.homepage = "https://github.com/Hammerspoon/Spoons"
 15  obj.license = "MIT - https://opensource.org/licenses/MIT"
 16  
 17  --- SpoonInstall.logger
 18  --- Variable
 19  --- Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.
 20  obj.logger = hs.logger.new('SpoonInstall')
 21  
 22  --- SpoonInstall.repos
 23  --- Variable
 24  --- Table containing the list of available Spoon repositories. The key
 25  --- of each entry is an identifier for the repository, and its value
 26  --- is a table with the following entries:
 27  ---  * desc - Human-readable description for the repository
 28  ---  * branch - Active git branch for the Spoon files
 29  ---  * url - Base URL for the repository. For now the repository is assumed to be hosted in GitHub, and the URL should be the main base URL of the repository. Repository metadata needs to be stored under `docs/docs.json`, and the Spoon zip files need to be stored under `Spoons/`.
 30  ---
 31  --- Default value:
 32  --- ```
 33  --- {
 34  ---    default = {
 35  ---       url = "https://github.com/Hammerspoon/Spoons",
 36  ---       desc = "Main Hammerspoon Spoon repository",
 37  ---       branch = "master",
 38  ---    }
 39  --- }
 40  --- ```
 41  obj.repos = {
 42     default = {
 43        url = "https://github.com/Hammerspoon/Spoons",
 44        desc = "Main Hammerspoon Spoon repository",
 45        branch = "master",
 46     }
 47  }
 48  
 49  --- SpoonInstall.use_syncinstall
 50  --- Variable
 51  --- If `true`, `andUse()` will update repos and install packages synchronously. Defaults to `false`.
 52  ---
 53  --- Keep in mind that if you set this to `true`, Hammerspoon will
 54  --- block until all missing Spoons are installed, but the notifications
 55  --- will happen at a more "human readable" rate.
 56  obj.use_syncinstall = false
 57  
 58  -- Execute a command and return its output with trailing EOLs trimmed. If the command fails, an error message is logged.
 59  local function _x(cmd, errfmt, ...)
 60     local output, status = hs.execute(cmd)
 61     if status then
 62        local trimstr = string.gsub(output, "\n*$", "")
 63        return trimstr
 64     else
 65        obj.logger.ef(errfmt, ...)
 66        return nil
 67     end
 68  end
 69  
 70  -- --------------------------------------------------------------------
 71  -- Spoon repository management
 72  
 73  -- Internal callback to process and store the data from docs.json about a repository
 74  -- callback is called with repo as arguments, only if the call is successful
 75  function obj:_storeRepoJSON(repo, callback, status, body, hdrs)
 76     local success=nil
 77     if (status < 100) or (status >= 400) then
 78        self.logger.ef("Error fetching JSON data for repository '%s'. Error code %d: %s", repo, status, body or "<no error message>")
 79     else
 80        local json = hs.json.decode(body)
 81        if json then
 82           self.repos[repo].data = {}
 83           for i,v in ipairs(json) do
 84              v.download_url = self.repos[repo].download_base_url .. v.name .. ".spoon.zip"
 85              self.repos[repo].data[v.name] = v
 86           end
 87           self.logger.df("Updated JSON data for repository '%s'", repo)
 88           success=true
 89        else
 90           self.logger.ef("Invalid JSON received for repository '%s': %s", repo, body)
 91        end
 92     end
 93     if callback then
 94        callback(repo, success)
 95     end
 96     return success
 97  end
 98  
 99  -- Internal function to return the URL of the docs.json file based on the URL of a GitHub repo
100  function obj:_build_repo_json_url(repo)
101     if self.repos[repo] and self.repos[repo].url then
102        local branch = self.repos[repo].branch or "master"
103        self.repos[repo].json_url = string.gsub(self.repos[repo].url, "/$", "") .. "/raw/"..branch.."/docs/docs.json"
104        self.repos[repo].download_base_url = string.gsub(self.repos[repo].url, "/$", "") .. "/raw/"..branch.."/Spoons/"
105        return true
106     else
107        self.logger.ef("Invalid or unknown repository '%s'", repo)
108        return nil
109     end
110  end
111  
112  --- SpoonInstall:asyncUpdateRepo(repo, callback)
113  --- Method
114  --- Asynchronously fetch the information about the contents of a Spoon repository
115  ---
116  --- Parameters:
117  ---  * repo - name of the repository to update. Defaults to `"default"`.
118  ---  * callback - if given, a function to be called after the update finishes (also if it fails). The function will receive the following arguments:
119  ---    * repo - name of the repository
120  ---    * success - boolean indicating whether the update succeeded
121  ---
122  --- Returns:
123  ---  * `true` if the update was correctly initiated (i.e. the repo name is valid), `nil` otherwise
124  ---
125  --- Notes:
126  ---  * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
127  function obj:asyncUpdateRepo(repo, callback)
128     if not repo then repo = 'default' end
129     if self:_build_repo_json_url(repo) then
130        hs.http.asyncGet(self.repos[repo].json_url, nil, hs.fnutils.partial(self._storeRepoJSON, self, repo, callback))
131        return true
132     else
133        return nil
134     end
135  end
136  
137  --- SpoonInstall:updateRepo(repo)
138  --- Method
139  --- Synchronously fetch the information about the contents of a Spoon repository
140  ---
141  --- Parameters:
142  ---  * repo - name of the repository to update. Defaults to `"default"`.
143  ---
144  --- Returns:
145  ---  * `true` if the update was successful, `nil` otherwise
146  ---
147  --- Notes:
148  ---  * This is a synchronous call, which means Hammerspoon will be blocked until it finishes. For use in your configuration files, it's advisable to use `SpoonInstall.asyncUpdateRepo()` instead.
149  ---  * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
150  function obj:updateRepo(repo)
151     if not repo then repo = 'default' end
152     if self:_build_repo_json_url(repo) then
153        local a,b,c = hs.http.get(self.repos[repo].json_url)
154        return self:_storeRepoJSON(repo, nil, a, b, c)
155     else
156        return nil
157     end
158  end
159  
160  --- SpoonInstall:asyncUpdateAllRepos()
161  --- Method
162  --- Asynchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`
163  ---
164  --- Parameters:
165  ---  * None
166  ---
167  --- Returns:
168  ---  * None
169  ---
170  --- Notes:
171  ---  * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
172  function obj:asyncUpdateAllRepos()
173     for k,v in pairs(self.repos) do
174        self:asyncUpdateRepo(k)
175     end
176  end
177  
178  --- SpoonInstall:updateAllRepos()
179  --- Method
180  --- Synchronously fetch the information about the contents of all Spoon repositories registered in `SpoonInstall.repos`
181  ---
182  --- Parameters:
183  ---  * None
184  ---
185  --- Returns:
186  ---  * None
187  ---
188  --- Notes:
189  ---  * This is a synchronous call, which means Hammerspoon will be blocked until it finishes.
190  ---  * For now, the repository data is not persisted, so you need to update it after every restart if you want to use any of the install functions.
191  function obj:updateAllRepos()
192     for k,v in pairs(self.repos) do
193        self:updateRepo(k)
194     end
195  end
196  
197  --- SpoonInstall:repolist()
198  --- Method
199  --- Return a sorted list of registered Spoon repositories
200  ---
201  --- Parameters:
202  ---  * None
203  ---
204  --- Returns:
205  ---  * Table containing a list of strings with the repository identifiers
206  function obj:repolist()
207     local keys={}
208     -- Create sorted list of keys
209     for k,v in pairs(self.repos) do table.insert(keys, k) end
210     table.sort(keys)
211     return keys
212  end
213  
214  --- SpoonInstall:search(pat)
215  --- Method
216  --- Search repositories for a pattern
217  ---
218  --- Parameters:
219  ---  * pat - Lua pattern that will be matched against the name and description of each spoon in the registered repositories. All text is converted to lowercase before searching it, so you can use all-lowercase in your pattern.
220  ---
221  --- Returns:
222  ---  * Table containing a list of matching entries. Each entry is a table with the following keys:
223  ---    * name - Spoon name
224  ---    * desc - description of the spoon
225  ---    * repo - identifier in the repository where the match was found
226  function obj:search(pat)
227     local res={}
228     for repo,v in pairs(self.repos) do
229        if v.data then
230           for spoon,rec in pairs(v.data) do
231              if string.find(string.lower(rec.name .. "\n" .. rec.desc), pat) then
232                 table.insert(res, { name = rec.name, desc = rec.desc, repo = repo })
233              end
234           end
235        else
236           self.logger.ef("Repository data for '%s' not available - call spoon.SpoonInstall:updateRepo('%s'), then try again.", repo, repo)
237        end
238     end
239     return res
240  end
241  
242  -- --------------------------------------------------------------------
243  -- Spoon installation
244  
245  -- Internal callback function to finalize the installation of a spoon after the zip file has been downloaded.
246  -- callback, if given, is called with (urlparts, success) as arguments
247  function obj:_installSpoonFromZipURLgetCallback(urlparts, callback, status, body, headers)
248     local success=nil
249     if (status < 100) or (status >= 400) then
250        self.logger.ef("Error downloading %s. Error code %d: %s", urlparts.absoluteString, status, body or "<none>")
251     else
252        -- Write the zip file to disk in a temporary directory
253        local tmpdir=_x("/usr/bin/mktemp -d", "Error creating temporary directory to download new spoon.")
254        if tmpdir then
255           local outfile = string.format("%s/%s", tmpdir, urlparts.lastPathComponent)
256           local f=assert(io.open(outfile, "w"))
257           f:write(body)
258           f:close()
259  
260           -- Check its contents - only one *.spoon directory should be in there
261           output = _x(string.format("/usr/bin/unzip -l %s '*.spoon/' | /usr/bin/awk '$NF ~ /\\.spoon\\/$/ { print $NF }' | /usr/bin/wc -l", outfile),
262                       "Error examining downloaded zip file %s, leaving it in place for your examination.", outfile)
263           if output then
264              if (tonumber(output) or 0) == 1 then
265                 -- Uncompress the zip file
266                 local outdir = string.format("%s/Spoons", hs.configdir)
267                 if _x(string.format("/usr/bin/unzip -o %s -d %s 2>&1", outfile, outdir),
268                       "Error uncompressing file %s, leaving it in place for your examination.", outfile) then
269                    -- And finally, install it using Hammerspoon itself
270                    self.logger.f("Downloaded and installed %s", urlparts.absoluteString)
271                    _x(string.format("/bin/rm -rf '%s'", tmpdir), "Error removing directory %s", tmpdir)
272                    success=true
273                 end
274              else
275                 self.logger.ef("The downloaded zip file %s is invalid - it should contain exactly one spoon. Leaving it in place for your examination.", outfile) 
276              end
277           end
278        end
279     end
280     if callback then
281        callback(urlparts, success)
282     end
283     return success
284  end
285  
286  --- SpoonInstall:asyncInstallSpoonFromZipURL(url, callback)
287  --- Method
288  --- Asynchronously download a Spoon zip file and install it.
289  ---
290  --- Parameters:
291  ---  * url - URL of the zip file to install.
292  ---  * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:
293  ---    * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file
294  ---    * success - boolean indicating whether the installation was successful
295  ---
296  --- Returns:
297  ---  * `true` if the installation was correctly initiated (i.e. the URL is valid), `false` otherwise
298  function obj:asyncInstallSpoonFromZipURL(url, callback)
299     local urlparts = hs.http.urlParts(url)
300     local dlfile = urlparts.lastPathComponent
301     if dlfile and dlfile ~= "" and urlparts.pathExtension == "zip" then
302        hs.http.asyncGet(url, nil, hs.fnutils.partial(self._installSpoonFromZipURLgetCallback, self, urlparts, callback))
303        return true
304     else
305        self.logger.ef("Invalid URL %s, must point to a zip file", url)
306        return nil
307     end
308  end
309  
310  --- SpoonInstall:installSpoonFromZipURL(url)
311  --- Method
312  --- Synchronously download a Spoon zip file and install it.
313  ---
314  --- Parameters:
315  ---  * url - URL of the zip file to install.
316  ---
317  --- Returns:
318  ---  * `true` if the installation was successful, `nil` otherwise
319  function obj:installSpoonFromZipURL(url)
320     local urlparts = hs.http.urlParts(url)
321     local dlfile = urlparts.lastPathComponent
322     if dlfile and dlfile ~= "" and urlparts.pathExtension == "zip" then
323        a,b,c=hs.http.get(url)
324        return self:_installSpoonFromZipURLgetCallback(urlparts, nil, a, b, c)
325     else
326        self.logger.ef("Invalid URL %s, must point to a zip file", url)
327        return nil
328     end
329  end
330  
331  -- Internal function to check if a Spoon/Repo combination is valid
332  function obj:_is_valid_spoon(name, repo)
333     if self.repos[repo] then
334        if self.repos[repo].data then
335           if self.repos[repo].data[name] then
336              return true
337           else
338              self.logger.ef("Spoon '%s' does not exist in repository '%s'. Please check and try again.", name, repo)
339           end
340        else
341           self.logger.ef("Repository data for '%s' not available - call spoon.SpoonInstall:updateRepo('%s'), then try again.", repo, repo)
342        end
343     else
344        self.logger.ef("Invalid or unknown repository '%s'", repo)
345     end
346     return nil
347  end
348  
349  --- SpoonInstall:asyncInstallSpoonFromRepo(name, repo, callback)
350  --- Method
351  --- Asynchronously install a Spoon from a registered repository
352  ---
353  --- Parameters:
354  ---  * name - Name of the Spoon to install.
355  ---  * repo - Name of the repository to use. Defaults to `"default"`
356  ---  * callback - if given, a function to call after the installation finishes (also if it fails). The function receives the following arguments:
357  ---    * urlparts - Result of calling `hs.http.urlParts` on the URL of the Spoon zip file
358  ---    * success - boolean indicating whether the installation was successful
359  ---
360  --- Returns:
361  ---  * `true` if the installation was correctly initiated (i.e. the repo and spoon name were correct), `false` otherwise.
362  function obj:asyncInstallSpoonFromRepo(name, repo, callback)
363     if not repo then repo = 'default' end
364     if self:_is_valid_spoon(name, repo) then
365        self:asyncInstallSpoonFromZipURL(self.repos[repo].data[name].download_url, callback)
366     end
367     return nil
368  end
369  
370  --- SpoonInstall:installSpoonFromRepo(name, repo)
371  --- Method
372  --- Synchronously install a Spoon from a registered repository
373  ---
374  --- Parameters:
375  ---  * name = Name of the Spoon to install.
376  ---  * repo - Name of the repository to use. Defaults to `"default"`
377  ---
378  --- Returns:
379  ---  * `true` if the installation was successful, `nil` otherwise.
380  function obj:installSpoonFromRepo(name, repo, callback)
381     if not repo then repo = 'default' end
382     if self:_is_valid_spoon(name, repo) then
383        return self:installSpoonFromZipURL(self.repos[repo].data[name].download_url)
384     end
385     return nil
386  end
387  
388  --- SpoonInstall:andUse(name, arg)
389  --- Method
390  --- Declaratively install, load and configure a Spoon
391  ---
392  --- Parameters:
393  ---  * name - the name of the Spoon to install (without the `.spoon` extension). If the Spoon is already installed, it will be loaded using `hs.loadSpoon()`. If it is not installed, it will be installed using `SpoonInstall:asyncInstallSpoonFromRepo()` and then loaded.
394  ---  * arg - if provided, can be used to specify the configuration of the Spoon. The following keys are recognized (all are optional):
395  ---    * repo - repository from where the Spoon should be installed if not present in the system, as defined in `SpoonInstall.repos`. Defaults to `"default"`.
396  ---    * config - a table containing variables to be stored in the Spoon object to configure it. For example, `config = { answer = 42 }` will result in `spoon.<LoadedSpoon>.answer` being set to 42.
397  ---    * hotkeys - a table containing hotkey bindings. If provided, will be passed as-is to the Spoon's `bindHotkeys()` method. The special string `"default"` can be given to use the Spoons `defaultHotkeys` variable, if it exists.
398  ---    * fn - a function which will be called with the freshly-loaded Spoon object as its first argument.
399  ---    * loglevel - if the Spoon has a variable called `logger`, its `setLogLevel()` method will be called with this value.
400  ---    * start - if `true`, call the Spoon's `start()` method after configuring everything else.
401  ---    * disable - if `true`, do nothing. Easier than commenting it out when you want to temporarily disable a spoon.
402  ---
403  --- Returns:
404  ---  * None
405  function obj:andUse(name, arg)
406     if not arg then arg = {} end
407     if arg.disable then return true end
408     if hs.spoons.use(name, arg, true) then
409        return true
410     else
411        local repo = arg.repo or "default"
412        if self.repos[repo] then
413           if self.repos[repo].data then
414              local load_and_config = function(_, success)
415                 if success then
416                    hs.notify.show("Spoon installed by SpoonInstall", name .. ".spoon is now available", "")
417                    hs.spoons.use(name, arg)
418                 else
419                    obj.logger.ef("Error installing Spoon '%s' from repo '%s'", name, repo)
420                 end
421              end
422              if self.use_syncinstall then
423                 return load_and_config(nil, self:installSpoonFromRepo(name, repo))
424              else
425                 self:asyncInstallSpoonFromRepo(name, repo, load_and_config)
426              end
427           else
428              local update_repo_and_continue = function(_, success)
429                 if success then
430                    obj:andUse(name, arg)
431                 else
432                    obj.logger.ef("Error updating repository '%s'", repo)
433                 end
434              end
435              if self.use_syncinstall then
436                 return update_repo_and_continue(nil, self:updateRepo(repo))
437              else
438                 self:asyncUpdateRepo(repo, update_repo_and_continue)
439              end
440           end
441        else
442           obj.logger.ef("Unknown repository '%s' for Spoon", repo, name)
443        end
444     end
445  end
446  
447  return obj