Skip to content
shayredmondPublic

About

Electron wrapper for Google Meet to provide better window management and global keyboard shortcuts

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

MeetLoaf

A standalone macOS, Windows and Linux app for Google Meet — separate window, true OS-global hotkeys, and automatic routing of meet.google.com links from your browser and other apps (Slack, Mail, Calendar, etc.) into the app.

Three pieces:

  1. app/ — Electron app that wraps Meet, registers the meet:// URL scheme, auto-grants camera/mic, and binds configurable global hotkeys.
  2. extension/ — Firefox WebExtension that intercepts meet.google.com/xxx-yyyy-zzz navigations and redirects to meet://.
  3. Velja (third-party) — system URL router that catches meet links clicked from Slack/Mail/Calendar/etc. and sends them to MeetLoaf.

For colleagues: install a pre-built release

MeetLoaf isn't signed by Apple or Microsoft, so how you download it decides whether the OS argues with you. Paste the one-liner for your platform and it won't.

macOS

curl -L -o MeetLoaf.dmg https://github.com/shayredmond/meetloaf/releases/latest/download/MeetLoaf-mac-arm64.dmg
hdiutil attach MeetLoaf.dmg
cp -R /Volumes/MeetLoaf/MeetLoaf.app /Applications/
hdiutil detach /Volumes/MeetLoaf
open -a MeetLoaf

Or open the DMG in Finder and drag MeetLoaf across to the Applications shortcut — but see the Gatekeeper note below if you got the DMG from a browser.

No xattr step, and no Gatekeeper prompt. The quarantine flag that triggers the "MeetLoaf is damaged" error isn't a property of the app — it's added by whatever downloads it, and only apps that opt in (browsers, Slack, Mail) do. curl doesn't, so there's nothing to strip.

If you downloaded it through a browser instead, the flag is there and macOS will refuse to open the app. Fix it with:

xattr -dr com.apple.quarantine /Applications/MeetLoaf.app

Or approve it in System Settings → Privacy & Security, where a blocked app shows an Open Anyway button shortly after you try to launch it. (Right-click → Open no longer works on macOS 15+; Apple removed that bypass.)

macOS via Homebrew

If the tap is set up (see below), this is the least-friction route — and brew upgrade keeps you current, which matters because macOS can't auto-update itself yet:

brew install --cask shayredmond/tap/meetloaf
xattr -dr com.apple.quarantine /Applications/MeetLoaf.app

The xattr line is required: MeetLoaf is ad-hoc signed rather than notarized, and Homebrew quarantines the apps it downloads. Without it you'll get the same "damaged" error described above. Homebrew used to offer --no-quarantine for this, but current versions reject the flag (Homebrew/brew#20755).

Later, brew upgrade --cask meetloaf picks up new releases. The upgraded app is quarantined again, so run the xattr line after each upgrade too.

Windows 10/11

Invoke-WebRequest -Uri https://github.com/shayredmond/meetloaf/releases/latest/download/MeetLoaf-Setup.exe -OutFile MeetLoaf-Setup.exe
.\MeetLoaf-Setup.exe

That installer carries both x64 and Arm64, so there's nothing to choose. It installs per-user — no admin — and registers the meet:// handler. If you'd rather not download both architectures, MeetLoaf-Setup-x64.exe and MeetLoaf-Setup-arm64.exe are on the release page and roughly half the size.

Same idea as macOS: SmartScreen's "Windows protected your PC" warning is triggered by the Mark-of-the-Web, which browsers attach to downloads and Invoke-WebRequest doesn't.

If you downloaded it through a browser, either click More info → Run anyway, or strip the mark first:

Unblock-File .\MeetLoaf-Setup.exe

Ubuntu LTS (x64)

wget -O /tmp/MeetLoaf.deb https://github.com/shayredmond/meetloaf/releases/latest/download/MeetLoaf-linux-amd64.deb
sudo apt install /tmp/MeetLoaf.deb

The .deb installs to /opt/MeetLoaf, puts meetloaf on your PATH, adds an app-grid entry, registers the meet:// handler, and on 24.04 installs the AppArmor profile Electron needs there. Remove it with sudo apt remove meetloaf.

Prefer not to install system-wide? MeetLoaf-linux-x86_64.AppImage is on the release page too — see § Linux below.

MeetLoaf checks for updates on launch and from the MeetLoaf → Check for Updates… menu. What happens next depends on your platform:

  • Windows and the Linux AppImage download and install. You're asked before the download starts and again before it restarts; decline the restart and it installs the next time you quit.
  • macOS and the Linux .deb offer to open the release page, and you re-download by hand. (The .deb belongs to apt; MeetLoaf won't ask for root to replace it behind apt's back.)

That split isn't an oversight. On macOS the updater drives Squirrel.Mac, which verifies the downloaded app's code signature before applying it — and MeetLoaf is ad-hoc signed, with no Apple Developer ID. That check can't be skipped, and shouldn't be: it's what stops an update being swapped in transit. Buying a Developer ID (§ About signing) is what unlocks macOS auto-update; the code is already there behind a platform check.

Once MeetLoaf is installed, follow §2 Firefox extension and §3 Velja below to wire link routing.


Windows

MeetLoaf also runs on Windows 10/11 (x64 and Arm64). Download MeetLoaf-Setup.exe from the latest release — it covers both architectures — and run it — it installs per-user, no admin needed, and registers the meet:// handler. See the install snippet at the top of this README for the download that avoids the SmartScreen warning.

The installer is unsigned, so SmartScreen shows "Windows protected your PC" on first run: click More info → Run anyway. That's the Windows counterpart of the xattr step above.

Differences from macOS:

  • Shortcuts use Ctrl where macOS uses ⌘. Configs synced from a Mac keep working — Cmd+… bindings are read as Ctrl+… on Windows.
  • Tray icon lives in the notification area (left-click toggles the window, right-click for the menu). Closing the window hides it there; quit from the tray menu.
  • Screen sharing shows MeetLoaf's own picker (there's no OS picker on Windows), with an option to share system audio.
  • Link routing: there's no Velja on Windows. Install the Firefox or Chrome/Edge extension in your default browser (Settings → Routing) — links clicked in Slack, Outlook etc. open in that browser, and the extension hands them to MeetLoaf.

Build locally with cd app && npm install && npm run dist:win → app/dist/MeetLoaf-Setup-<arch>.exe. For npm start on Windows, run npm run icon:win once first so the tray icon exists.


Linux

MeetLoaf runs on Ubuntu LTS (x64). It's tested on 26.04 (GNOME 50, Wayland), and built on 22.04 so the same packages should also install on 22.04 and 24.04 (untested). Other recent distros should work from the AppImage. Install the .deb with the snippet at the top of this README, or use the AppImage:

mkdir -p ~/Applications && wget -O ~/Applications/MeetLoaf.AppImage https://github.com/shayredmond/meetloaf/releases/latest/download/MeetLoaf-linux-x86_64.AppImage
chmod +x ~/Applications/MeetLoaf.AppImage
~/Applications/MeetLoaf.AppImage

AppImages need FUSE 2: sudo apt install libfuse2t64 on 24.04 (libfuse2 on 22.04). On 24.04, AppArmor also blocks the unprivileged user namespaces Chromium's sandbox uses. The .deb ships a profile that allows them, but an AppImage can't install one, so it fails to start with a sandbox error. Either use the .deb, or run the AppImage with --no-sandbox.

Differences from macOS:

  • Shortcuts use Ctrl where macOS uses ⌘, as on Windows.
  • Global hotkeys on Wayland don't register from inside the app. On GNOME 50, every globalShortcut.register() fails (electron#51875; still failing on Electron 44.7). MeetLoaf logs Shortcut unavailable for each one. Bind them in GNOME instead: every action can be triggered by running meetloaf --shortcut=<name> (mute, camera, hand, toggleWindow, newMeeting, presentation, presentTab, leave). The running app performs it without raising its window. Add one under Settings → Keyboard → View and Customise Shortcuts → Custom Shortcuts, or from a terminal:
    p=/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/meetloaf-mute/
    s=org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:$p
    gsettings set $s name 'MeetLoaf: mute'
    gsettings set $s command 'meetloaf --shortcut=mute'
    gsettings set $s binding '<Control><Alt>m'
    # then append "$p" to: gsettings get org.gnome.settings-daemon.plugins.media-keys custom-keybindings
    Pick combos that are free system-wide: a GNOME binding fires in every app, so Ctrl+W-style keys would break other apps. On X11, MeetLoaf's own global shortcuts work as on Windows.
  • Screen sharing on Wayland uses the desktop's own picker (the xdg-desktop-portal dialog), so there's no MeetLoaf picker. On X11 MeetLoaf shows its own picker, as on Windows. Sharing system audio isn't supported on Linux.
  • Tray icon needs a desktop that shows StatusNotifier icons. Ubuntu's default session does, via the built-in AppIndicator extension. Clicking it opens the menu, and Show / Hide MeetLoaf is the first item. Closing the window hides it to the tray; quit from the tray menu. Without a tray, launching MeetLoaf again brings the window back.
  • Link routing: there's no Velja on Linux either. Install the Firefox or Chrome extension (Settings → Routing). Ubuntu's Firefox is a snap and can't read files inside the app, so MeetLoaf copies the extension to ~/Downloads before handing it over.
  • Permissions: Linux has no per-app camera or microphone permission outside snaps and flatpaks, so the Permissions panel shows them as unknown.

Build locally on Linux with cd app && npm install && npm run dist:linux → app/dist/MeetLoaf-linux-amd64.deb and MeetLoaf-linux-x86_64.AppImage. For npm start, run npm run icon:win once first. Despite the name, it's the cross-platform resvg script, and it generates the tray icon Linux uses.


For you (developer): build and publish

Prereqs

brew install librsvg node

Node 20+ recommended.

One-time setup

Update app/package.json's repository.url to the real GitHub repo. Without this, the in-app update checker silently does nothing.

"repository": {
  "type": "git",
  "url": "git+https://github.com/YOURNAME/MeetLoaf.git"
}

Local dev loop

cd app
npm install
npm start           # launches MeetLoaf against dev Electron

Global hotkeys, camera/mic, and meet:// handoff all work in dev. The only thing that requires a packaged .app in /Applications is the OS registering MeetLoaf as the default meet:// handler for other apps.

Permissions

Settings → Permissions shows what the OS has granted MeetLoaf, with a link into the relevant system pane for each. What it can tell you differs by platform, and the panel is built around those limits rather than pretending otherwise:

macOS Windows Linux
Camera / Microphone status read read (global Win10+ setting) unknown — no such permission
Screen Recording status read row hidden — no such permission row hidden — the portal asks per share
Prompting from the app only while not yet requested not possible not possible
Local Network probed, not read row hidden — no such permission row hidden — no such permission

Two things worth understanding. A denied permission can't be re-requested by the app — on macOS the OS refuses to ask twice, and Windows has no prompt API at all, so the honest action is the Settings link. And Local Network has no API whatsoever: Electron can neither read nor request it, so that row offers a Test instead, which attempts to reach your Home Assistant base URL and reports what happened. That's empirical rather than authoritative — a failure could be Home Assistant being down — but it's the only signal that exists, and it's the permission most likely to be silently missing.

Configure hotkeys

Open MeetLoaf → Settings… (⌘,). Click any shortcut field and press the key combo you want — Raycast-style live recording. ⌫ clears, Esc cancels. Saves automatically on each change and re-registers global shortcuts immediately.

Under the hood it writes ~/.config/meetloaf/config.json (or $XDG_CONFIG_HOME/meetloaf/config.json if you've set that), which you can also hand-edit if you prefer — and easily back up via your dotfiles manager:

{
  "shortcuts": {
    "mute": "Cmd+Shift+M",
    "camera": "Cmd+Shift+V",
    "hand": "Cmd+Shift+H",
    "toggleWindow": "Cmd+Shift+Backslash",
    "newMeeting": "Cmd+Shift+N",
    "presentTab": "Cmd+Shift+P",
    "leave": { "accelerator": "Cmd+W", "global": false }
  },
  "window": { "width": 1200, "height": 800, "startFullScreen": false, "popOutPresentation": true, "presentBrowserTab": true, "hideDock": false, "meetDarkTheme": true },
  "screens": { "mainWindow": null, "presentationWindow": null, "presentationSize": "default", "presentationWidth": 1280, "presentationHeight": 800 }
}

Start instant meeting creates a meeting and puts its link on your clipboard, ready to paste. It's unbound by default; bind it to something global and you can spin up a call from any app. It works by loading meet.google.com/new and reading the meeting URL Meet redirects to — no clicking around Meet's UI, so it doesn't break when Meet moves a button. Pressed while you're already in a call it does nothing, rather than navigating away and hanging up on you.

Leave meeting is bound to ⌘W out of the box. It's local-only (not global) on purpose — a global ⌘W would hijack window-close everywhere, so it only hangs up while MeetLoaf is focused. Clear or rebind it in Settings like any other shortcut.

Hand-edits use Electron's accelerator syntax. Each shortcut is { "accelerator": "…", "global": true|false }; a bare string is treated as a global binding. Empty string = unbound. Restart the app after hand-editing.

Note on Picture-in-Picture: Meet's own More options → Picture-in-picture doesn't work inside MeetLoaf. It relies on the Document Picture-in-Picture API, which Electron doesn't render (electron#39633). The older per-video requestPictureInPicture API is wired up in Electron and renders for an ordinary video, but on Meet's remote WebRTC tiles it surfaces no window and the request never completes (the meeting keeps working — it doesn't crash). Both were tested and ruled out — MeetLoaf has no PiP. Use always-on-top (Settings → Window), or the presentation pop-out below, to keep things visible instead.

Hide the Dock icon (macOS)

MeetLoaf can run as a menu-bar app with no Dock tile. Turn it on in Settings → Window → Hide Dock icon, or set "window": { "hideDock": true }. It takes effect immediately and no restart is needed. With the Dock icon gone there's no app menu bar either, so the window comes back from the menu-bar icon (left-click toggles it, right-click for the menu), the Show / hide MeetLoaf shortcut, or any Meet link routed to the app. Keyboard shortcuts keep working, including ⌘, for Settings and ⌘Q to quit. The Dock icon stays if the menu-bar icon can't be created, since there'd otherwise be no way back to a hidden window.

Match system dark mode in Meet

Meet's home page and pre-join screen only come in light. Google ships no dark theme for Meet on the web, and the page ignores the OS setting even though MeetLoaf passes it through. So when your system is in dark mode, MeetLoaf turns on Chromium's own auto dark mode for the Meet window — the engine behind Chrome's "Auto Dark Mode for Web Contents" flag. It darkens light backgrounds and lightens dark text at paint time, and leaves images, video and Meet's already-dark in-call UI as they are. Nothing is injected into the page. It follows the OS live: switch your system theme and the page switches with it, no reload. Nothing changes for light-mode users.

On by default. Turn it off in Settings → Window → Match system dark mode in Meet, or set "window": { "meetDarkTheme": false }. MeetLoaf's own windows (Settings, the welcome tour, the screen picker) follow the OS theme regardless.

Presentation pop-out

When someone else starts presenting, MeetLoaf asks Meet to open the shared screen in its own window — the same thing as clicking Open in new window on the presentation tile. Meet moves the share out, so the main window goes back to showing faces rather than showing the presentation twice.

On by default. Turn it off in Settings → Window → Pop out presentations, or set "window": { "popOutPresentation": false }. View → Pop Out Presentation triggers it by hand at any time.

Earlier versions opened their own window and handed it Meet's MediaStream by reference. That worked, but it duplicated the presentation instead of moving it, and the window had its own failure modes. Meet's own control does the job properly and is a few hundred lines less of ours.

Worth knowing it is not the Picture-in-picture menu item, which uses the Document PiP API that Electron has never implemented (electron#39633) and which silently renders nothing. Open in new window is an ordinary popup and works.

Detection only covers other people's presentations — your own share is excluded, since a window showing the screen you are sharing is an infinite hall of mirrors.

Screens

With more than one display, Settings → Screens chooses where MeetLoaf's windows open: the Meet window on one screen (a teleprompter next to the camera, say) and the presentation pop-out on another, at Meet's own size, filling the screen, or at a fixed width × height centred on it. The tab only appears when two or more displays are connected. Pair it with Settings → Window → Start in full screen to have the Meet window take over its screen.

Changes apply right away, and again when a configured screen is plugged in after launch — the window moves there once the display geometry settles. A window you drag elsewhere stays put; View → Move Windows to Their Screens puts everything back. A full-screen window leaves full screen, moves, and goes full screen again on the other side.

A screen is stored as { "label": "LG ULTRAFINE", "id": 2 }; a bare "LG ULTRAFINE" works too when hand-editing. The id tells twin monitors apart and the label survives the id changing, which it does across reboots on Windows. A configured screen that isn't connected shows as (not connected) in Settings and the window opens wherever the system puts it until it comes back. null means the system decides, which is the default and what every earlier version did.

"window": { "startFullScreen": true },
"screens": {
  "mainWindow": "Elgato Prompter",
  "presentationWindow": "LG ULTRAFINE",
  "presentationSize": "custom",
  "presentationWidth": 1340,
  "presentationHeight": 990
}

The presentation rows only show while Pop out presentations is on, but the placement also applies to pop-outs you open by hand with View → Pop Out / Re-dock Presentation.

Tiling window managers

A tiling window manager such as yabai decides where the windows it tiles go, so it puts a MeetLoaf window straight back: the window flickers towards its configured screen and returns. Either stop yabai tiling MeetLoaf, so Settings → Screens takes effect:

yabai -m rule --add app="^MeetLoaf$" manage=off

or keep MeetLoaf tiled, leave its screens on the system default, and have yabai pick the display (its index from yabai -m query --displays). This puts the Meet window and the presentation pop-out on the same display:

yabai -m rule --add app="^MeetLoaf$" display=2

Add the rule to your yabairc to keep it. A new rule only applies to windows that open afterwards; yabai -m rule --apply applies it to windows already open.

Present a browser tab

MeetLoaf's screen sharing offers whole screens and windows only. Chrome's tab sharing — the tab list, tab audio, Share this tab instead — only exists when Chrome itself handles the share, and MeetLoaf is a separate Chromium that can't see Chrome's tabs.

Hover Meet's Share screen button and a Present a browser tab option pops up above it (it's also in View → Present a Browser Tab…, the tray menu, and bindable as a shortcut). It opens the meeting you're in, in your default browser if it's Chromium-based (Chrome, Arc, Brave, Edge…) or else the first one installed, in Meet's Companion mode: meet.google.com/xxx-yyyy-zzz?companion=1. Companion mode joins with no mic, camera or speaker, so there's no echo. Click Start Companion mode, then Present → A tab as usual. You stay in the call in MeetLoaf; the companion shows up as a second you in the participant list while it's open.

On by default. Turn it off in Settings → Window → Present a browser tab, or set "window": { "presentBrowserTab": false } — that removes the popover, the menu items and the shortcut.

The Chrome extension lets ?companion=1 links through rather than handing them back to MeetLoaf, so this works with routing on. That needs extension 0.2.4 or later — reload the unpacked extension after updating.

Display stays awake in calls

MeetLoaf keeps the display awake while you're connected to a call, so the screen doesn't sleep and lock mid-meeting when you're only listening — cameras off, or the window minimised — and haven't touched the keyboard or mouse in a while. Chromium already keeps the machine itself awake during a call, and the display while video is playing; this covers the rest. It's released the moment you leave the call. Not configurable.

Setting up the Homebrew tap (one-time)

The cask lives at packaging/homebrew/meetloaf.rb in this repo, and .github/workflows/homebrew.yml renders it into a tap whenever a release is published. Three things have to exist before it does anything:

  1. A tap repository named homebrew-tap under your account — the homebrew- prefix is what lets brew install --cask shayredmond/tap/meetloaf resolve. It can be empty; the workflow creates Casks/meetloaf.rb and a README on first run.
    gh repo create shayredmond/homebrew-tap --public --description "Homebrew tap for MeetLoaf"
  2. A tap-publish environment on this repo, which is what the deploy key is scoped to:
    gh api -X PUT repos/shayredmond/meetloaf/environments/tap-publish
    Add a required reviewer to it under Settings → Environments if you want each tap push to wait for your approval. Worth considering: this credential can change what brew install delivers to everyone using the tap, and an approval gate means a modified workflow can't use it unattended.
  3. A deploy key, held as an environment secret. A deploy key is scoped to one repository by construction and never expires, so there's no renewal to forget:
    ssh-keygen -t ed25519 -f meetloaf-tap -N "" -C "meetloaf release -> homebrew-tap"
    gh repo deploy-key add meetloaf-tap.pub --repo shayredmond/homebrew-tap --title "meetloaf release" --allow-write
    gh secret set TAP_DEPLOY_KEY --repo shayredmond/meetloaf --env tap-publish < meetloaf-tap
    rm meetloaf-tap meetloaf-tap.pub
    The private key goes from the file straight into the secret and both files are deleted, so it never appears on screen or in shell history.

Without the secret the workflow still runs and renders the cask, then logs that it skipped publishing — so nothing breaks for anyone who hasn't set a tap up.

It deliberately triggers on published, not on the tag build: releases are created as drafts, and a cask pointing at a draft's download URL would 404 for everyone until you pressed Publish. Set TAP_REPO as a repository variable to point somewhere other than shayredmond/homebrew-tap.

On log exposure: the key is written to a file and is never an argument to git. A token embedded in an HTTPS remote URL can surface in git's own error messages; an SSH remote carries no credential at all. Actions masks secrets either way, but this repo's logs are public, so the smaller surface matters. GitHub's SSH host key is pinned from api.github.com/meta rather than accepted trust-on-first-use.

Build a DMG locally

cd app
npm run icon        # SVG → icon.icns
npm run dist        # arm64 .app, ad-hoc sign, wrap in DMG

Output: app/dist/MeetLoaf-<version>.dmg

Drop that file into Slack / Drive / wherever and colleagues can use the install snippet at the top of this README.

Publish a release via GitHub Actions

  1. Bump version in app/package.json (e.g. 0.1.0 → 0.2.0).
  2. Commit, tag, push:
    git tag v0.2.0
    git push origin v0.2.0
  3. CI (.github/workflows/release.yml) builds every platform in parallel — the arm64 DMG on macos-14, the x64 + arm64 installers on windows-latest, the x64 .deb + AppImage on ubuntu-22.04 — then a separate release job collects them into a GitHub Release named v0.2.0.
  4. The release is created as a draft. Nothing is public yet. Open it under Releases, download the DMG and the installers, check they run, then press Publish release.

Step 4 is the point of no return, and the only one that reaches your colleagues — the update checker reads /releases/latest, which excludes drafts. If something's wrong, delete the draft and the release never existed. Merging a PR does none of this: only a v* tag starts a release at all.

The same workflow runs on every pull request, building every platform and uploading them as workflow artifacts without publishing anything. That's deliberate: a Windows build problem should surface on the PR, not halfway through cutting a release. Only a v* tag reaches the release job, and only that job gets a write-scoped token.

The release job refuses to run if the tag and app/package.json disagree — tagging v0.2.0 against a 0.1.9 package.json would otherwise prompt everyone to upgrade to a build reporting the version they already have, and keep prompting forever.

Once you publish, colleagues on older versions get prompted by MeetLoaf's update checker within 5 seconds of next launch.

About signing

We ad-hoc sign (codesign --sign -). That's enough to satisfy Apple Silicon's "must be signed to execute" rule, but not notarized, so macOS shows a Gatekeeper prompt on first launch until the user strips the quarantine attr. Upgrading to a Developer ID cert + notarization later is a one-line change in app/scripts/build-dmg.sh — swap - for the cert identity and add an xcrun notarytool submit step.


2. Firefox extension

Best path for a permanent install: bundle a signed XPI inside MeetLoaf so colleagues can install it from Settings → Routing → Install.

One-time setup (developer)

  1. Get an AMO API key at https://addons.mozilla.org/developers/addon/api/key/.
  2. Sign + bundle:
    cd extension
    export WEB_EXT_API_KEY='user:xxxxxxx:xx'
    export WEB_EXT_API_SECRET='hex...'
    ./build.sh
    This calls web-ext sign --channel=unlisted (signed by Mozilla, not listed publicly), then drops the resulting .xpi into app/firefox-extension.xpi. Commit that file — see the note below. Bump version in extension/manifest.json before each re-sign; AMO rejects a version it has already seen for this add-on ID.
  3. Rebuild the desktop app:
    cd ../app && npm run dist
    The XPI is now bundled inside MeetLoaf.app.

For colleagues

Open MeetLoaf → Settings (⌘,) → Routing → Install. MeetLoaf detects Firefox in /Applications, opens the bundled XPI in it, and Firefox shows its standard "Add MeetLoaf Router?" prompt. One click.

Re-sign + redistribute the app whenever the extension code changes — the signed XPI carries a version that has to match what's published.

The add-on ID changed to meetloaf@shayredmond.github.io. AMO identifies an add-on by that ID, so this counts as a brand-new add-on: the first ./build.sh after this creates a fresh unlisted listing, and app/firefox-extension.xpi has to be regenerated before the bundled installer works again. Anyone running the old extension keeps it until they install the new one — worth having them remove the old one, or both will try to route the same links.

Commit the signed XPI. app/firefox-extension.xpi is tracked on purpose, even though it's a build artifact. CI builds the app from a clean checkout and never runs extension/build.sh, so an uncommitted XPI means every published release ships without the extension and Settings → Routing → Install reports it as missing. Sign, commit the .xpi, then tag.

Alternative — temporary load (no signing)

For a quick test without going through AMO:

  1. Open about:debugging#/runtime/this-firefox
  2. Load Temporary Add-on… → pick extension/manifest.json

(Resets when you restart Firefox.)

2b. Chrome / Chromium extension (Arc, Brave, Edge, Vivaldi, etc.)

Chrome blocks .crx side-loading, so for personal-use extensions you either submit to the Chrome Web Store (~$5 one-time, public/unlisted listing) or load the unpacked source. MeetLoaf bundles the source and points the user at it.

For colleagues

Settings (⌘,) → Routing → next to "Chrome / Chromium extension":

  1. Click Open extensions — your browser opens chrome://extensions.
  2. Toggle Developer mode (top-right of that page).
  3. Click Show folder in MeetLoaf — Finder opens with the extension folder selected.
  4. Drag the folder onto the extensions page, or click Load unpacked and select it.

The extension is now active. Click its icon (under the puzzle-piece menu if it isn't pinned) for an on/off switch; when routing is off, the icon shows an "off" badge and Meet links open in the browser as usual.

Unpacked extensions don't update themselves — after a MeetLoaf update, your browser keeps running the old extension until you click reload on it. The extension tells MeetLoaf its version with each link it hands over, and MeetLoaf prompts once (Open Extensions / Show Folder) when that's older than the copy it bundles. Settings → Routing shows whether the loaded extension is up to date. Extensions from before 0.2.4 don't report a version, so they can't be detected.

The first Meet link you click shows the browser's "Open MeetLoaf?" prompt — tick Always allow and click Open. From then on links go straight to MeetLoaf, and the hand-off tab closes itself after a 10-second countdown (or goes back, if you clicked the link from another page) — Close now skips the wait. Join in the browser instead on the hand-off tab lets that one tab load Meet normally. Companion-mode links (?companion=1) are never redirected — see Present a browser tab.

How it works: a declarativeNetRequest rule redirects meet.google.com/xxx-yyyy-zzz to the extension's handoff.html before the request is sent, so the Meet page never loads in the browser and never grabs the camera/mic. (An earlier version reacted to webNavigation.onBeforeNavigate, which can't cancel the navigation — the call opened in both the browser and MeetLoaf.)

Submitting to the Chrome Web Store (optional, one-click flow)

If you'd rather have a single-click install for colleagues:

  1. Pay the one-time $5 Chrome Web Store developer fee
  2. Zip the extension-chrome/ folder, submit as an unlisted item
  3. ~1 day review, then you have a permanent install URL
  4. Replace MeetLoaf's "Open extensions / Show folder" buttons with shell.openExternal('https://chromewebstore.google.com/detail/...')

Alternative — sign manually

Get API credentials at https://addons.mozilla.org/developers/addon/api/key/, then:

cd extension
npx web-ext sign --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET --channel=unlisted

This produces a signed .xpi in web-ext-artifacts/ — drag it into Firefox to install permanently.

Click the toolbar icon to toggle routing on/off.

3. Velja (system URL router)

brew install --cask velja

Open Velja → Preferences:

  1. Set Velja as the default browser (Velja prompts for this).
  2. Set your default browser fallback to Firefox.
  3. Add a Browser Rule:
    • Match: host equals meet.google.com
    • Open in: MeetLoaf

Now a meet link clicked from anywhere (Slack, Mail, Fantastical, Terminal, ...) goes through Velja → MeetLoaf.


How the pieces fit

Link source Caught by Path to MeetLoaf
Click inside Firefox Extension meet.google.com/... → meet://...
Click in Slack / Mail / Calendar / Terminal Velja Velja rule routes meet.google.com → MeetLoaf
Direct meet:// URL MeetLoaf Native URL scheme handler

If you're already in a meeting when a new meet:// link arrives, MeetLoaf prompts you before switching.

4. Home Assistant (meeting automations)

MeetLoaf can fire a Home Assistant automation the moment you join a meeting, and another when you leave — "on air" sign, desk lamp, do-not-disturb, whatever you've got.

Configure it in Settings → Home Assistant.

What counts as "joined"

By default, the earliest point MeetLoaf can tell you're in a meeting. Usually that's reaching the waiting room — the pre-join screen where you pick your camera and mic. That's deliberate: these automations are usually preparation (camera on, lights up, mic switched over), so they have to have run by the time you're configuring devices, not after.

Where there's no waiting room, it fires on connection instead. Starting a meeting with the New button or a meet://new link drops you straight into the call, and the join still fires — just at the moment you connect rather than a few seconds earlier. You lose the head start, never the event.

The meeting code alone can't tell these apart — it's in the URL for every screen — so MeetLoaf reads Meet's own controls and distinguishes three phases:

Phase Detected by Counts as "in a meeting"?
lobby a Join now / Ask to join button yes (default), no in Connected mode
in_call the Leave call control yes
post_call a Rejoin / Return to home screen button no

That third row is the one that matters most: after you hang up, Meet leaves you on a "You left the meeting" screen still at the meeting URL. It's counted as not in a meeting, so your leave automation runs and nothing is left switched on.

Anything MeetLoaf doesn't recognise counts as not in a meeting. That direction is chosen on purpose — if Meet renames a label, the worst case is an automation that doesn't fire, never a camera or light left on indefinitely.

Fire "join" when in Settings switches between Waiting room (default) and Connected only. The two aren't alternatives so much as a floor: Waiting room means waiting room or connection, whichever comes first, while Connected only suppresses the waiting-room case entirely. Pick Connected only for something that shouldn't announce you early, like an on-air sign.

Leave also fires on navigating away from the meeting, on a renderer crash, and on quitting MeetLoaf mid-call. A phase change has to hold briefly before it commits (400ms entering, 2s leaving) so Meet's DOM churn during a reconnect can't run your automations twice.

Note that always-on-top (Settings → Window) is unaffected by this setting — it still engages only once you're actually connected, never in the waiting room.

Webhook mode (default, recommended)

No credentials. In Home Assistant, create an automation with a Webhook trigger, then paste its webhook ID into MeetLoaf.

# Home Assistant automation
alias: Meeting started
triggers:
  - trigger: webhook
    webhook_id: meetloaf-joined
    local_only: true          # drop this if you're going via Nabu Casa
actions:
  - action: light.turn_on
    target:
      entity_id: light.on_air_sign

MeetLoaf POSTs JSON, so the automation can see the details:

{
  "event": "join",
  "app": "meetloaf",
  "version": "0.1.5",
  "meeting_code": "abc-defg-hij",
  "url": "https://meet.google.com/abc-defg-hij",
  "timestamp": "2026-08-23T10:04:11.482Z",
  "phase": "lobby",
  "reason": "dom"
}

Reachable as {{ trigger.json.event }}, {{ trigger.json.meeting_code }}, and so on — which means one webhook can serve both events if you'd rather branch inside HA than use two automations.

phase is the phase that triggered it — lobby, in_call, post_call or away — so one automation can do less at the waiting room and more once you're actually connected.

reason says what detected the transition: dom (a control appeared/disappeared), navigation (left the meeting URL), poll (main's backstop timer), quit (you quit MeetLoaf mid-call), renderer-gone (the Meet page crashed). Useful if you want "turn the lamp off, but only if I actually hung up".

Paste either a bare webhook ID (combined with Base URL) or a full webhook URL — handy for a Nabu Casa cloud webhook, which gives you a public URL with no port forwarding.

Service-call mode

If you'd rather trigger an entity you already have, switch to Service call, supply a base URL and a long-lived access token, and give an entity ID. The service is derived from the domain:

Entity domain On join On leave
automation trigger trigger
script, scene turn_on turn_on
input_boolean, switch, light, fan, siren turn_on turn_off

That last row means a single input_boolean.in_a_meeting helper mirrors your call state, which is usually nicer to build automations against than two one-shot triggers.

Why webhook is the default: config.json lives in ~/.config/meetloaf/ precisely so your dotfiles manager can track it. A long-lived access token in that file grants full API access to your whole Home Assistant instance — a webhook ID only triggers the one automation, and you can revoke it by deleting the trigger. Service-call mode is there when you want it; just know what you're committing.

Notes

  • The Test buttons in Settings send a real request (with "test": true in the payload) and report the HTTP status or the exact error, so you can verify before your next meeting.
  • Requests time out after 5s and retry once on a 5xx or a network error. 4xx isn't retried — it means the ID or token is wrong.
  • Failures are logged, never shown as a dialog. A smart-home hook going quiet must not interrupt a call. Check Console.app (or run from a terminal) for [meetloaf] Home Assistant … failed lines.
  • Requests go out from the app's main process via Node, which uses its own CA store — a Home Assistant behind a self-signed certificate will be rejected. Use plain http:// on your LAN, or a real certificate.

Troubleshooting

  • "MeetLoaf is damaged and can't be opened" on first launch: run xattr -dr com.apple.quarantine /Applications/MeetLoaf.app. See the install snippet at the top.
  • Shortcuts don't fire: some accelerators are taken by macOS/other apps. Check Console.app for "Shortcut unavailable" warnings, or pick different bindings. Bindings on punctuation keys (`, -, [, ;, /, …) recorded before 0.2.3 were written in a form Electron rejects and never registered; 0.2.3 translates them on load, so they start working without re-recording.
  • Mic/camera button clicks do nothing: Meet occasionally changes aria-labels. Open DevTools (View → Toggle Developer Tools) and inspect the button.
  • Firefox extension doesn't redirect: confirm it's enabled (toolbar icon has no "off" badge); confirm about:config → network.protocol-handler.external.meet is true.
  • Firefox "Launch Application" dialog every link: tick "Remember my choice" the first time.
  • Velja doesn't see MeetLoaf: MeetLoaf has to be in /Applications and launched at least once for Velja to find it.
  • Home Assistant never fires and Settings → Test reports a connection error (macOS): if Home Assistant is on your LAN, macOS is probably blocking MeetLoaf's local network access. Check System Settings → Privacy & Security → Local Network. An app that has never successfully asked doesn't appear in that list at all, so absent is as much a failure as switched off — reinstalling a build that declares NSLocalNetworkUsageDescription (0.2.2 and later) makes macOS prompt properly. Running MeetLoaf from a terminal masks this entirely, because it inherits the terminal's own grant — so "it works when I launch it from the shell" is a symptom, not a workaround.
  • Home Assistant automation never fires, but MeetLoaf says it sent: check the automation's own conditions before suspecting MeetLoaf. In Home Assistant, open the automation → Traces; execution: failed_conditions means the webhook arrived and your automation stopped itself. A gate like "only when I'm in the room" will do this silently.
  • Home Assistant automation never fires: hit Test in Settings → Home Assistant — it reports the real HTTP status. 404 on a webhook means the ID is wrong or the automation was deleted; 401/403 in service-call mode means the token is bad. Also check the integration is actually Enabled.
  • Home Assistant fires late, at connection instead of the waiting room: that meeting had no waiting room — starting one with New or meet://new goes straight in. The event still fires; only the head start is lost.
  • Home Assistant doesn't fire at the waiting room: check Fire "join" when is set to Waiting room. If it still doesn't, Meet has probably renamed the pre-join button — open DevTools (View → Toggle Developer Tools) and look for [meetloaf] phase: lines. unknown where you expected lobby means the label changed; the patterns are at the top of the call-phase watcher in app/main-inject.js.
  • Windows open on the wrong screen: check Settings → Screens — a screen listed as (not connected) isn't being found, which usually means the OS renamed it (the label comes from the monitor; a KVM or a hub can change it). Re-pick it from the list. If the tab is missing, Electron only sees one display. View → Move Windows to Their Screens re-applies the settings to windows that have been dragged elsewhere. If the window flickers towards its screen and snaps back, a tiling window manager is moving it; see Tiling window managers.
  • Presentations don't pop out: while someone is presenting, open DevTools (View → Toggle Developer Tools) and run __meetloafPresentationDebug(). It lists every tile with the labels the detector saw. If no tile shows presentation: true, Meet's wording has changed; the patterns are at the top of the pop-out section in app/main-inject.js. View → Pop Out Presentation opens the largest remote video by hand in the meantime.
  • Something stays switched on after I hang up: MeetLoaf should read Meet's post-hangup screen as post_call and fire leave. Check DevTools for [meetloaf] phase: post_call; if it says unknown, the Rejoin / Return to home screen labels changed.
  • Update checker never prompts: confirm repository.url in app/package.json points to a real GitHub repo (not OWNER/REPO).

License

MeetLoaf is released under the MIT License. Contributions are welcome; see CONTRIBUTING.md.

MeetLoaf is an independent project and is not affiliated with, endorsed by, or sponsored by Google. Google Meet is a trademark of Google LLC.

About

Electron wrapper for Google Meet to provide better window management and global keyboard shortcuts

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages