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