Skip to content

Repository files navigation

Google Messages for Omarchy

Read and reply to Google Messages from the Omarchy bar — an unread badge in the bar, and a panel with your conversation list, full thread history, inline images, voice messages, and a composer.

CI

The panel: conversation list, threaded history with an inline image, and the composer

Screenshot uses synthetic data.

Requirements

  • Omarchy with the Quickshell shell (omarchy-shell)
  • An Android phone running Google Messages, reachable on the network
  • A Chromium-family browser (Chrome, Chromium, or Brave) signed in to Google Messages — this is where pairing credentials come from
  • Go 1.27+ to build the daemon

Runtime tools, all of which a typical Omarchy install already has:

Tool Used for Without it
sqlite3 reading the browser cookie database pairing cannot read cookies
secret-tool (libsecret) the browser's cookie encryption key pairing cannot decrypt cookies
xdg-desktop-portal the "attach an image" file chooser the 📎 button does nothing
ffmpeg webcam capture and voice recording the 📷 and 🎤 buttons report a failure
ffplay (ships with ffmpeg) playing a recording back before sending the Play button does nothing
qrencode the legacy QR pairing fallback only the QR path is affected

Install

git clone https://github.com/MarcFord/gmessages-omarchy-plugin
cd gmessages-omarchy-plugin
make install
systemctl --user enable --now gmessagesd
omarchy plugin enable marcford.gmessages

make install puts the daemon in ~/.local/bin, the plugin in ~/.config/omarchy/plugins/marcford.gmessages/, and a user unit in ~/.config/systemd/user/.

To install as a plugin repo instead:

omarchy plugin add https://github.com/MarcFord/gmessages-omarchy-plugin.git

You still need the daemon built and running — the QML alone does nothing.

Uninstall

make uninstall

That disables and removes the systemd unit, deletes the daemon binary, and removes the plugin directory. It deliberately leaves your credentials and cache in place; remove those too with:

rm -rf ~/.local/share/gmessages-omarchy ~/.cache/gmessages-omarchy

Also revoke the device from your phone under Messages → ⋮ → Device pairing.

Nothing outside ~/.config/omarchy/plugins/marcford.gmessages/, ~/.local/bin/gmessagesd, and ~/.config/systemd/user/gmessagesd.service is written by the installer. Enabling the widget edits your bar layout, but only through omarchy plugin enable, which is Omarchy's own tooling.

Pairing

Click the bar icon and press Pair with Google. That is the whole flow — no terminal required.

The daemon finds a browser profile you are signed in to, reads the Google session cookies from it, and starts pairing. The panel then shows a single large emoji; Google Messages on your phone shows several, and you tap the one that matches. It expires after about a minute.

You must have opened https://messages.google.com/web in that browser at least once and let it load. The OSID cookie is issued by messages.google.com itself and is absent until you do — signing in to Google generally is not enough. If that is the problem the panel says so and names the profile it looked at.

Newer Google Messages builds have removed the QR scanner entirely, which is why the account flow is the default. Use a QR code remains available for older phones.

Credentials are written to ~/.local/share/gmessages-omarchy/session.json (mode 0600) and reused on every later start. A session is only written once pairing actually completes.

Pairing from a terminal

Equivalent, and useful when the panel cannot start:

gmessagesd pair --from-browser     # read cookies automatically
gmessagesd pair                    # paste a "Copy as cURL" from devtools

To unpair: press Unpair in the panel, delete the session file, or revoke the device from your phone.

Configuration

Settings live in the bar widget entry in ~/.config/omarchy/shell.json, and are editable from Setup → Plugins:

Key Default Meaning
autostart true Try to start the systemd unit if unreachable
serviceName gmessagesd.service Unit to start when autostart is on

Files

Path Contents
~/.local/share/gmessages-omarchy/session.json Pairing credentials — secret
~/.local/share/gmessages-omarchy/config.json Preferences: chosen browser profile, GIPHY API key — secret
~/.cache/gmessages-omarchy/media/ Downloaded attachments
~/.cache/gmessages-omarchy/webcam-*.jpg Photos taken with the webcam
~/.cache/gmessages-omarchy/voice-*.m4a Voice messages you recorded
$XDG_RUNTIME_DIR/gmessages-omarchy/daemon.sock Plugin ↔ daemon socket

Using it

Click the bar icon to open the panel. Pick a conversation on the left, read the thread on the right, type in the composer and press Enter (or Send).

Message text can be selected with the mouse and copied with Ctrl+C; Ctrl+A selects the whole message. Right-clicking a bubble copies the entire message without selecting anything. Either way a brief Copied confirmation appears.

Your own messages carry a delivery receipt underneath: Sending…, Sent, Delivered, Read, or Failed. Read is highlighted, the rest are muted. Read receipts come back over RCS; an SMS or MMS usually stops at Sent, so the absence of Read is not evidence a message went unread.

A thread opens at its newest message and stays pinned there as messages arrive. Scrolling up releases the pin so nothing yanks you back mid-read; a ↓ button appears to return to the bottom.

Three buttons sit left of the message box:

Button What it does
📎 Pick an image from disk, via your desktop's own file chooser
📷 Open the webcam with a live preview and a shutter button
GIF Search GIPHY and send a GIF
🙂 Emoji picker — search by name, inserts at the cursor

Each message carries a faint 🙂 button at its corner; click it and pick a reaction. Tapping the same emoji again takes it back, a different one switches; your own reaction is outlined. Only the seven emoji Google Messages supports are offered — anything else is sent as a custom emoji that not every recipient can render.

GIFs

The GIF button searches GIPHY. An empty search shows what is trending. Picking one downloads it and stages it like any other attachment, so you still see it and can add a caption before sending.

You need your own free GIPHY API key. The picker prompts for it the first time and explains what to do rather than failing:

  1. Go to developers.giphy.com and sign in.
  2. Create an App, and choose API (not SDK).
  3. Give it a name — gmessages-omarchy is fine — and accept the terms.
  4. Copy the API Key shown.
  5. Click GIF in the panel and paste it in.

The key you get is a beta key: free, issued instantly, no review, and rate limited to a level that is ample for personal use. A production key requires GIPHY to review the app and only matters at real volume.

It is stored in ~/.local/share/gmessages-omarchy/config.json (mode 0600), in plaintext, alongside your pairing credentials. To change or remove it, edit that file — deleting the giphyApiKey line returns the picker to its prompt.

Why the key is not bundled

Every user needs their own, and no key ships with this plugin:

  • A key committed to a public repository is a leaked key; scrapers find them within hours.
  • Rate limits are per key, so one shared key would be drained by everyone at once.
  • The key belongs to whoever registered it, and any user's abuse gets that account's key revoked, breaking GIFs for all of them.
  • GIPHY's terms expect per-app registration.

The smallest rendition that still looks right is sent, since carriers reject large files, and anything over 8 MB is refused. Downloads are restricted to https URLs on giphy.com.

Attachments are staged before they go anywhere: you see the image, can add a caption, and nothing is sent until you press Send image.

The file chooser runs through xdg-desktop-portal, so it is the same dialog the rest of your desktop uses and works correctly under Wayland.

The webcam shoots via a separate ffmpeg process after a 3-second countdown.

Voice messages

The 🎤 button records from your default PulseAudio/PipeWire source and stages the result like any other attachment: nothing is sent until you press Send voice message, and Play lets you hear it first — worth doing, since there is no level meter while recording. Re-record starts over, Cancel deletes the file.

Recordings are mono AAC in an MP4 container (.m4a), which is the format Google Messages expects for a voice note; mautrix's own bridge converts to the same thing before uploading. They are capped at five minutes, and a cancelled or re-recorded take is deleted from the cache rather than left behind.

Recording stops by asking ffmpeg to quit rather than by killing it. An MP4 writes its index last, so a killed process leaves a file that will not play.

Recordings are normalised before encoding (highpass + speechnorm), because speech captured at a comfortable desk level is usually well below what a phone plays back at, and the result reads as "you sound far away" at the other end. Turn that off with the Normalise voice recording level setting if you would rather send the raw level.

Choosing the input

default follows whatever your system default source is. To pin a specific microphone, list the inputs:

pactl list short sources | grep -v monitor

and put the source name in the PulseAudio/PipeWire source for voice messages setting, either through the widget's settings in Omarchy or with:

omarchy-shell shell setBarWidget marcford.gmessages audioDevice \
  '"alsa_input.usb-Your_Mic-00.analog-stereo"' '{}'

To check which source a recording actually used, start one and run pactl list source-outputs | grep -A1 "application.name" while it runs — the Source: number maps to the pactl list short sources output. Because there is no live preview (deliberately — see below), the shot is shown back at a larger size with three choices: Retake, Cancel, or Send image, plus an optional caption. Rejected captures are deleted rather than left in the cache.

Set cameraDevice if your webcam is not /dev/video0.

The emoji picker reads Omarchy's own catalogue, so the set and its search keywords match the rest of the desktop.

Why there is no live camera preview

