Skip to content

Instantly share code, notes, and snippets.

@stephancasas
Last active February 18, 2025 09:47
Show Gist options
  • Save stephancasas/a36c81fbc4189f46bc803f388a1985be to your computer and use it in GitHub Desktop.
Save stephancasas/a36c81fbc4189f46bc803f388a1985be to your computer and use it in GitHub Desktop.
Toggle sidecar or screen mirroring from Control Center in macOS Ventura
#!/usr/bin/env osascript -l JavaScript
/**
* -----------------------------------------------------------------------------
* Activate Sidecar/Screen Mirroring from Control Center
* -----------------------------------------------------------------------------
*
* Created on February 17, 2023 by Stephan Casas
* Updated on May 18, 2023 by Stephan Casas
*
* Options:
* - TARGET_DEVICE_NAME
* - The name of the sidecar/screen mirroring device to toggle.
* - This should be exactly as it's written in screen mirroring menu.
* - Include any whitespace characters as given in the menu entry.
*
*
* Notes:
* This script was tested on macOS 13 Ventura and may break with future OS
* updates.
*/
const TARGET_DEVICE_NAME = 'REPLACE_WITH_YOUR_DEVICE_NAME';
const $attr = Ref();
const $windows = Ref();
const $children = Ref();
function run(_) {
// Get the current Control Center PID.
const pid = $.NSRunningApplication.runningApplicationsWithBundleIdentifier(
'com.apple.controlcenter',
).firstObject.processIdentifier;
// Get the Control Center application.
const app = $.AXUIElementCreateApplication(pid);
// Get the Control Center menubar extra children.
$.AXUIElementCopyAttributeValue(app, 'AXChildren', $children);
$.AXUIElementCopyAttributeValue($children[0].js[0], 'AXChildren', $children);
// Locate the Control Center menubar extra (also has Clock, Users, etc.).
const ccExtra = $children[0].js.find((child) => {
$.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr);
return $attr[0].js == 'com.apple.menuextra.controlcenter';
});
// Open Control Center window and await draw.
$.AXUIElementPerformAction(ccExtra, 'AXPress');
if (
!(() => {
const timeout = new Date().getTime() + 2000;
while (true) {
$.AXUIElementCopyAttributeValue(app, 'AXWindows', $windows);
if (
typeof $windows[0] == 'function' &&
($windows[0].js.length ?? 0) > 0
) {
return true;
}
if (new Date().getTime() > timeout) {
return false;
}
delay(0.1);
}
})()
) {
return;
}
// Get Control Center window children.
$.AXUIElementCopyAttributeValue($windows[0].js[0], 'AXChildren', $children);
// Locate the Control Center modules group.
const modulesGroup = $children[0].js.find((child) => {
$.AXUIElementCopyAttributeValue(child, 'AXRole', $attr);
return $attr[0].js == 'AXGroup';
});
// Get the individual modules within the modules group.
$.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children);
// Locate the screen-mirroring module.
const screenMirroring = $children[0].js.find((child) => {
$.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr);
return $attr[0].js == 'controlcenter-screen-mirroring';
});
// Activate the screen mirroring module and await draw.
$.AXUIElementPerformAction(
screenMirroring,
// Wtf is this action name, Apple??
'Name:show details\nTarget:0x0\nSelector:(null)',
);
if (
!(() => {
const timeout = new Date().getTime() + 2000;
while (true) {
$.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children);
if (
typeof $children[0] == 'function' &&
($children[0].js.length ?? 0) > 0
) {
return true;
}
if (new Date().getTime() > timeout) {
return false;
}
delay(0.1);
}
})()
) {
return;
}
// Get the scroll area containing the device mirroring options.
const mirroringOptions = $children[0].js.find((child) => {
$.AXUIElementCopyAttributeValue(child, 'AXRole', $attr);
return $attr[0].js == 'AXScrollArea';
});
// Get all mirroring options.
$.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children);
// Locate the toggle element for the target mirroring device.
const toggle = $children[0].js
.filter((child) => {
$.AXUIElementCopyAttributeValue(child, 'AXRole', $attr);
return $attr[0].js == `AXCheckBox`;
})
.find((child) => {
$.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr);
return $attr[0].js == `screen-mirroring-device-${TARGET_DEVICE_NAME}`;
});
if (!toggle) {
console.log(
'Error: Could not get toggle for target screen-mirroring device.',
);
return 1;
}
// Press the toggle for the target device.
$.AXUIElementPerformAction(toggle, 'AXPress');
// Send ⎋ to dismiss Control Center.
$.CGEventPost($.kCGHIDEventTap, $.CGEventCreateKeyboardEvent(null, 53, true));
$.CGEventPost($.kCGHIDEventTap, $.CGEventCreateKeyboardEvent(null, 53, true));
return 0;
}
// prettier-ignore
(() => {
ObjC.import('Cocoa'); // yes, it's necessary -- stop telling me it isn't
ObjC.bindFunction('AXUIElementPerformAction', ['int', ['id', 'id']]);
ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['unsigned int']]);
ObjC.bindFunction('AXUIElementCopyAttributeValue',['int', ['id', 'id', 'id*']]);
})();
@tylinux
Copy link

tylinux commented Aug 31, 2023

confirm works on macOS 14 beta7, nice job!

@stephancasas
Copy link
Author

@tylinux Good to know! Thank you for testing! :)

@tonydehnke
Copy link

How do we add this to Alfred (for non-technical people)?

@stephancasas
Copy link
Author

@tonydehnke In an existing or new workflow, choose right-click and choose ActionRun Script. Set Language to /usr/bin/osascript (JavaScript). Copy the content of this gist and paste it into the script editor text box. Apply the changes described in the header instructions, and then click Save to commit the workflow node.

@tonydehnke
Copy link

Thanks!

@mig8447
Copy link

mig8447 commented Oct 12, 2023

Tested in 13.5.1 and it doesn't work in there

@altofoerster
Copy link

Works in Sonoma 14.1.1. Thanks a lot!

@acifre
Copy link

acifre commented Jan 18, 2024

Been searching for a couple months of any easy way to get sidecar automated (reliably and quickly) and this works flawlessly, can't thank you enough. Works on Sonoma 14.3 (23D56).

@mig8447
Copy link

mig8447 commented Feb 4, 2024

Couldn't get it to work on Ventura 13.6.4 either, although I'm thinking this is because my OS is in another language (Spanish)

@emrecengdev
Copy link

The action “Run JavaScript” encountered an error: “Error: TypeError: undefined is not an object (evaluating '$children[0].js[0]')”

@stephancasas
Copy link
Author

@emrecengdev I'll need a little more context to help you troubleshoot. Can you tell me more about your setup? Language, Control Center config, etc.?

@ShaodongDev
Copy link

Works in Sonoma 14.5. Thanks a lot! This is an amazing work!!!!!!!!

@GrumTech
Copy link

GrumTech commented Aug 1, 2024

Brilliant work, still working for me also!

@tonydehnke
Copy link

Seems to be broken in MacOS Sequiia 15.1 Beta - has anyone else tested it there?

@RainerUderia
Copy link

Doesn't work for me either - MacOS Sequiia 15.1

@dpyy
Copy link

dpyy commented Dec 4, 2024

Any fix for 15.1?

@phewstaff
Copy link

Any fix for 15.1?

I tried to fix for 1-2 hours, though i'm not familiar with any of the technologies except javaScript with some help of gpt.
Works for 15.2. Not perfectly though.

function run(_) {
const TARGET_DEVICE_NAME = 'YOUR_DEVICE_NAME'
  const $attr = Ref()
  const $windows = Ref()
  const $children = Ref()

  // Get the current Control Center PID.
  const pid =
    $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.controlcenter').firstObject
      .processIdentifier

  // Get the Control Center application.
  const app = $.AXUIElementCreateApplication(pid)

  // Get the Control Center menubar extra children.
  $.AXUIElementCopyAttributeValue(app, 'AXChildren', $children)
  $.AXUIElementCopyAttributeValue($children[0].js[0], 'AXChildren', $children)

  // Locate the Control Center menubar extra (also has Clock, Users, etc.).
  const ccExtra = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
    return $attr[0].js == 'com.apple.menuextra.controlcenter'
  })

  // Open Control Center window and await draw.
  $.AXUIElementPerformAction(ccExtra, 'AXPress')
  if (
    !(() => {
      const timeout = new Date().getTime() + 2000
      while (true) {
        $.AXUIElementCopyAttributeValue(app, 'AXWindows', $windows)
        if (typeof $windows[0] == 'function' && ($windows[0].js.length ?? 0) > 0) {
          return true
        }
        if (new Date().getTime() > timeout) {
          return false
        }
        delay(0.1)
      }
    })()
  ) {
    return
  }

  // Get Control Center window children.
  $.AXUIElementCopyAttributeValue($windows[0].js[0], 'AXChildren', $children)

  // Locate the Control Center modules group.
  const modulesGroup = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    return $attr[0].js == 'AXGroup'
  })

  // Get the individual modules within the modules group.
  $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)

  // Locate the screen-mirroring module.
  const screenMirroring = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
    return $attr[0].js == 'controlcenter-screen-mirroring'
  })

  // Activate the screen mirroring module and await draw.
  $.AXUIElementPerformAction(
    screenMirroring,
    // Wtf is this action name, Apple??
    'Name:show details\nTarget:0x0\nSelector:(null)'
  )
  if (
    !(() => {
      const timeout = new Date().getTime() + 2000
      while (true) {
        $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)
        if (typeof $children[0] == 'function' && ($children[0].js.length ?? 0) > 0) {
          return true
        }
        if (new Date().getTime() > timeout) {
          return false
        }
        delay(0.1)
      }
    })()
  ) {
    return
  }

  // Get the scroll area containing the device mirroring options.
  const mirroringOptions = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    return $attr[0].js == 'AXScrollArea'
  })

  // Get all mirroring options.
  $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)

  // First, check if we're in the connected state by looking for "Use As Extended Display"
  const isConnected = $children[0].js.some((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    if ($attr[0].js !== 'AXCheckBox') return false

    $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    return $attr[0].js === 'Use As Extended Display'
  })

  if (isConnected) {
    // Look for the disclosure triangle to go back
    const backButton = $children[0].js.find((child) => {
      $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
      return $attr[0].js === 'AXDisclosureTriangle'
    })

    if (!backButton) {
      console.log('Error: Could not find back button')
      return 1
    }

    // Click the back button
    $.AXUIElementPerformAction(backButton, 'AXPress')

    // Wait a moment for the UI to update
    delay(0.5)

    // Get the updated children
    $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)
  }

  // Now look for our device toggle
  const toggle = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    if ($attr[0].js !== 'AXCheckBox') return false

    $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    return $attr[0].js === TARGET_DEVICE_NAME
  })

  if (!toggle) {
    console.log('Error: Could not get toggle for target screen-mirroring device.')
    console.log('Target device name:', TARGET_DEVICE_NAME)
    return 1
  }

  // Toggle the device
  $.AXUIElementPerformAction(toggle, 'AXPress')
  return 0
}

// prettier-ignore
(() => {
ObjC.import('Cocoa'); // yes, it's necessary -- stop telling me it isn't

ObjC.bindFunction('AXUIElementPerformAction', ['int', ['id', 'id']]);
ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['unsigned int']]);
ObjC.bindFunction('AXUIElementCopyAttributeValue',['int', ['id', 'id', 'id*']]);
ObjC.bindFunction('AXUIElementCopyAttributeNames', ['int', ['id', 'id*']]);
})();

@ShaodongDev
Copy link

@phewstaff Thanks a lot for your efforts! I got it fixed as well by your code.

@dpyy
Copy link

dpyy commented Dec 14, 2024

This is amazing! Does anyone have a scrip that turns off mirroring?

@S-Yuan137
Copy link

S-Yuan137 commented Dec 19, 2024

Any fix for 15.1?

I tried to fix for 1-2 hours, though i'm not familiar with any of the technologies except javaScript with some help of gpt. Works for 15.2. Not perfectly though.

function run(_) {
const TARGET_DEVICE_NAME = 'YOUR_DEVICE_NAME'
  const $attr = Ref()
  const $windows = Ref()
  const $children = Ref()

  // Get the current Control Center PID.
  const pid =
    $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.controlcenter').firstObject
      .processIdentifier

  // Get the Control Center application.
  const app = $.AXUIElementCreateApplication(pid)

  // Get the Control Center menubar extra children.
  $.AXUIElementCopyAttributeValue(app, 'AXChildren', $children)
  $.AXUIElementCopyAttributeValue($children[0].js[0], 'AXChildren', $children)

  // Locate the Control Center menubar extra (also has Clock, Users, etc.).
  const ccExtra = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
    return $attr[0].js == 'com.apple.menuextra.controlcenter'
  })

  // Open Control Center window and await draw.
  $.AXUIElementPerformAction(ccExtra, 'AXPress')
  if (
    !(() => {
      const timeout = new Date().getTime() + 2000
      while (true) {
        $.AXUIElementCopyAttributeValue(app, 'AXWindows', $windows)
        if (typeof $windows[0] == 'function' && ($windows[0].js.length ?? 0) > 0) {
          return true
        }
        if (new Date().getTime() > timeout) {
          return false
        }
        delay(0.1)
      }
    })()
  ) {
    return
  }

  // Get Control Center window children.
  $.AXUIElementCopyAttributeValue($windows[0].js[0], 'AXChildren', $children)

  // Locate the Control Center modules group.
  const modulesGroup = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    return $attr[0].js == 'AXGroup'
  })

  // Get the individual modules within the modules group.
  $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)

  // Locate the screen-mirroring module.
  const screenMirroring = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
    return $attr[0].js == 'controlcenter-screen-mirroring'
  })

  // Activate the screen mirroring module and await draw.
  $.AXUIElementPerformAction(
    screenMirroring,
    // Wtf is this action name, Apple??
    'Name:show details\nTarget:0x0\nSelector:(null)'
  )
  if (
    !(() => {
      const timeout = new Date().getTime() + 2000
      while (true) {
        $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)
        if (typeof $children[0] == 'function' && ($children[0].js.length ?? 0) > 0) {
          return true
        }
        if (new Date().getTime() > timeout) {
          return false
        }
        delay(0.1)
      }
    })()
  ) {
    return
  }

  // Get the scroll area containing the device mirroring options.
  const mirroringOptions = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    return $attr[0].js == 'AXScrollArea'
  })

  // Get all mirroring options.
  $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)

  // First, check if we're in the connected state by looking for "Use As Extended Display"
  const isConnected = $children[0].js.some((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    if ($attr[0].js !== 'AXCheckBox') return false

    $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    return $attr[0].js === 'Use As Extended Display'
  })

  if (isConnected) {
    // Look for the disclosure triangle to go back
    const backButton = $children[0].js.find((child) => {
      $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
      return $attr[0].js === 'AXDisclosureTriangle'
    })

    if (!backButton) {
      console.log('Error: Could not find back button')
      return 1
    }

    // Click the back button
    $.AXUIElementPerformAction(backButton, 'AXPress')

    // Wait a moment for the UI to update
    delay(0.5)

    // Get the updated children
    $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)
  }

  // Now look for our device toggle
  const toggle = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    if ($attr[0].js !== 'AXCheckBox') return false

    $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    return $attr[0].js === TARGET_DEVICE_NAME
  })

  if (!toggle) {
    console.log('Error: Could not get toggle for target screen-mirroring device.')
    console.log('Target device name:', TARGET_DEVICE_NAME)
    return 1
  }

  // Toggle the device
  $.AXUIElementPerformAction(toggle, 'AXPress')
  return 0
}

// prettier-ignore
(() => {
ObjC.import('Cocoa'); // yes, it's necessary -- stop telling me it isn't

ObjC.bindFunction('AXUIElementPerformAction', ['int', ['id', 'id']]);
ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['unsigned int']]);
ObjC.bindFunction('AXUIElementCopyAttributeValue',['int', ['id', 'id', 'id*']]);
ObjC.bindFunction('AXUIElementCopyAttributeNames', ['int', ['id', 'id*']]);
})();

This is the best script I have found on the internet so far! It works so smoothly! For anyone who wants to turn off the sidecar, simply run it again. Also, it works flawlessly when combining the Hammerspoon shortcut with this script, as confirmed by MacOS 13.7.2 Ventura and iPadOS 17.5.1. @phewstaff Thanks a lot for your great work!

@tonydehnke
Copy link

Thanks @S-Yuan137 - that is working in Alfred as Javascript for me on MacOS 15.2 and iPadOS 18.2

@ShaodongDev
Copy link

Any fix for 15.1?

I tried to fix for 1-2 hours, though i'm not familiar with any of the technologies except javaScript with some help of gpt. Works for 15.2. Not perfectly though.

function run(_) {
const TARGET_DEVICE_NAME = 'YOUR_DEVICE_NAME'
  const $attr = Ref()
  const $windows = Ref()
  const $children = Ref()

  // Get the current Control Center PID.
  const pid =
    $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.controlcenter').firstObject
      .processIdentifier

  // Get the Control Center application.
  const app = $.AXUIElementCreateApplication(pid)

  // Get the Control Center menubar extra children.
  $.AXUIElementCopyAttributeValue(app, 'AXChildren', $children)
  $.AXUIElementCopyAttributeValue($children[0].js[0], 'AXChildren', $children)

  // Locate the Control Center menubar extra (also has Clock, Users, etc.).
  const ccExtra = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
    return $attr[0].js == 'com.apple.menuextra.controlcenter'
  })

  // Open Control Center window and await draw.
  $.AXUIElementPerformAction(ccExtra, 'AXPress')
  if (
    !(() => {
      const timeout = new Date().getTime() + 2000
      while (true) {
        $.AXUIElementCopyAttributeValue(app, 'AXWindows', $windows)
        if (typeof $windows[0] == 'function' && ($windows[0].js.length ?? 0) > 0) {
          return true
        }
        if (new Date().getTime() > timeout) {
          return false
        }
        delay(0.1)
      }
    })()
  ) {
    return
  }

  // Get Control Center window children.
  $.AXUIElementCopyAttributeValue($windows[0].js[0], 'AXChildren', $children)

  // Locate the Control Center modules group.
  const modulesGroup = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    return $attr[0].js == 'AXGroup'
  })

  // Get the individual modules within the modules group.
  $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)

  // Locate the screen-mirroring module.
  const screenMirroring = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
    return $attr[0].js == 'controlcenter-screen-mirroring'
  })

  // Activate the screen mirroring module and await draw.
  $.AXUIElementPerformAction(
    screenMirroring,
    // Wtf is this action name, Apple??
    'Name:show details\nTarget:0x0\nSelector:(null)'
  )
  if (
    !(() => {
      const timeout = new Date().getTime() + 2000
      while (true) {
        $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)
        if (typeof $children[0] == 'function' && ($children[0].js.length ?? 0) > 0) {
          return true
        }
        if (new Date().getTime() > timeout) {
          return false
        }
        delay(0.1)
      }
    })()
  ) {
    return
  }

  // Get the scroll area containing the device mirroring options.
  const mirroringOptions = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    return $attr[0].js == 'AXScrollArea'
  })

  // Get all mirroring options.
  $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)

  // First, check if we're in the connected state by looking for "Use As Extended Display"
  const isConnected = $children[0].js.some((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    if ($attr[0].js !== 'AXCheckBox') return false

    $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    return $attr[0].js === 'Use As Extended Display'
  })

  if (isConnected) {
    // Look for the disclosure triangle to go back
    const backButton = $children[0].js.find((child) => {
      $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
      return $attr[0].js === 'AXDisclosureTriangle'
    })

    if (!backButton) {
      console.log('Error: Could not find back button')
      return 1
    }

    // Click the back button
    $.AXUIElementPerformAction(backButton, 'AXPress')

    // Wait a moment for the UI to update
    delay(0.5)

    // Get the updated children
    $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)
  }

  // Now look for our device toggle
  const toggle = $children[0].js.find((child) => {
    $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    if ($attr[0].js !== 'AXCheckBox') return false

    $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    return $attr[0].js === TARGET_DEVICE_NAME
  })

  if (!toggle) {
    console.log('Error: Could not get toggle for target screen-mirroring device.')
    console.log('Target device name:', TARGET_DEVICE_NAME)
    return 1
  }

  // Toggle the device
  $.AXUIElementPerformAction(toggle, 'AXPress')
  return 0
}

// prettier-ignore
(() => {
ObjC.import('Cocoa'); // yes, it's necessary -- stop telling me it isn't

ObjC.bindFunction('AXUIElementPerformAction', ['int', ['id', 'id']]);
ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['unsigned int']]);
ObjC.bindFunction('AXUIElementCopyAttributeValue',['int', ['id', 'id', 'id*']]);
ObjC.bindFunction('AXUIElementCopyAttributeNames', ['int', ['id', 'id*']]);
})();

@phewstaff bro, we need to work on this for 15.3 again lol

@stephancasas
Copy link
Author

will provide a fix as soon as work updates systems to 15.3. should be this week.

@ShaodongDev
Copy link

will provide a fix as soon as work updates systems to 15.3. should be this week.

Thanks a lot @stephancasas ! Look forward to it as I also want to learn the tech improvements behind it.

@brookr
Copy link

brookr commented Jan 30, 2025

Thanks for the effort here! Does this support mac to mac sidecar?

@nedx86
Copy link

nedx86 commented Feb 6, 2025

Here is corrected version for macOS 15.3

#!/usr/bin/env osascript -l JavaScript

/**
 * -----------------------------------------------------------------------------
 * Activate Sidecar/Screen Mirroring from Control Center
 * -----------------------------------------------------------------------------
 *
 * Created on February 17, 2023 by Stephan Casas
 * Updated on May 18, 2023 by Stephan Casas
 *
 * Options:
 *   - TARGET_DEVICE_NAME
 *     - The name of the sidecar/screen mirroring device to toggle.
 *     - This should be exactly as it's written in screen mirroring menu.
 *     - Include any whitespace characters as given in the menu entry.
 *
 *
 * Notes:
 *   This script was tested on macOS 13 Ventura and may break with future OS
 *   updates.
 */

const $attr = Ref();
const $windows = Ref();
const $children = Ref();

function run(_) {
    const TARGET_DEVICE_NAME = 'LG'
    const $attr = Ref()
    const $windows = Ref()
    const $children = Ref()

    // Get the current Control Center PID.
    const pid =
        $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.controlcenter').firstObject
            .processIdentifier

    // Get the Control Center application.
    const app = $.AXUIElementCreateApplication(pid)

    // Get the Control Center menubar extra children.
    $.AXUIElementCopyAttributeValue(app, 'AXChildren', $children)
    $.AXUIElementCopyAttributeValue($children[0].js[0], 'AXChildren', $children)

    // Locate the Control Center menubar extra (also has Clock, Users, etc.).
    const ccExtra = $children[0].js.find((child) => {
        $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
        return $attr[0].js == 'com.apple.menuextra.controlcenter'
    })

    // Open Control Center window and await draw.
    $.AXUIElementPerformAction(ccExtra, 'AXPress')
    if (
        !(() => {
            const timeout = new Date().getTime() + 2000
            while (true) {
                $.AXUIElementCopyAttributeValue(app, 'AXWindows', $windows)
                if (typeof $windows[0] == 'function' && ($windows[0].js.length ?? 0) > 0) {
                    return true
                }
                if (new Date().getTime() > timeout) {
                    return false
                }
                delay(0.1)
            }
        })()
    ) {
        return
    }

    // Get Control Center window children.
    $.AXUIElementCopyAttributeValue($windows[0].js[0], 'AXChildren', $children)

    // Locate the Control Center modules group.
    const modulesGroup = $children[0].js.find((child) => {
        $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
        return $attr[0].js == 'AXGroup'
    })

    // Get the individual modules within the modules group.
    $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)

    // Locate the screen-mirroring module.
    const screenMirroring = $children[0].js.find((child) => {
        $.AXUIElementCopyAttributeValue(child, 'AXIdentifier', $attr)
        console.log("Locate the screen-mirroring module - " + $attr[0].js)
        return $attr[0].js == 'controlcenter-screen-mirroring'
    })

    // Activate the screen mirroring module and await draw.
    $.AXUIElementPerformAction(
        screenMirroring,
        // Wtf is this action name, Apple??
        'Name:show details\nTarget:0x0\nSelector:(null)'
    )
    if (
        !(() => {
            const timeout = new Date().getTime() + 2000
            while (true) {
                $.AXUIElementCopyAttributeValue(modulesGroup, 'AXChildren', $children)
                if (typeof $children[0] == 'function' && ($children[0].js.length ?? 0) > 0) {
                    return true
                }
                if (new Date().getTime() > timeout) {
                    return false
                }
                delay(0.1)
            }
        })()
    ) {
        return
    }

    // Get the scroll area containing the device mirroring options.
    const mirroringOptions = $children[0].js.find((child) => {
        $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
        return $attr[0].js == 'AXScrollArea'
    })

    // Get all mirroring options.
    $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)

    // First, check if we're in the connected state by looking for "Use As Extended Display"
    const isConnected = $children[0].js.some((child) => {
        $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
        console.log("isConnected - AXRole: " + $attr[0].js)
        if ($attr[0].js === 'AXGroup') {
            console.log("Found AXGroup, checking its children for AXCheckBox")
            $.AXUIElementCopyAttributeValue(child, 'AXChildren', $children)
            return $children[0].js.some((grandchild) => {
                $.AXUIElementCopyAttributeValue(grandchild, 'AXRole', $attr)
                console.log("Checking grandchild - AXRole: " + $attr[0].js)
                if ($attr[0].js === 'AXCheckBox') {
                    $.AXUIElementCopyAttributeValue(grandchild, 'AXDescription', $attr)
                    console.log("Checking grandchild - AXDescription: " + $attr[0].js)
                    return $attr[0].js === 'Use As Extended Display'
                }
                return false
            })
        }
        if ($attr[0].js !== 'AXCheckBox') return false

        $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
        console.log("isConnected - AXDescription: " + $attr[0].js)
        return $attr[0].js === 'Use As Extended Display'
    })

    if (isConnected) {
        // Look for the disclosure triangle to go back
        const backButton = $children[0].js.find((child) => {
            $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
            console.log("Back button - AXRole: " + $attr[0].js)
            return $attr[0].js === 'AXDisclosureTriangle'
        })

        if (!backButton) {
            console.log('Error: Could not find back button')
            return 1
        }

        // Click the back button
        $.AXUIElementPerformAction(backButton, 'AXPress')

        // Wait a moment for the UI to update
        delay(2)

        // Get the updated children
        $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)
    }

    // // Function to recursively find and log AXGroup elements
    // function findAndLogAXGroups(element, depth = 0) {
    //     $.AXUIElementCopyAttributeValue(element, 'AXRole', $attr)
    //     console.log(' '.repeat(depth * 2) + `Role: ${$attr[0].js}`)
    //     if ($attr[0].js === 'AXGroup') {
    //         console.log(' '.repeat(depth * 2) + "Found AXGroup, checking its children for AXCheckBox")
    //         $.AXUIElementCopyAttributeValue(element, 'AXChildren', $children)
    //         return $children[0].js.some((child) => {
    //             $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
    //             console.log(' '.repeat(depth * 2) + `Checking child - AXRole: ${$attr[0].js}`)
    //             if ($attr[0].js === 'AXGroup') {
    //                 const result = findAndLogAXGroups(child, depth + 1)
    //                 if (result) {
    //                     return result
    //                 }
    //             } else if ($attr[0].js === 'AXCheckBox') {
    //                 $.AXUIElementCopyAttributeValue(child, 'AXDescription', $attr)
    //                 console.log(' '.repeat(depth * 2) + `Checking child - AXDescription: ${$attr[0].js}`)
    //                 if ($attr[0].js === TARGET_DEVICE_NAME) {
    //                     return child
    //                 }
    //             }
    //             return false
    //         })
    //     }
    //     return false
    // }

    // Now look for our device toggle
    // const toggle = findAndLogAXGroups($children[0].js)

    // Now look for our device toggle
    $.AXUIElementCopyAttributeValue(mirroringOptions, 'AXChildren', $children)

    const toggle = $children[0].js.find((child) => {
      $.AXUIElementCopyAttributeValue(child, 'AXRole', $attr)
      console.log("isConnected - AXRole: " + $attr[0].js)
      if ($attr[0].js === 'AXGroup') {
        console.log("Found AXGroup, checking its children for AXCheckBox")
        $.AXUIElementCopyAttributeValue(child, 'AXChildren', $children)
        return $children[0].js.find((grandchild) => {
          $.AXUIElementCopyAttributeValue(grandchild, 'AXRole', $attr)
          console.log("Checking grandchild - AXRole: " + $attr[0].js)
          if ($attr[0].js === 'AXCheckBox') {
            $.AXUIElementCopyAttributeValue(grandchild, 'AXDescription', $attr)
            console.log("Checking grandchild - AXDescription: " + $attr[0].js)
            if ($attr[0].js === TARGET_DEVICE_NAME) {
              console.log("FOUND: " + TARGET_DEVICE_NAME)
              // delay(2)

              $.AXUIElementPerformAction(grandchild, 'AXPress')
              return grandchild
            }
          }
          return false
        })
      }
      return false
    })

    if (!toggle) {
      console.log('Error: Could not get toggle for target screen-mirroring device.')
      console.log('Target device name:', TARGET_DEVICE_NAME)
      return 1
    }

    // Debug the toggle value
    console.log('Toggle element:', toggle)

    // Toggle the device
    $.AXUIElementPerformAction(toggle, 'AXPress')
    return 0
}

function changeFocusBackToMainMonitor() {
  const mainApp = $.AXUIElementCreateApplication($.NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier);
  $.AXUIElementPerformAction(mainApp, 'AXRaise');
}

function stopExtending() {
  // Logic to stop extending the monitor
  console.log('Stopping monitor extension');
  // Implement the logic to stop extending here
}

function main() {
  const args = $.NSProcessInfo.processInfo.arguments;
//   if (args.includes('stop')) {
//     stopExtending();
//     return 0;
//   }

  // Existing code to extend monitor
//   const toggle = $.AXUIElementCopyAttributeValue(grandchild, 'AXDescription', $attr);
//   console.log("Checking grandchild - AXDescription: " + $attr[0].js);
//   if ($attr[0].js === TARGET_DEVICE_NAME) {
//     console.log("FOUND: " + TARGET_DEVICE_NAME);
//     $.AXUIElementPerformAction(grandchild, 'AXPress');
//     return grandchild;
//   }
  return false;
}

// prettier-ignore
(() => {
    ObjC.import('Cocoa'); // yes, it's necessary -- stop telling me it isn't

    ObjC.bindFunction('AXUIElementPerformAction', ['int', ['id', 'id']]);
    ObjC.bindFunction('AXUIElementCreateApplication', ['id', ['unsigned int']]);
    ObjC.bindFunction('AXUIElementCopyAttributeValue',['int', ['id', 'id', 'id*']]);
    ObjC.bindFunction('AXUIElementCopyAttributeNames', ['int', ['id', 'id*']]);

    const result = main();
    if (result !== 0) {
      changeFocusBackToMainMonitor();
    }
})();

@nedx86
Copy link

nedx86 commented Feb 6, 2025

Wait for extended display script. (yabai must be installed)

#!/bin/bash

# Display index to query
DISPLAY_INDEX=2

# Maximum wait time in seconds
MAX_WAIT_TIME=30

# Start time
START_TIME=$(date +%s)

# Loop until the display index is fetched successfully or timeout is reached
while true; do
    # Fetch the display index
    INDEX=$(yabai -m query --spaces --display $DISPLAY_INDEX 2>/dev/null | jq -r '.[0].index')

    # Check if the index was successfully retrieved
    if [[ -n "$INDEX" && "$INDEX" != "null" ]]; then
        echo "Display index $DISPLAY_INDEX found: $INDEX"
        exit 0
    else
        # Calculate elapsed time
        CURRENT_TIME=$(date +%s)
        ELAPSED_TIME=$((CURRENT_TIME - START_TIME))

        # Check if the maximum wait time has been exceeded
        if [[ $ELAPSED_TIME -ge $MAX_WAIT_TIME ]]; then
            echo "Timeout: Display index $DISPLAY_INDEX not found within $MAX_WAIT_TIME seconds."
            exit 1
        fi

        echo "Display index $DISPLAY_INDEX not found. Retrying in 2 seconds..."
        sleep 2
    fi
done

@nedx86
Copy link

nedx86 commented Feb 6, 2025

This scripts runs mpv and properly move it to extended display. can be runned via ssh.

sudo yabai --load-sa

# @FIXME @DIRTY
# killall mpv

# @exampless

# Launch mpv with IPC socket
# mpv --input-ipc-server=/tmp/mpv-ipc-server.socket /Volumes/media/video.mkv &
# mpv --aid=3 --input-ipc-server=/tmp/mpv-ipc-server.socket playlist.m3u &


# Wait for mpv to launch
# sleep 5

# Try multiple times to find and move the window
for i in {1..5}; do
    # Get the window ID of the mpv window
    WIN_ID=$(yabai -m query --windows | jq -r '.[] | select(.app == "mpv") | .id' | head -1)

    if [ -n "$WIN_ID" ] && [ "$WIN_ID" != "null" ]; then
        echo "Found mpv window with ID: $WIN_ID"

        # Get the first space ID on display 2
        SPACE_ID=$(yabai -m query --spaces --display 2 | jq -r '.[0].index')
        echo "Space ID: $SPACE_ID"

        # Move window to the space
        yabai -m window "$WIN_ID" --space "$SPACE_ID"

        # Focus the window
        # yabai -m window --focus "$WIN_ID"

        # # sleep 5

        # # yabai -m window --focus ""
        # yabai -m display --focus west

        # Wait a moment for the move to complete
        sleep 1

        # Send fullscreen command via IPC socket
        echo '{ "command": ["set_property", "fullscreen", true] }' | socat - /tmp/mpv-ipc-server.socket

        echo "Moved window to space $SPACE_ID and made fullscreen"

        sleep 1

        yabai -m display --focus west

        # Switch audio output to LG
        echo "Switching audio output to LG"
        SwitchAudioSource -s "LG"

        break
    else
        echo "Attempt $i: Waiting for mpv window..."
        sleep 2
    fi
done

@tonydehnke
Copy link

What are the last 2 scripts for @nedx86 ? Or why/when would we need to use them?

@nedx86
Copy link

nedx86 commented Feb 18, 2025

What are the last 2 scripts for @nedx86 ? Or why/when would we need to use them?

Hi there!

These scripts are designed to work with a system that allows you to control your Mac remotely. For example, even if your Mac is inaccessible (or has a disabled display), you can still start playing a video (from Mac) via Airplay to your TV using another device, such as your phone or Home Assistant.

This setup is incredibly useful if you have multiple Macs in different rooms and several TVs in various locations. It simplifies the process of streaming content seamlessly across your devices, no matter where they are.

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