Created
March 29, 2023 17:23
-
-
Save stephancasas/830218f2cb576855ddeddac9a7f9ecd0 to your computer and use it in GitHub Desktop.
Pocket Casts AppleScript Automation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env osascript -l JavaScript | |
/** | |
* ------------------------------------------------------------------------------ | |
* Pocket Casts Automation / Skip Forward | |
* ------------------------------------------------------------------------------ | |
* | |
* Skip forward 45 seconds in Pocket Casts for Mac | |
* | |
* Note: the window must be open. It may be *hidden*, but it must be open. | |
* | |
* Author: Stephan Casas | |
* Based on FastAX: https://github.com/stephancasas/fast-ax | |
* | |
*/ | |
const App = Application.currentApplication(); | |
App.includeStandardAdditions = true; | |
ObjC.import('CoreGraphics'); | |
ObjC.bindFunction('AXAPIEnabled', ['bool', []]); | |
ObjC.bindFunction('AXIsProcessTrusted', ['bool', []]); | |
ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['unsigned int']]); | |
ObjC.bindFunction('AXUIElementCopyAttributeValue', [ | |
'int', | |
['id', 'id', 'id *'], | |
]); | |
ObjC.bindFunction('AXUIElementCopyAttributeNames', ['int', ['id', 'id *']]); | |
ObjC.bindFunction('AXUIElementCopyActionNames', ['int', ['id', 'id *']]); | |
ObjC.bindFunction('AXUIElementPerformAction', ['int', ['id', 'id']]); | |
Ref.prototype.$ = function () { | |
return ObjC.deepUnwrap(ObjC.castRefToObject(this)); | |
}; | |
class AXUIElement { | |
__element; | |
__cachedChildren; | |
__cachedFirstChild; | |
constructor(element) { | |
this.__element = element; | |
this.linkAttributes(); | |
this.linkActions(); | |
} | |
linkAttributes() { | |
const attributes = this.attributes; | |
Object.keys(attributes) | |
.filter((key) => !key.match(/(windows|children|actions)/)) | |
.forEach((key) => Object.assign(this, { [key]: attributes[key] })); | |
} | |
linkActions() { | |
const actions = this.actions; | |
Object.keys(actions).forEach((key) => | |
Object.assign(this, { [key]: actions[key] }), | |
); | |
} | |
traverse(role, depth = 1) { | |
let child = this; | |
for (let i = 0; i < depth; i++) { | |
const newChild = child.firstChildWhereLike('role', role); | |
if (!newChild) { | |
break; | |
} | |
child = newChild; | |
} | |
return child; | |
} | |
makeFunctionForLocatorAncestry(axUiElementAncestry, withComments = true) { | |
const indices = axUiElementAncestry | |
.map((ancestor, i) => ({ | |
index: ancestor.$children.findIndex( | |
(child) => child == axUiElementAncestry[i + 1], | |
), | |
role: ancestor.role ?? '<AXUnknownRole>', | |
description: ancestor.description ?? '<AXEmptyDescription>', | |
roleDescription: ancestor.roleDescription ?? '<AXEmptyRoleDescription>', | |
})) | |
.slice(0, -1); | |
const greatest = { | |
role: | |
[{ role: 'ROLE' }, ...indices] | |
.map(({ role }) => role.length) | |
.sort((a, b) => a > b) | |
.slice(-1)[0] ?? 0, | |
description: | |
[{ description: 'DESCRIPTION' }, ...indices] | |
.map(({ description }) => description.length) | |
.sort((a, b) => a > b) | |
.slice(-1)[0] ?? 0, | |
roleDescription: | |
[{ roleDescription: 'ROLE DESC.' }, ...indices] | |
.map(({ roleDescription }) => roleDescription.length) | |
.sort((a, b) => a > b) | |
.slice(-1)[0] ?? 0, | |
}; | |
const pad = (word, length) => | |
`${word}${ | |
word.length < length | |
? `${Array(length + 1 - word.length).join(' ')}` | |
: '' | |
}`; | |
const makeComment = ({ i, role, description, roleDescription }) => | |
!withComments | |
? `${i == indices.length - 1 ? ';' : ''}` | |
: `${i == indices.length - 1 ? '; ' : ' '}/*${ | |
i == 0 ? '****' : i == indices.length - 1 ? '***' : '****' | |
} ${pad(role, greatest.role)} | ${pad( | |
roleDescription, | |
greatest.roleDescription, | |
)} | ${pad( | |
description, | |
greatest.description + greatest.role + greatest.roleDescription >= | |
44 | |
? greatest.description | |
: greatest.description + | |
greatest.role + | |
greatest.roleDescription - | |
44, | |
)} ****/`; | |
const makeCommentHeader = () => | |
`${pad('ROLE', greatest.role)} | ${pad( | |
'ROLE DESC.', | |
greatest.roleDescription, | |
)} | ${pad('DESCRIPTION', greatest.description)}`; | |
const path = indices | |
.map( | |
({ index, role, description, roleDescription }, i) => | |
` .$children[${index}]${makeComment({ | |
i, | |
role, | |
description, | |
roleDescription, | |
})}`, | |
) | |
.join('\n'); | |
return `const myElement = (root) => {\n return root /********* ${makeCommentHeader()} ${'***'}*/\n${path}\n};`; | |
} | |
locate(using, forInstance = 1, __ancestry = [], __found = 0) { | |
let match = using(this); | |
__found = __found + (match ? 1 : 0); | |
if (match && __found == forInstance) { | |
return [...__ancestry, this]; | |
} | |
let children = this.$children ?? []; | |
for (let i = 0; i < children.length; i++) { | |
const child = children[i]; | |
const search = child.locate( | |
using, | |
forInstance, | |
[...__ancestry, this], | |
__found, | |
); | |
if (!!search) { | |
return search; | |
} | |
} | |
return null; | |
} | |
locateWhereLike(property, value, instance = 1) { | |
const regex = new RegExp(value, 'gi'); | |
return this.locate( | |
(child) => `${child[property] ?? ''}`.match(regex), | |
instance, | |
); | |
} | |
locateWhereHasActionLike(name, instance = 1) { | |
return this.locate( | |
(child) => !!child.firstActionLike(name, null), | |
instance, | |
); | |
} | |
childrenHavingLike(property, value) { | |
return this.children | |
.map((child) => (child.locateWhereLike(property, value) ?? [null]).pop()) | |
.filter((child) => !!child); | |
} | |
childrenHavingActionLike(name) { | |
return this.children | |
.map((child) => (child.locateWhereHasActionLike(name) ?? [null]).pop()) | |
.filter((child) => !!child); | |
} | |
firstChildWhereHasActionLike(name) { | |
return this.$children.find((child) => !!child.firstActionLike(name, null)); | |
} | |
firstChildWhereLike(property, value) { | |
const regex = new RegExp(value, 'gi'); | |
return this.$children.find((child) => | |
`${child[property] ?? ''}`.match(regex), | |
); | |
} | |
firstChildWhere( | |
property, | |
valueOrOperator, | |
value = 'com.stephancasas.undefined', | |
) { | |
let operator = '=='; | |
if (value != 'com.stephancasas.undefined') { | |
operator = valueOrOperator; | |
} else { | |
value = valueOrOperator; | |
} | |
return this.$children.find((child) => | |
eval(`(child, value) => (child.${property} ${operator} value)`)( | |
child, | |
value, | |
), | |
); | |
} | |
firstActionLike(name, fallback = () => {}) { | |
const regex = new RegExp(name, 'gi'); | |
const action = Object.keys(this.actions).find((key) => key.match(regex)); | |
return action ? this[action] : fallback; | |
} | |
__valueOf(key) { | |
const value = Ref(); | |
$.AXUIElementCopyAttributeValue(this.__element, key, value); | |
return ObjC.unwrap(value[0]); | |
} | |
__performAction(action) { | |
return $.AXUIElementPerformAction(this.__element, action); | |
} | |
__getUninitializedChildren() { | |
const value = Ref(); | |
$.AXUIElementCopyAttributeValue(this.__element, 'AXChildren', value); | |
return ( | |
ObjC.unwrap($.NSArray.arrayWithArray($.CFBridgingRelease(value[0]))) ?? [] | |
); | |
} | |
get children() { | |
this.__cachedChildren = this.__getUninitializedChildren().map( | |
(child) => new AXUIElement(child), | |
); | |
return this.__cachedChildren; | |
} | |
get $children() { | |
return this.__cachedChildren == undefined | |
? this.children | |
: this.__cachedChildren; | |
} | |
get firstChild() { | |
const children = this.__getUninitializedChildren(); | |
this.__cachedFirstChild = | |
children.length == 0 | |
? null | |
: new AXUIElement(this.__getUninitializedChildren()[0]); | |
return this.__cachedFirstChild; | |
} | |
get $firstChild() { | |
return this.__cachedFirstChild == undefined | |
? this.firstChild | |
: this.__cachedFirstChild; | |
} | |
static __mapActionsToCallable(instance, actionNames) { | |
return actionNames.reduce( | |
(acc, cur) => | |
Object.assign(acc, { | |
[`${cur.replace(/^AX/, '').charAt(0).toLowerCase()}${cur | |
.replace(/^AX/, '') | |
.slice(1)}`](...args) { | |
return instance.__performAction(cur, ...args); | |
}, | |
}), | |
{}, | |
); | |
} | |
get actions() { | |
const names = Ref(); | |
$.AXUIElementCopyActionNames(this.__element, names); | |
return AXUIElement.__mapActionsToCallable(this, ObjC.deepUnwrap(names[0])); | |
} | |
static __mapAttributesToProperties(instance, attributeNames) { | |
return attributeNames.reduce( | |
(acc, cur) => | |
Object.assign( | |
acc, | |
eval( | |
`(instance) => {return {get ${cur | |
.replace(/^AX/, '') | |
.charAt(0) | |
.toLowerCase()}${cur | |
.replace(/^AX/, '') | |
.slice(1)}(){return instance.__valueOf('${cur}');}}}`, | |
)(instance), | |
), | |
{}, | |
); | |
} | |
get attributes() { | |
const names = Ref(); | |
$.AXUIElementCopyAttributeNames(this.__element, names); | |
return AXUIElement.__mapAttributesToProperties( | |
this, | |
ObjC.deepUnwrap(names[0]), | |
); | |
} | |
} | |
class AXApplication extends AXUIElement { | |
__cachedWindows; | |
__cachedFirstWindow; | |
constructor(nameOrPid) { | |
if ( | |
`${nameOrPid}`.replace(/\d/g, '').length == `${nameOrPid}`.length && | |
typeof nameOrPid != 'number' | |
) { | |
super(AXApplication.__getApplicationByName(nameOrPid)); | |
} else { | |
super(AXApplication.__getApplicationByPid(parseInt(nameOrPid))); | |
} | |
if (!this.__element) { | |
throw new Error(`Could not find application for ${nameOrPid}`); | |
} | |
} | |
get windows() { | |
this.__cachedWindows = this.__valueOf('AXWindows').map( | |
(window) => new AXUIElement(window), | |
); | |
return this.__cachedWindows; | |
} | |
get $windows() { | |
return this.__cachedWindows == undefined | |
? this.windows | |
: this.__cachedWindows; | |
} | |
get firstWindow() { | |
this.__cachedFirstWindow = this.windows[0] ?? null; | |
return this.__cachedFirstWindow; | |
} | |
get $firstWindow() { | |
return this.__cachedFirstWindow == undefined | |
? this.firstWindow | |
: this.__cachedFirstWindow; | |
} | |
static __getApplicationByName(name) { | |
const pid = | |
$.CGWindowListCopyWindowInfo( | |
$.kCGWindowListExcludeDesktopElements, | |
$.kCGNullWindowID, | |
) | |
.$() | |
.filter(({ kCGWindowOwnerName }) => kCGWindowOwnerName == name) | |
.map(({ kCGWindowOwnerPID: pid }) => pid)[0] ?? null; | |
return !pid ? null : this.__getApplicationByPid(pid); | |
} | |
static __getApplicationByPid(pid) { | |
return $.AXUIElementCreateApplication(pid); | |
} | |
} | |
function run(_) { | |
const PocketCasts = new AXApplication('Pocket Casts').$firstWindow; | |
const axWebArea = | |
PocketCasts.$children[0].$children[0].$children[0].$children[0]; | |
try { | |
axWebArea.$children[22].$children[3].press(); | |
} catch (error) { | |
axWebArea | |
.firstChildWhereLike('description', 'controls') | |
.firstChildWhereLike('description', 'skip forwards') | |
.press(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment