Skip to content

Instantly share code, notes, and snippets.

@nriley
Last active March 6, 2024 17:02
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save nriley/f2dfb2955836462b8f7806ce0da76bfb to your computer and use it in GitHub Desktop.
Save nriley/f2dfb2955836462b8f7806ce0da76bfb to your computer and use it in GitHub Desktop.
Hammerspoon script for ensuring Sidecar is active when iPad is plugged in (macOS 10.15; see comments for versions up to macOS 13)
hs.loadSpoon('SpoonInstall')
spoon.SpoonInstall.use_syncinstall = true
Install = spoon.SpoonInstall
log = hs.logger.new('init', 5)
-- function debugUI(msg, table)
-- log:d(msg)
-- log:d(hs.inspect(table))
-- end
function connectToSidecar(msg, results, count)
local item
sidecarItemFound = (count > 0)
if not sidecarItemFound then
return log:d("Can't get Sidecar connection menu item:", msg)
end
item = results[1]
if item.AXMenuItemMarkChar == nil then
-- log:d("Connecting to Sidecar...")
item:doAXPress()
else
-- log:d("Closing menu - already connected to Sidecar...")
item.AXParent:doAXCancel()
end
end
function stopConnectToSidecarItemSearchTimer()
if connectToSidecarItemSearchTimer then
connectToSidecarItemSearchTimer:stop()
connectToSidecarItemSearchTimer = nil
end
end
function displaysMenu(msg, results, count)
local menu, connectToSneezerItemSearch
if count == 0 then
return log:d("Can't get displays menu:", msg)
end
menu = results[1]
menu:doAXPress()
connectToSneezerItemSearch = hs.axuielement.searchCriteriaFunction({
{attribute = 'AXRole', value = 'AXMenuItem'},
{attribute = 'AXIdentifier', value = '_deviceSelected:'},
{attribute = 'AXTitle', value = 'Sneezer'}})
-- menu:elementSearch(debugUI, connectToSneezer,
-- {objectOnly = false, asTree = true, depth = 2})
-- iPad may not appear immediately in the Displays menu
-- wait up to 3 seconds for it to appear
stopConnectToSidecarItemSearchTimer()
sidecarItemFound = false
connectToSidecarItemSearchTimer = hs.timer.doUntil(
function()
return sidecarItemFound
end,
function()
menu:elementSearch(connectToSidecar, connectToSneezerItemSearch,
{count = 1, depth = 2, noCallback = true})
end,
0.5)
hs.timer.doAfter(3, stopConnectToSidecarItemSearchTimer)
end
function connectSidecar()
local suisApp, suisAX, displayMenuSearch
suisApp = hs.application.find('com.apple.systemuiserver')
suisAX = hs.axuielement.applicationElement(suisApp)
displayMenuSearch = hs.axuielement.searchCriteriaFunction({
{attribute = 'AXRole', value = 'AXMenuBarItem'},
{attribute = 'AXSubrole', value = 'AXMenuExtra'},
{attribute = 'AXDescription', value = '^Displays', pattern = true}
})
-- suisAX:elementSearch(debugUI, displayMenuSearch,
-- {objectOnly = false, asTree = true, depth = 2})
suisAX:elementSearch(displaysMenu, displayMenuSearch,
{count = 1, depth = 2})
end
-- what we actually get when the iPad connects:
-- connect, disconnect, connect
-- so, don't trigger twice within half a second
connectSidecarTimer = hs.timer.delayed.new(0.5, function()
connectSidecar()
end)
function iPadConnected(connected)
-- log:d("iPad connected?", connected)
if connected then
connectSidecarTimer:start()
else
connectSidecarTimer:stop()
end
end
Install:andUse(
"USBDeviceActions",
{
config = {
devices = {
iPad = { fn = iPadConnected }
}
},
start = true
}
)
@Technifocal
Copy link

Thank you for this. Semi-repurposed it in my own version:

https://gist.github.com/Technifocal/6284a6d2bfc76fa1fac18c1e9121c3bd

@nriley
Copy link
Author

nriley commented Dec 18, 2021

Sure! At the time there weren't very many good examples of using accessibility in Hammerspoon. I've got my own Monterey version which I'm working on and will post once it's a bit more reliable...

@blissdev
Copy link

@nriley any updates on your Monterey version?

@nriley
Copy link
Author

nriley commented Apr 21, 2022

So, it was working pretty well on 12.2 and then it completely broke on 12.3 because it seems Apple removed/broke the ability to connect from the menubar. I can share what I've got as it might work for you — you'll need to replace "Sneezer II" with the name of your iPad.

function controlCenterApplicationElement()
  local ccApp
  ccApp = hs.application.find('com.apple.controlcenter')
  return hs.axuielement.applicationElement(ccApp)
end

function connectToSidecar(msg, results, count)
  local item

  sidecarButtonFound = (count > 0)

  if not sidecarButtonFound then
    return log:d("Can't get Sidecar connection button:", msg)
  end
  item = results[1]
  if item.AXValue == 0 then
    log:d("Connecting to Sidecar...")
    item:doAXPress()
  else
    log:d("Closing window - already connected to Sidecar...")
  end
  doWithDisplayMenuExtra(displayMenuExtraPress)
end

function stopConnectToSidecarButtonSearchTimer()
  if connectToSidecarButtonSearchTimer then
    connectToSidecarButtonSearchTimer:stop()
    connectToSidecarButtonSearchTimer = nil
  end
end

function controlCenterWindow(msg, results, count)
  local window, connectToSidecarButtonSearch, searchTries

  if count == 0 then
    return log:d("Can't get Control Center window:", msg)
  end
  window = results[1]

  connectToSidecarButtonSearch = hs.axuielement.searchCriteriaFunction({
      {attribute = 'AXRole',  value = 'AXCheckBox'},
      {attribute = 'AXTitle', value = 'Sneezer II'}})
  -- XXX searching by AXIdentifier fails if in "connecting" (spinner) state

  stopConnectToSidecarButtonSearchTimer()
  sidecarButtonFound = false
  searchTries = 0
  connectToSidecarButtonSearchTimer = hs.timer.doUntil(
    function()
      searchTries = searchTries + 1
      return (sidecarButtonFound or searchTries == 6)
    end,
    function()
      window:elementSearch(connectToSidecar, connectToSidecarButtonSearch,
                           {count = 1, depth = 2, noCallback = true})
    end,
    0.5)
end

function displayMenuExtraPress(msg, results, count)
  local menuExtra

  if count == 0 then
    return log:d("Can't get display menu extra:", msg)
  end
  menuExtra = results[1]
  menuExtra:doAXPress()
end

function displayMenuExtraConnect(msg, results, count)
  local ccAX, ccWindowSearch

  displayMenuExtraPress(msg, results, count)

  ccAX = controlCenterApplicationElement()

  ccWindowSearch = hs.axuielement.searchCriteriaFunction({
      {attribute = 'AXRole',  value = 'AXWindow'},
      {attribute = 'AXTitle', value = 'Control Center'}})

  -- ccAX:elementSearch(debugUI, ccWindowSearch,
  --                    {objectOnly = false, asTree = true, depth = 2})

  ccAX:elementSearch(controlCenterWindow, ccWindowSearch,
                     {count = 1, depth = 1})

end

function doWithDisplayMenuExtra(callback)
  local ccAX, displayMenuExtraSearch

  ccAX = controlCenterApplicationElement()

  displayMenuExtraSearch = hs.axuielement.searchCriteriaFunction({
    {attribute = 'AXRole',       value = 'AXMenuBarItem'},
    {attribute = 'AXSubrole',    value = 'AXMenuExtra'},
    {attribute = 'AXIdentifier', value = 'com.apple.menuextra.display'}
  })

  -- ccAX:elementSearch(debugUI, displayMenuExtraSearch,
  --                      {objectOnly = false, asTree = true, depth = 2})

  ccAX:elementSearch(callback, displayMenuExtraSearch,
                     {count = 1, depth = 2})
end

function connectSidecar()
  doWithDisplayMenuExtra(displayMenuExtraConnect)
end

-- what we actually get when the iPad connects:
-- connect, disconnect, connect
-- ...but then we can't connect without triggering a timeout
-- so, wait 5 seconds
connectSidecarTimer = hs.timer.delayed.new(5, function()
  connectSidecar()
end)

function iPadConnected(connected)
  -- log:d("iPad connected?", connected)
  if connected then
    connectSidecarTimer:start()
  else
    connectSidecarTimer:stop()
  end
end