The first version used QtMultimedia's Camera and VideoOutput, which do work in a standalone Quickshell instance. Inside the real Omarchy shell they segfault:

Signal: Segmentation fault (11)
#1  libffmpegmediaplugin.so

The Omarchy shell is one process that also owns the bar, notifications, and the lock screen, so a crash in a media backend takes the desktop with it — the same reason this plugin cannot embed a web view. Capture therefore runs as a child ffmpeg process, where a crash can only kill the child.

The first frames are discarded before the shot, because webcams need a moment to auto-expose and frame zero is usually black.

Staying paired

Two credentials keep a session alive, and only one looks after itself:

  • The auth token refreshes on its own. libgm signs a refresh with the stored ECDSA key about an hour before expiry, needing no cookies. This is not what goes stale.
  • The Google cookies do not. Every request is authenticated with a SAPISIDHASH derived from them, and Google rotates __Secure-1PSIDTS continuously as you use the browser. A snapshot taken at pairing time drifts from the browser's copy until Google rejects the session with SESSION_COOKIE_INVALID — which retrying cannot fix, because it needs a new pairing.

libgm keeps the cookies current by applying the Set-Cookie headers Google returns on every request, and the daemon persists the session every 10 minutes so that survives a restart.

The daemon deliberately does not copy cookies from your browser on a timer. That was tried and it made things worse: __Secure-1PSIDTS is a rotating token, and the browser and the daemon each rotate their own copy. Overwriting the daemon's freshly-rotated cookie with the browser's hands Google a stale token, which it answers with SESSION_COOKIE_INVALID — killing the session the sync was meant to preserve.

Reading the browser's cookies is reactive only: on an actual authentication failure, when our copy is known bad and the browser is the only other source. If the session is already gone the daemon re-pairs itself, which is usually silent once your account trusts the device.

The practical consequence: stay signed in to Google in the browser profile you paired from. If you sign out there, the daemon loses its source of fresh cookies and you will eventually have to pair again.

Choosing a browser profile

By default the daemon picks the most recently used profile that has a complete cookie set. That is a guess, and wrong as soon as you keep several Google accounts in separate profiles.

The pairing screen lists every profile it can see, with whether each is usable and why not:

✓  Chrome / Profile 1     7 cookies — ready
•  Chrome / Default       Signed in to Google, but not to Messages.
•  Chromium / Default     no Google cookies could be read

Press Change to pin one, or Choose automatically to go back to the default behaviour. The choice is stored in ~/.local/share/gmessages-omarchy/config.json and is honoured by the background cookie sync too, so it keeps working with no panel open.

Resource limits

Everything the daemon accepts from the network is bounded, because none of it is under your control — an attachment's size is whatever the far side decides to send.

Limit Value Why
Outgoing upload 25 MB Rejected before upload; carriers reject larger anyway
Incoming attachment 32 MB Read through a bounded reader, so an oversized or unbounded response is refused rather than allocated
Attachment cache 256 MB Trimmed oldest-first after each write
Webcam photos and recordings 64 MB Same trim; a sent one is deleted immediately
Avatar 4 MB Fetched only over https, and only from a public address
Voice recording 5 minutes ffmpeg stops itself at the cap

The incoming limit is enforced on the response body as it is read, not from the declared Content-Length: a response can declare a small size and send far more, or declare nothing at all. The size a message claims an attachment is gets used only as an early short-circuit, to avoid opening a connection for something already known to be too big.

A group's avatar URL arrives in conversation data, which means whoever controls the group controls the URL the daemon is asked to fetch. That request is refused unless it is https, and the connection is checked at dial time against the address actually resolved — loopback, private, link-local (cloud metadata) and carrier-grade NAT ranges are all refused, so the daemon cannot be turned into a request generator pointed at your own network.

This means the plugin does not call libgm's DownloadMedia or DownloadAvatar, which reads the whole body with io.ReadAll and whose HTTP client is not reachable from outside that package. internal/daemon/download.go issues the same request using libgm's own exported helpers and decryption, and differs only in reading through an io.LimitReader.

Security notes

Everything below is either enforced in code or a deliberate, stated decision.

Nothing from the network sizes an allocation. Attachment downloads, inline media, avatars and GIF fetches all read through a bounded reader. A declared size is only ever used to refuse early; it is never trusted as the actual bound.

URLs that arrive in data are not followed blindly. A group avatar URL comes from conversation data, so it must be https and must resolve to a public address — checked at dial time, against the address actually connected to, so a hostname resolving to loopback does not slip through. GIF URLs, which come from a search response, must be https on giphy.com or a subdomain; both the preview the panel loads and the URL the daemon fetches are checked.

