Having heavily used Scriptable’s Keychain
and Device
modules inside my shortcuts, I have developed some solutions for both approaches I thought I would share here.
The Pasteboard
approach
Because the scripts I pass are often one liners, but vary a lot in content, I wanted a generic way to have Scriptable evaluate some JS from Shortcuts without actually opening the Scriptable app. The result is a two part setup. On Scriptable’s side, I have a script Scriptable Intent with his code:
var result
try {
let code = Pasteboard.pasteString()
if (code == null) throw new EvalError('No code to evaluate.')
result = {kind: 'Result', value: Function(code)()}
} catch (e) {
result = {kind: e.name, value: e.message}
}
Pasteboard.copyString(JSON.stringify(result))
Script.complete()
On Shortcuts’ side, I have a shortcut that will take text input, save the current pasteboard, put the input on the pasteboard, call the intent (you need it to run in Scriptable at least once for Siri, and hence Shortcuts, to pick it up), turn the JSON on the pasteboard into a dictionary, restore the pasteboard and return the dictionary. You can then pass your code to it via the Call Shortcut action (just make sure it includes a top level return
statement), and the returned dictionary allows you to handle errors (when the dictionary’s kind
key contains “Error”) or use the result value
if no error occurred as your needs dictate, all without clobbering your pasteboard. Find the shortcut here for your convenience.
The x-callback
approach
As convenient as calling Scriptable via the intent is, you sometimes need to switch to the app, notably when you want to display UI elements (like an Alert
). The call itself is pretty straightforward, but I found handling the result callbacks a bit of a hassle, so I generalised the thing away into a class, CallbackHandler
. Basically, it looks like this:
// Handle the x-callback results, with fallbacks when no callback URLs are provided.
class CallbackHandler {
constructor (parameters) {
const urlBuilderFor = base => {
return base == null ? null : payload => {
let url = base
if (payload != null) {
let parts = Object.keys(payload).reduce((acc, cur) => {
if (payload[cur] != null) {
let arg = encodeURIComponent(cur)
let val = encodeURIComponent(JSON.stringify(payload[cur]))
acc.push(`${arg}=${val}`)
}
return acc
}, [])
if (parts.length > 0) url += `?${parts.join('&')}`
}
return url
}
}
this.successURL = urlBuilderFor(parameters['x-success'])
this.cancelURL = urlBuilderFor(parameters['x-cancel'])
this.errorURL = urlBuilderFor(parameters['x-error'])
}
success (message, payload) {
if (this.successURL != null) {
let url = this.successURL(payload)
Safari.open(url)
} else {
if (message == null) throw(new Error('Missing message String!'))
console.log(message)
}
}
cancel (message, payload) {
if (this.cancelURL != null) {
let url = this.cancelURL(payload)
Safari.open(url)
} else {
if (message == null) throw(new Error('Missing message String!'))
console.log(message)
}
}
error (err) {
if (this.errorURL != null) {
let url = this.errorURL({errorCode: err.name, errorMessage: err.message})
Safari.open(url)
} else {
throw err
}
}
}
which allows me to do:
const params = URLScheme.allParameters()
const handle = new CallbackHandler(params)
// depending on outcome:
handle.success('All is well', {foo: 'bar'})
handle.cancel('Aborted by user', {foo: 'bar'})
handle.error(err)
and automagically get the correct callback URL called, with all parameters set and properly escaped, or a log message written when some callback URLs are unspecified.
Now, as the class definition is rather a handful of code to include into your scripts, I suggest you use my module importing hack and wrap the class code into a module, i.e. its own file with the following content:
const exports = {
CallbackHandler: // replace comment by the full class code shown above
}
then require()
it as per the linked post.