Install:andUse(
  "USBDeviceActions",
  {
    config = {
      devices = {
        iPad = { fn = iPadConnected }
      }
    },
    start = true
  }
)

@blissdev
Copy link

Thanks!

@nriley
Copy link
Author

nriley commented Apr 24, 2022

Got sick of this not working on macOS 12.3, so here's a version that uses System Preferences to connect. It's a bit of a combination of the 10.15 and 12.2 versions. I tried shortening the 5 second timer but unfortunately there are still issues with trying to connect immediately after plugging in via USB.

function systemPreferencesApplicationElement()
  local spApp
  spApp = hs.application.find('com.apple.systempreferences')
  if spApp then
    return hs.axuielement.applicationElement(spApp)
  else
    return nil
  end
end

function connectToSidecar(msg, results, count)
  local item

  sidecarItemFound = (count > 0)
  
  if not sidecarItemFound then
    return log:d("Can't get Sidecar connection menu item:", msg)
  end
  item = results[1]
  if item.AXMenuItemMarkChar == nil then
    log:d("Connecting to Sidecar...")
    item:doAXPress()
  else
    log:d("Closing menu - already connected to Sidecar...")
    item.AXParent:doAXCancel()
  end
  hs.application.find('com.apple.systempreferences'):kill()
end

function stopConnectToSidecarItemSearchTimer()
  if connectToSidecarItemSearchTimer then
    connectToSidecarItemSearchTimer:stop()
    connectToSidecarItemSearchTimer = nil
  end
end

function addDisplayMenu(msg, results, count)
  local menu, connectToSneezerItemSearch
  
  if count == 0 then
    return log:d("Can't find Add Display menu:", msg)
  end
  menu = results[1]
  menu:doAXPress()

  connectToSidecarItemCriteria = hs.axuielement.searchCriteriaFunction({
      {attribute = 'AXRole',       value = 'AXMenuItem'},
      {attribute = 'AXIdentifier', value = 'menuAction:'},
      {attribute = 'AXTitle',      value = 'Sneezer II'}})

  -- iPad may not appear immediately
  -- wait up to 3 seconds for it to appear
  stopConnectToSidecarItemSearchTimer()
  sidecarItemFound = false
  connectToSidecarItemSearchTimer = hs.timer.doUntil(
    function()
      return sidecarItemFound
    end,
    function()
      menu:elementSearch(connectToSidecar, connectToSidecarItemCriteria,
                         {count = 1, depth = 2, noCallback = true})
    end,
    0.5)
  hs.timer.doAfter(3, stopConnectToSidecarItemSearchTimer)
end

function stopSystemPreferencesSearchTimer()
  if systemPreferencesSearchTimer then
    systemPreferencesSearchTimer:stop()
    systemPreferencesSearchTimer = nil
  end
end

function connectSidecar()
  hs.urlevent.openURL("file:///System/Library/PreferencePanes/Displays.prefPane")
  addDisplayMenuCriteria = hs.axuielement.searchCriteriaFunction({
    {attribute = 'AXRole', value = 'AXPopUpButton'},
    {attribute = 'AXTitle', value = 'Add Display'}
  })
  spAX = nil
  addDisplayMenuSearch = nil
  systemPreferencesSearchTimer = hs.timer.doUntil(
    function()
      return spAX ~= nil and addDisplayMenuSearch and addDisplayMenuSearch:matched() > 0
    end,
    function()
      if addDisplayMenuSearch and addDisplayMenuSearch:isRunning() then
        return
      end
      spAX = systemPreferencesApplicationElement()
      if spAX ~= nil then
        log:d("Searching for Add Display menu")
        addDisplayMenuSearch = spAX:elementSearch(addDisplayMenu, addDisplayMenuCriteria,
                                                  {count = 1, depth = 2})
      end
    end,
    0.5)
  hs.timer.doAfter(3, stopSystemPreferencesSearchTimer)
end

-- what we actually get when the iPad connects:
-- connect, disconnect, connect
-- ...but then we can't connect without triggering a timeout
-- so, wait 5 seconds
connectSidecarTimer = hs.timer.delayed.new(5, function()
  connectSidecar()
end)

function iPadConnected(connected)
  -- log:d("iPad connected?", connected)
  if connected then
    connectSidecarTimer:start()
  else
    connectSidecarTimer:stop()
  end
end

Install:andUse(
  "USBDeviceActions",
  {
    config = {
      devices = {
        iPad = { fn = iPadConnected }
      }
    },
    start = true
  }
)

@LeaveNhA
Copy link

I have a bug to report but it's okay, we have a work-around.

Problem:

When you connect your iPad, the screen opens and it starts the charges. That's fine. But while the screen is on, the menu field you are triggering the Sidecar is showing two different options under two different categories; Linking the controls with Universal Control and Extending/Mirroring the screen to the device.

Work-around:

After plugging-in the device. Just click the lock button on it. After Screen goes off, you should be fine. 🤷🏻‍♂️

@nriley
Copy link
Author

nriley commented Aug 26, 2022

Good timing! I fixed this last week, should have posted an update…

function systemPreferencesApplicationElement()
  local spApp
  spApp = hs.application.find('com.apple.systempreferences')
  if spApp then
    return hs.axuielement.applicationElement(spApp)
  else
    return nil
  end
end

function connectToSidecar(msg, results, count)
  local item

  sidecarItemFound = (count > 0)
  
  if not sidecarItemFound then
    return log:d("Can't get Sidecar connection menu item:", msg)
  end
  -- first item may be Universal Control
  if count == 2 then
    item = results[2]
  else
    item = results[1]
  end
  if item.AXMenuItemMarkChar == nil then
    log:d("Connecting to Sidecar...")
    item:doAXPress()
  else
    log:d("Closing menu - already connected to Sidecar...")
    item.AXParent:doAXCancel()
  end
  hs.application.find('com.apple.systempreferences'):kill()
end

function stopConnectToSidecarItemSearchTimer()
  if connectToSidecarItemSearchTimer then
    connectToSidecarItemSearchTimer:stop()
    connectToSidecarItemSearchTimer = nil
  end
end

function addDisplayMenu(msg, results, count)
  local menu, connectToSneezerItemSearch
  
  if count == 0 then
    return log:d("Can't find Add Display menu:", msg)
  end
  menu = results[1]
  menu:doAXPress()

  connectToSidecarItemCriteria = hs.axuielement.searchCriteriaFunction({
      {attribute = 'AXRole',       value = 'AXMenuItem'},
      {attribute = 'AXIdentifier', value = 'menuAction:'},
      {attribute = 'AXTitle',      value = 'Sneezer II'}})

  -- iPad may not appear immediately
  -- wait up to 3 seconds for it to appear
  stopConnectToSidecarItemSearchTimer()
  sidecarItemFound = false
  connectToSidecarItemSearchTimer = hs.timer.doUntil(
    function()
      return sidecarItemFound
    end,
    function()
      menu:elementSearch(connectToSidecar, connectToSidecarItemCriteria,
                         {count = 2, depth = 2, noCallback = true})
    end,
    0.5)
  hs.timer.doAfter(3, stopConnectToSidecarItemSearchTimer)
end

function stopSystemPreferencesSearchTimer()
  if systemPreferencesSearchTimer then
    systemPreferencesSearchTimer:stop()
    systemPreferencesSearchTimer = nil
  end
end

function connectSidecar()
  hs.urlevent.openURL("file:///System/Library/PreferencePanes/Displays.prefPane")
  addDisplayMenuCriteria = hs.axuielement.searchCriteriaFunction({
    {attribute = 'AXRole', value = 'AXPopUpButton'},
    {attribute = 'AXTitle', value = 'Add Display'}
  })
  spAX = nil
  addDisplayMenuSearch = nil
  systemPreferencesSearchTimer = hs.timer.doUntil(
    function()
      return spAX ~= nil and addDisplayMenuSearch and addDisplayMenuSearch:matched() > 0
    end,
    function()
      if addDisplayMenuSearch and addDisplayMenuSearch:isRunning() then
        return
      end
      spAX = systemPreferencesApplicationElement()
      if spAX ~= nil then
        log:d("Searching for Add Displays menu")
        addDisplayMenuSearch = spAX:elementSearch(addDisplayMenu, addDisplayMenuCriteria,
                                                  {count = 1, depth = 2})
      end
    end,
    0.5)
  hs.timer.doAfter(3, stopSystemPreferencesSearchTimer)
end

-- what we actually get when the iPad connects:
-- connect, disconnect, connect
-- ...but then we can't connect without triggering a timeout
-- so, wait 5 seconds
connectSidecarTimer = hs.timer.delayed.new(5, function()
  connectSidecar()
end)

function iPadConnected(connected)
  -- log:d("iPad connected?", connected)
  if connected then
    connectSidecarTimer:start()
  else
    connectSidecarTimer:stop()
  end
end

Install:andUse(
  "USBDeviceActions",
  {
    config = {
      devices = {
        iPad = { fn = iPadConnected }
      }
    },
    start = true
  }
)

@LeaveNhA
Copy link

LeaveNhA commented Aug 26, 2022

I made the same changes you did and tried and didn't work. And I thought my Lua is rusty.

Let me try the updated version of yours. I'll inform you.

Thank you for your effort and time! 😇

@LeaveNhA
Copy link

LeaveNhA commented Aug 26, 2022

Edit: I just forgot to rename the device name literal. Sorry.

It works! 🥳

@nriley
Copy link
Author

nriley commented Aug 27, 2022

Great! (Of course, all this is going to break again in a few months, sigh…)

@nriley
Copy link
Author

nriley commented Jun 11, 2023

Finally upgraded to macOS 13. Surprisingly, only some very small changes need to be made to the last version I posted. Just matching the popup button:

  addDisplayMenuCriteria = hs.axuielement.searchCriteriaFunction({
    {attribute = 'AXRole', value = 'AXPopUpButton'},
    {attribute = 'AXDescription', value = 'Add'}
  })

and looking deeper in the hierarchy for it:

        addDisplayMenuSearch = spAX:elementSearch(addDisplayMenu, addDisplayMenuCriteria,
                                                  {count = 1, depth = 5})

@kmplngj
Copy link

kmplngj commented Jan 29, 2024

@nriley Does this work for you with macOS 14?

@nriley
Copy link
Author

nriley commented Jan 29, 2024

@kmplngj I haven't upgraded yet on my main Mac due to various persistent issues in macOS 14. Still works on current macOS 13.

@peterhartree
Copy link

@nriley Thank you for sharing this script. I've been looking for something like this for ages.

On macOS 14 I had to tweak the connectSidecar() function to avoid a "spaX is nil" error:

function connectSidecar()
  hs.urlevent.openURL("file:///System/Library/PreferencePanes/Displays.prefPane")
  addDisplayMenuCriteria = hs.axuielement.searchCriteriaFunction({
    {attribute = 'AXRole', value = 'AXPopUpButton'},
    {attribute = 'AXDescription', value = 'Add'}
  })

  systemPreferencesSearchTimer = hs.timer.doUntil(
    function()
      return spAX ~= nil and addDisplayMenuSearch and addDisplayMenuSearch:matched() > 0
    end,
    function()
      if addDisplayMenuSearch and addDisplayMenuSearch:isRunning() then
        return
      end
      spAX = systemPreferencesApplicationElement()
      if spAX then
        log:d("Searching for Add Displays menu")
        addDisplayMenuSearch = spAX:elementSearch(addDisplayMenu, addDisplayMenuCriteria,
                                                  {count = 1, depth = 2})
      else
        log:d("spAX is nil, waiting for System Preferences")
      end
    end,
    0.5)
  hs.timer.doAfter(3, stopSystemPreferencesSearchTimer)
end

With that edit your script opens System Preferences as expected. Unfortunately it then gets stuck on "Searching for Add Displays" menu:

2024-03-04 11:40:29:                          table: 0x60000316bcc0 Searching for Add Displays menu
2024-03-04 11:40:29:                          table: 0x60000316bcc0 Can't find Add Display menu: completed

I don't know how to figure out what the menu is called on macOS 14. Do you have a quick tip on how to do this? Thanks in advance!

@peterhartree
Copy link

Here's a screenshot of the "Displays" dialog on macOS 14:
image

I tried setting {attribute = 'AXDescription', value = '+'}, but no dice.

@nriley
Copy link
Author

nriley commented Mar 5, 2024

@peterhartree Sorry, I don't have macOS 14 easily accessible to test. You can try using Accessibility Inspector if you want to figure out what changed since the prior version, but instead of scripting System Settings you could also try https://github.com/Ocasio-J/SidecarLauncher — just read about it today. It uses a private API to enable/disable Sidecar and is tested on macOS 14.

@peterhartree
Copy link

Thank you @nriley. SidecarLauncher is working perfectly on Mac OS 14.2.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment