Phoenix

created: Fri, 03 Jan 2025 23:37:03 GMT, modified: Fri, 03 Jan 2025 23:40:20 GMT

Phoenix is a lightweight macOS window and app manager scriptable with JavaScript. I use it to assign global keyboard shortcuts for applications and Quake-like terminal console.

Phoenix allows defining global hotkeys to manage windows and applications, and script reactions with JavaScript, so I can assign a hotkey per application, like ⌘+1 for Chrome, ⌘+2 for Sublime Text, ⌘+3 for Outlook, ⌘+4 for Slack, and so forth.

Alternatives:

A configuration script ~/.phoenix.js:

// Guake Style Applications
// Must use the following setting
// Apple menu > System Preferences > Mission Control > Displays have Separate Spaces
// uses apple script hacks to get around the following issue
// https://github.com/kasper/phoenix/issues/209
// helper for finding application names at the bottom of the file
// while developing run log stream --process Phoenix in a console

// KNOWN ISSUE
// doesn't work on minimized apps
// https://github.com/kasper/phoenix/issues/269

// common screen locations
const topHalf = { left: 0, top: 0, right: 0, bottom: 0.5 };
const leftHalf = { left: 0, top: 0, right: 0.5, bottom: 0 };
const lowerLeftHalf = { left: 0, top: 0.5, right: 0.5, bottom: 0 };
const rightHalf = { left: 0.5, top: 0, right: 0, bottom: 0 };
const full = { left: 0, top: 0, right: 0, bottom: 0 };

// the actual applications
quakeApp({
  key: "return",
  modifiers: ["command"],
  appName: "WezTerm",
  position: topHalf,
});

quakeApp({
  key: "1",
  modifiers: ["command", "shift"],
  appName: "Google Chrome",
});
quakeApp({ key: "2", modifiers: ["command", "shift"], appName: "Firefox" });
quakeApp({
  key: "3",
  modifiers: ["command", "shift"],
  appName: "Sublime Text",
});
quakeApp({ key: "4", modifiers: ["command", "shift"], appName: "Claude" });

quakeApp({ key: "a", modifiers: ["option", "shift"], appName: "Preview" });
quakeApp({
  key: "w",
  modifiers: ["option", "shift"],
  appName: "Microsoft Word",
});
quakeApp({
  key: "o",
  modifiers: ["option", "shift"],
  appName: "Microsoft Outlook",
});
quakeApp({
  key: "p",
  modifiers: ["option", "shift"],
  appName: "Microsoft PowerPoint",
});
quakeApp({ key: "s", modifiers: ["option", "shift"], appName: "Slack" });
// quakeApp({ key: "i", modifiers: ["option", "shift"], appName: "IntelliJ IDEA" });

// quakeApp({ key: "4", modifiers: ["cmd", "shift"], appName: "Microsoft PowerPoint" });
// quakeApp({ key: "5", modifiers: ["cmd", "shift"], appName: "Slack" });
// quakeApp({ key: "6", modifiers: ["cmd", "shift"], appName: "Microsoft Teams (work or school)" });

Key.on("return", ["cmd", "shift"], () => Window.focused().maximise());

/**
 * Create a keyboard event listener which implements a quake app
 * @param {string} key the key which triggers the app
 * @param {string[]} modifiers the modifiers which must be used in combination with the key (["alt", "ctrl"])
 * @param {string} appName the name of the app
 * @param {{left: number, top: number, right: number, bottom: number}} relativeFrame the margins to place the application in.
 * @param {followsMouse} boolean whether the app should open in the screen containing the mouse
 * @param {hideOnBlur} boolean whether the window should hide when it loses focus
 */
function quakeApp({
  key,
  modifiers,
  appName,
  position = full,
  followsMouse = true,
  hideOnBlur = false,
}) {
  Key.on(key, modifiers, async function (_, repeat) {
    // ignore keyboard repeats
    if (repeat) {
      return;
    }
    let [app, opened] = await startApp(appName, { focus: false });

    // if the app started
    if (app !== undefined) {
      // move the app to the currently active space
      const { moved, space } = moveAppToActiveSpace(app, followsMouse);

      // set the app position
      setAppPosition(app, position, space);

      // hide the app if it is active and wasn't just opened or moved to
      // a new space
      if (app.isActive() && !opened && !moved) {
        app.hide();
      } else {
        app.focus();
      }

      if (hideOnBlur) {
        const identifier = Event.on("appDidActivate", (activatedApp) => {
          if (app.name() !== activatedApp.name()) {
            app.hide();
            Event.off(identifier);
          }
        });
      }
    }
  });
}

/**
 * Positions an application using margins which are a percentage of the width and height.
 * left: 0 positions the left side of the app on the left side of the screen.
 * left: .5 positions the left side of the app half the width from the left side of the screen.
 * {left: 0, right: 0, top: 0, bottom: 0} would be full screen
 * {left: .25, right: .25, top: .25, bottom: .25} would be centered with half the screen height
 * {left: 0, right: .5, top: 0, bottom: .5} would be the top left quadrant
 * @param {App} app the application to set the position of
 * @param {{left: number, top: number, right: number, bottom: number}} relativeFrame the margins to place the application in.
 * @param {Space} space the space to position the app in
 */
function setAppPosition(app, relativeFrame, space) {
  const mainWindow = app.mainWindow(); // get app window
  if (space.screens().length > 1) {
    // check one space per screen
    throw new Error(DISPLAYS_HAVE_SEPARATE_SPACES);
  } else if (space.screens().length > 0) {
    // set the position of the app
    const activeScreen = space.screens()[0];
    const screen = activeScreen.flippedVisibleFrame();
    const left = screen.x + relativeFrame.left * screen.width;
    const top = screen.y + relativeFrame.top * screen.height;
    const right = screen.x + screen.width - relativeFrame.right * screen.width;
    const bottom =
      screen.y + screen.height - relativeFrame.bottom * screen.height;
    if (mainWindow.isFullScreen()) {
      mainWindow.setFullScreen(false);
    }
    mainWindow.setTopLeft({
      x: left,
      y: top,
    });
    mainWindow.setSize({
      width: right - left,
      height: bottom - top,
    });
  }
}

/**
 * Move the passed in App to the currently active space
 * Returns whether the app was moved and the space the app is now in.
 * @param {App} app the application to move to the active space
 * @param {boolean} followsMouse whether the app should open in the screen containing the mouse or the key with keyboard focus
 */
function moveAppToActiveSpace(app, followsMouse) {
  const activeSpace = followsMouse ? mouseSpace() : Space.active();
  const mainWindow = app.mainWindow(); // get app window
  let moved = false; // boolean if the app was moved to a new space
  if (mainWindow.spaces().length > 1) {
    // check one space per screen
    throw new Error(DISPLAYS_HAVE_SEPARATE_SPACES);
  }
  if (activeSpace !== undefined) {
    // check if the main window was moved
    moved = !!!(
      mainWindow.spaces().length > 0 &&
      mainWindow.spaces()[0].isEqual(activeSpace)
    );
    if (moved) {
      // otherwise remove the main window from the spaces it is in
      mainWindow.spaces().forEach((space) => {
        space.removeWindows([mainWindow]);
      });
      // add window to active space
      activeSpace.addWindows([mainWindow]);
    }
  }
  return { moved, space: activeSpace };
}

/**
 * Get or launch the application with the passed in name.
 * Returns the app and a boolean for if the app was opened. app is undefined if the application fails to start.
 * @param {string} appName the name of the application to start
 * @param {{focus: boolean}} options focus determines whether or not to focus the app on launch
 */
async function startApp(appName) {
  // https://github.com/kasper/phoenix/issues/209
  // basically a hack to get around this bug

  // get the app if it is open
  let app = App.get(appName);
  let opened = false;

  // if app is open
  if (app !== undefined) {
    // make sure it has an open window
    if (app.windows().length === 0) {
      // if not open a new window
      await osascript(`tell application "${appName}"
        try
            reopen
        on error
          log "can not reopen the app"
          activate
        end
          end tell
        `);
      opened = true;
    }
  } else {
    // if app is not open activate it
    await osascript(`tell application "${appName}"
            activate
          end tell
        `);

    app = App.get(appName);
    opened = true;
  }

  return [app, opened];
}

/**
 * Return a promise containing the Task handler used to run the osascript.
 * The promise is resolved or rejected with the handler based on the status.
 * @param {string} script the osascript script to run
 */
function osascript(script) {
  return new Promise((resolve, reject) =>
    Task.run("/usr/bin/osascript", ["-e", script], (handler) => {
      if (handler.status === 0) {
        return resolve(handler);
      } else {
        return reject(handler);
      }
    }),
  );
}

/**
 * Get the space which contains the mouse
 */
function mouseSpace() {
  const mouseLocation = Mouse.location();
  const screen = Screen.all().find((s) =>
    screenContainsPoint(s, mouseLocation),
  );
  if (screen !== undefined) {
    return screen.currentSpace();
  }
}

/**
 * Return whether the point is contained in the screen
 * @param {Screen} screen a screen object to check for a point
 * @param {Point} point a point using flipped coordinates (origin upper left)
 */
function screenContainsPoint(screen, point) {
  const frame = screen.flippedFrame();
  return (
    point.x >= frame.x &&
    point.x <= frame.x + frame.width &&
    point.y >= frame.y &&
    point.y <= frame.y + frame.height
  );
}

/**
 * Error message for invalid display settings
 */
const DISPLAYS_HAVE_SEPARATE_SPACES = `Must set Apple menu > System Preferences > Mission Control > Displays have Separate Spaces`;

// Finding Application Names
// to find application names run the following command
// open the app you're interested in
// open a phoenix log `log stream --process Phoenix`
// uncomment the following keyboard shortcut to trigger a log of open application names
// Key.on("a", ["alt", "shift"], () => {
//   const array = App.all()
//     .map((a) => a.name())
//     .sort();
//   let chunk = 3;
//   Phoenix.log();
//   Phoenix.log("************ APPLICATIONS START *************");
//   for (let i = 0, j = array.length; i < j; i += chunk) {
//     let temp = array.slice(i, i + chunk);
//     Phoenix.log(temp);
//   }
//   Phoenix.log("************ APPLICATIONS END *************");
//   Phoenix.log();
// });