Child processes are never handed a shell. Every one is spawned with an argument array. The SQL used to read the browser cookie database is a constant.

Secrets stay out of logs and errors. An error names which cookie is missing, never its value. Session and config files are 0600, their directories 0700, and the control socket is 0600, so only your own account can talk to the daemon.

In-memory state is bounded. Attachment secrets and reaction records are capped and evicted oldest-first, rather than keeping every message the daemon has ever seen — along with its decryption keys — for the life of the process.

Accepted, and why

  • The daemon will read and upload any path the panel hands it. That is the file picker working as intended, and reaching the socket already requires your account.
  • Widget settings become process arguments (the audio and camera device names, the systemd unit to start). Changing them requires write access to your own config, which is a larger problem than this plugin.
  • A copy of the browser cookie database is made in /tmp to read it while the browser holds a lock. It is 0600, deleted immediately, and the unit sets PrivateTmp=yes, so it is not visible to the rest of the system.
  • libgm is a reverse-engineered client. Google can change or break the protocol without notice.

Deliberate limits

Why it works this way

The obvious design — embed messages.google.com/web in a web view inside the bar — is not possible. Omarchy's shell is a single long-running Quickshell process that also owns the bar, notifications, the OSD, and the lock screen. Quickshell never calls QtWebEngineQuick::initialize(), so creating a WebEngineView inside it aborts the whole process:

FATAL: Argument list is empty, the program name is not passed to
QCoreApplication. base::CommandLine cannot be properly initialized.

That is not a recoverable widget error; it takes the desktop shell down with it. So this plugin does not embed a browser. Instead:

┌──────────────────────┐   NDJSON over    ┌────────────────────────┐
│  Quickshell plugin   │◄──unix socket───►│      gmessagesd        │
│  (bar widget + panel)│                  │  Go daemon, libgm      │
└──────────────────────┘                  └───────────┬────────────┘
                                                      │ Google Messages
                                                      │ web protocol
                                                ┌─────▼─────┐
                                                │ Your phone │
                                                └───────────┘

gmessagesd speaks the real Google Messages web protocol using libgm from the mautrix project, and exposes a small JSON API. The QML side is pure UI — it holds one socket and renders what the daemon pushes.

  • No desktop notifications. Your phone and any other paired client already notify you; a third source is noise. The bar badge is the signal.
  • Images only for outgoing attachments. Sending pictures (from disk or the webcam) works; video and audio are not wired up yet. Incoming media of any type still downloads.
  • Inbox only, 50 conversations. This is a bar popup, not an archive browser.

Caveats worth knowing

  • libgm is a reverse-engineered client. Google can break it without notice; when they do, this plugin stops working until libgm is updated.
  • Pairing consumes one of your limited Messages for web device slots.
  • RCS and end-to-end encrypted chats are relayed through your phone, which must stay online. When the phone is unreachable the panel says so.
  • Messages are decrypted on this machine to be displayed. Attachments sit in the cache directory until you clear it.

Development

make build           # build bin/gmessagesd
make test            # go test ./...
make lint            # go vet + qmllint
./bin/gmessagesd --log-level debug --socket /tmp/gm.sock

CI runs gofmt, go vet, a build, and go test -race on every push, plus a QML syntax check. It cannot do more than that: the interesting behaviour needs a running Quickshell, a paired phone, and a signed-in browser, none of which exist on a runner. Treat a green tick as "it compiles and the pure logic holds", not as "it works".

The wire protocol is defined in internal/wire/wire.go. You can drive the daemon by hand:

printf '{"id":"1","method":"status"}\n' | socat - UNIX-CONNECT:/tmp/gm.sock

QML changes do not hot-reload. Omarchy launches Quickshell with QS_DISABLE_FILE_WATCHER=1, so after editing any .qml file you need:

make install-plugin && omarchy-restart-shell

omarchy plugin disable/enable and omarchy-shell shell rescanPlugins look like they work — the log even says Local plugin changed, reloading — but they only re-instantiate objects from QML already compiled in memory, so your edit is not loaded. If a change appears to do nothing, that is why.

CONTRIBUTING.md covers this and three other things that will otherwise waste an evening.

Contributing

Bug reports and patches are both welcome, and so is telling me something is wrong without a fix attached.

Credits

Protocol work is entirely the mautrix project's. This repo is a desktop client and an Omarchy plugin around it.

License

MIT — see LICENSE.

About

Read and reply to Google Messages from the Omarchy status bar. Go daemon + Quickshell plugin.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages