Skip to content

Instantly share code, notes, and snippets.

@markjaquith
Last active February 21, 2025 00:53
Show Gist options
  • Save markjaquith/35387783db04abae008678433a26989f to your computer and use it in GitHub Desktop.
Save markjaquith/35387783db04abae008678433a26989f to your computer and use it in GitHub Desktop.
Control zoom audio/video with JXA (AppleScript in JavaScript)

Zoom JS

Control Zoom audio/video via AppleScript.

Install

Clone the repo somewhere. Assume: ~/Applications/Zoom-JS

Then run:

./install.sh

Run Daemon

For performance reasons, this AppleScript runs as a daemon. The startup cost for osascript is significant (on the order of one second). By running as a daemon and listening on a pipe, you can get really fast reaction times.

~/Applications/Zoom-JS/Zoom.js &

Send Commands

echo "audio-on" > /tmp/zoom_js echo "audio-off" > /tmp/zoom_js echo "video-on" > /tmp/zoom_js echo "video-off" > /tmp/zoom_js

Kill Daemon

If for any reason you need to kill the daemon, run:

pkill -f Zoom.js

Uninstall

./uninstall.sh
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.markjaquith.zoom-js</string>
<key>ProgramArguments</key>
<array>
<string>{{DIR}}/Zoom.js</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/zoom_js.out</string>
<key>StandardErrorPath</key>
<string>/tmp/zoom_js.err</string>
</dict>
</plist>
#!/bin/bash
# Get this directory path.
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Make sure Zoom.js is executable
echo "Making Zoom.js executable..."
chmod +x $DIR/zoom.js
# Install the LaunchAgent
echo "Installing the LaunchAgent..."
cp $DIR/com.markjaquith.zoom-js.plist ~/Library/LaunchAgents/
# Edit the LaunchAgent to point to the correct path
echo "Editing the LaunchAgent..."
sed -i '' "s|{{DIR}}|$DIR|g" ~/Library/LaunchAgents/com.markjaquith.zoom-js.plist
# Load the LaunchAgent
echo "Loading the LaunchAgent..."
launchctl unload ~/Library/LaunchAgents/com.markjaquith.zoom-js.plist > /dev/null 2>&1
launchctl load ~/Library/LaunchAgents/com.markjaquith.zoom-js.plist
#!/bin/bash
# Get this directory path.
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Killing the Zoom.js process
echo "Killing the Zoom.js process..."
pkill -f "Zoom.js"
# Unload the LaunchAgent
echo "Unloading the LaunchAgent..."
launchctl unload ~/Library/LaunchAgents/com.markjaquith.zoom-js.plist > /dev/null 2>&1
# Remove the LaunchAgent
echo "Removing the LaunchAgent..."
rm ~/Library/LaunchAgents/com.markjaquith.zoom-js.plist 2>/dev/null
#!/usr/bin/osascript -l JavaScript
ObjC.import('Foundation')
ObjC.import('stdlib')
const APP_NAME = 'zoom.us'
const MENU_NAME = 'Meeting'
const SCRIPT_NAME = 'Zoom JS'
const AUDIO_ON = 'audio-on'
const AUDIO_OFF = 'audio-off'
const VIDEO_ON = 'video-on'
const VIDEO_OFF = 'video-off'
const PIPE_PATH = '/tmp/zoom_js'
const LOG_PATH = '/tmp/zoom_js.log'
const MENU_ITEM = {
AUDIO: { ON: 'Unmute audio', OFF: 'Mute audio' },
VIDEO: { ON: 'Start video', OFF: 'Stop video' }
}
const SOUND = {
[AUDIO_ON]: 'Purr',
[AUDIO_OFF]: 'Pop',
// [VIDEO_ON]: 'Funk',
// [VIDEO_OFF]: 'Bottle'
}
function log(msg) {
const app = Application.currentApplication()
app.includeStandardAdditions = true
app.doShellScript(`echo "[${SCRIPT_NAME}] ${msg}" >> ${LOG_PATH}`)
}
function makeSound(sound) {
try {
const app = Application.currentApplication()
app.includeStandardAdditions = true
app.doShellScript(`nohup afplay /System/Library/Sounds/${sound}.aiff > /dev/null 2>&1 &`)
} catch (e) {
log(`Failed to play sound: ${e}`)
}
}
function waitForMenuItem(menuItem, timeoutMs = 1_000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
try {
if (menuItem[0].exists() && menuItem[0].enabled()) {
return true
}
} catch (error) {
delay(0.1) // Wait for 100ms before checking again
}
}
return false
}
function executeZoomCommand(command) {
log(`Received command: ${command}`)
try {
const systemEvents = Application("System Events")
const zoomProcs = systemEvents.processes.whose({ name: APP_NAME })
if (zoomProcs.length === 0) {
log("Zoom is not running.")
return
}
const zoomProc = zoomProcs[0]
if (zoomProc.windows.length === 0) {
log("Zoom has no open windows.")
return
}
const meetingItems = zoomProc.menuBars[0].menuBarItems.whose({ name: MENU_NAME })
if (meetingItems.length === 0) {
log("Meeting menu not found.")
return
}
const meetingMenu = meetingItems[0]
const meetingMenuContent = meetingMenu.menus[0]
const menuContentItemFn = {
[AUDIO_ON]: () => meetingMenuContent.menuItems.whose({ name: MENU_ITEM.AUDIO.ON }),
[AUDIO_OFF]: () => meetingMenuContent.menuItems.whose({ name: MENU_ITEM.AUDIO.OFF }),
[VIDEO_ON]: () => meetingMenuContent.menuItems.whose({ name: MENU_ITEM.VIDEO.ON }),
[VIDEO_OFF]: () => meetingMenuContent.menuItems.whose({ name: MENU_ITEM.VIDEO.OFF }),
}[command]
if (!menuContentItemFn) {
log(`Unknown command: ${command}`)
return
}
const menuContentItem = menuContentItemFn()
if (menuContentItem.length === 0) {
log(`Waiting for menu item ${command} to become enabled...`);
if (!waitForMenuItem(menuContentItem)) {
log(`Menu item ${command} is still disabled after timeout.`);
return;
}
}
const menuItem = menuContentItem[0]
if (menuItem.enabled()) {
log(`Clicking menu item: ${command}`)
menuItem.click()
const sound = SOUND[command]
if (sound) makeSound(sound)
} else {
log(`Menu item for ${command} is disabled.`)
}
} catch (error) {
log(`Error executing command ${command}: ${error}`)
}
}
function listenForCommands() {
const app = Application.currentApplication()
app.includeStandardAdditions = true
// Ensure the named pipe exists
app.doShellScript(`rm -f ${PIPE_PATH} && mkfifo ${PIPE_PATH}`)
log("Listening for commands...")
while (true) {
try {
const command = app.doShellScript(`cat ${PIPE_PATH}`).trim()
if (command) {
executeZoomCommand(command)
}
} catch (error) {
log(`Error reading command: ${error}`)
}
}
}
// Run the command listener
listenForCommands()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment