Skip to content

Commit 175e63b

Browse files
acrogenesisryanrhughescodex
authored
Sync Slack's native theme with Omarchy (#17113)
* Sync Slack Desktop with Omarchy themes live * Fix Slack header contrast across themes * Fix Slack install and uninstall lifecycle and cover live updates * Theme packaged Slack over inherited pipes * Remove Slack-specific theming documentation * Use native Slack theme colors * Read Slack theme after connecting the watcher * Trim redundant Slack theme tests * Normalize RGBA colors for Slack * Stop Slack's theme lookup from scanning on every DOM change When Slack ships a build without the actions theme.js looks for, the observer stayed connected and walked all ~83,000 module exports on every DOM change, about 22 ms each time, for the life of the window. Measured on a signed-in Slack 4.52.182: 331 ms of main-thread time per 20 idle seconds, with nothing to stop it. Find the actions once and reuse them on theme changes, look at most once a second, and stop watching after a minute when they never turn up. * Bring Slack's restarts back through the theme launcher Slack's Clear Cache and Restart, Reset App Data and hardware acceleration toggle call app.relaunch(), which starts the original command line again without descriptors 3 and 4. Electron quits when --remote-debugging-pipe has no pipe behind it, so Slack closed about 10 seconds after the restart instead of coming back. Start Slack under omarchy-launch-slack's name so those restarts come back through it. The launcher drops the stale pipe flag and the theme launcher adds it again with a fresh pipe. Checked on Slack 4.52.182: the restarted Slack opens, has its pipe, and follows a live theme switch. Restarts that pass their own arguments (debug logs, net logs) still use Slack's binary path and still quit. Co-Authored-By: GPT-6.1 Sol <noreply@openai.com> * Follow Slack's page loads without streaming its console Runtime.enable sent every Slack console message through the pipe into the launcher: about 7 MB at startup, then a message a second. Page events mark each new document without that, and the first apply runs on attach. A message the launcher can't handle no longer stops it reading, which left Slack queueing everything after it. Checked on Slack 4.52.182: 98 KB in the first 40 s and nothing while idle, the theme comes back after a reload, and a live switch to light still applies. * Look for Slack's actions again when the page goes quiet The one-second guard skipped DOM changes without scheduling another look, so a Slack that finished loading inside that second and then went quiet kept its old colors. A skipped call now schedules one look for when the second is up. --------- Co-authored-by: Ryan Hughes <ryan@heyoodle.com> Co-authored-by: GPT-6.1 Sol <noreply@openai.com>
1 parent e2c7007 commit 175e63b

15 files changed

Lines changed: 561 additions & 2 deletions

‎bin/omarchy-install-service-slack‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
3+
# omarchy:summary=Install Slack Desktop with live Omarchy theme support
4+
# omarchy:requires-sudo=true
5+
6+
set -euo pipefail
7+
8+
omarchy-pkg-add slack-desktop
9+
if omarchy-setup-slack; then
10+
setsid uwsm-app -- gtk-launch slack >/dev/null 2>&1 &
11+
else
12+
echo "Slack theme setup failed; opening packaged Slack." >&2
13+
setsid uwsm-app -- gio launch /usr/share/applications/slack.desktop >/dev/null 2>&1 &
14+
fi

‎bin/omarchy-launch-slack‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
3+
# omarchy:summary=Launch Slack with live Omarchy theme support
4+
# omarchy:args=[slack-arguments...]
5+
# omarchy:hidden=true
6+
7+
# Slack's own restarts come back through here with the pipe flag the
8+
# launcher added; the launcher adds it again with a pipe behind it.
9+
arguments=()
10+
for argument in "$@"; do
11+
[[ $argument == "--remote-debugging-pipe" ]] || arguments+=("$argument")
12+
done
13+
14+
if [[ -f $HOME/.local/state/omarchy/slack/enabled ]]; then
15+
exec /usr/bin/python "$OMARCHY_PATH/default/slack/launcher.py" "${arguments[@]}"
16+
else
17+
exec /usr/bin/slack "${arguments[@]}"
18+
fi

‎bin/omarchy-remove-launcher-entry‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec)[[:space:]].*-e
7777
exit 0
7878
fi
7979

80+
if [[ $desktop_file_name == "slack.desktop" && $exec_line =~ ^omarchy-launch-slack($|[[:space:]]) ]]; then
81+
exec omarchy-launch-floating-terminal-with-presentation omarchy-remove-service-slack
82+
fi
83+
8084
if is_user_desktop_file; then
8185
display_name="${entry_name:-$desktop_name}"
8286
rm -f "$desktop_file"

‎bin/omarchy-remove-service-slack‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/bash
2+
3+
# omarchy:summary=Uninstall Slack Desktop and clean up its theme integration
4+
# omarchy:hidden=true
5+
# omarchy:requires-sudo=true
6+
7+
set -euo pipefail
8+
9+
omarchy-pkg-drop slack-desktop
10+
override="$HOME/.local/share/applications/slack.desktop"
11+
if [[ -f $override ]] && grep -qE '^Exec=omarchy-launch-slack( |$)' "$override"; then
12+
rm "$override"
13+
update-desktop-database "${override%/*}"
14+
fi
15+
rm -rf "$HOME/.local/state/omarchy/slack"

‎bin/omarchy-setup-slack‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/bin/bash
2+
3+
# omarchy:summary=Make Slack follow Omarchy themes live
4+
5+
set -euo pipefail
6+
7+
if (( EUID == 0 )); then
8+
echo "Run this command as your desktop user, without sudo." >&2
9+
exit 1
10+
fi
11+
12+
[[ -f /usr/bin/slack ]] || { echo "Install Slack Desktop first." >&2; exit 1; }
13+
14+
if [[ ! -f $HOME/.local/state/omarchy/current/theme/slack.json ]]; then
15+
omarchy-theme-refresh
16+
fi
17+
18+
desktop="/usr/share/applications/slack.desktop"
19+
override="$HOME/.local/share/applications/slack.desktop"
20+
state="$HOME/.local/state/omarchy/slack"
21+
if ! grep -qE '^Exec=(/usr/bin/)?slack( |$)' "$desktop"; then
22+
echo "Slack's desktop launcher has changed; refusing to replace it." >&2
23+
exit 1
24+
fi
25+
26+
mkdir -p "${override%/*}" "$state"
27+
if [[ -f $override ]] && ! grep -qE '^Exec=omarchy-launch-slack( |$)' "$override"; then
28+
if [[ -e $override.before-omarchy ]]; then
29+
echo "An earlier launcher backup already exists: $override.before-omarchy" >&2
30+
exit 1
31+
fi
32+
cp -a "$override" "$override.before-omarchy"
33+
fi
34+
pending=$(mktemp "$override.XXXXXX")
35+
trap 'rm -f "$pending"' EXIT
36+
sed -E 's#^Exec=(/usr/bin/)?slack( |$)#Exec=omarchy-launch-slack\2#' "$desktop" >"$pending"
37+
mv "$pending" "$override"
38+
touch "$state/enabled"
39+
update-desktop-database "${override%/*}"
40+
omarchy-theme-set-slack
41+
42+
echo "Slack theme sync enabled. Fully quit Slack and reopen it from the launcher once."
43+
echo "Future Omarchy theme changes will recolor open Slack windows automatically."

‎bin/omarchy-theme-set‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ other_theme_commands=(
542542
omarchy-theme-set-claude
543543
omarchy-theme-set-hermes
544544
omarchy-theme-set-t3code
545+
omarchy-theme-set-slack
545546
omarchy-theme-set-browser
546547
omarchy-theme-set-vscode
547548
omarchy-theme-set-obsidian

‎bin/omarchy-theme-set-slack‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash
2+
3+
# omarchy:summary=Sync the generated Omarchy theme to running Slack windows
4+
# omarchy:hidden=true
5+
6+
set -euo pipefail
7+
8+
state="$HOME/.local/state/omarchy/slack"
9+
theme="$HOME/.local/state/omarchy/current/theme/slack.json"
10+
[[ -f $state/enabled && -f $theme ]] || exit 0
11+
12+
pending=$(mktemp "$state/theme.XXXXXX")
13+
trap 'rm -f "$pending"' EXIT
14+
if jq -e 'select(.mode == "light" or .mode == "dark") |
15+
select([.background, .accent, .green, .red] | all(type == "string" and test("^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$"))) |
16+
{mode, background, accent, green, red} |
17+
with_entries(if .key == "mode" then . else .value |= .[0:7] end)' "$theme" >"$pending"; then
18+
mv "$pending" "$state/theme.json"
19+
else
20+
echo "Skipping invalid Slack theme." >&2
21+
fi

‎default/omarchy/omarchy-menu.jsonc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@
299299
"install.service.dropbox": {"icon":"","label":"Dropbox","when":"[[ $(getconf PAGESIZE) == 4096 ]]","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"},
300300
"install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify || [[ -f $HOME/.local/share/applications/Spotify.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"},
301301
"install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"},
302-
"install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"},
302+
"install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-slack"},
303303
"install.service.tailscale": {"icon":"","iconFont":"omarchy","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"},
304304
"install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"},
305305
"install.service.once": {"icon":"󰏖","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"},

‎default/slack/launcher.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Apply native theme preferences to packaged Slack over inherited pipes."""
2+
3+
import json
4+
import os
5+
from pathlib import Path
6+
import signal
7+
import sys
8+
9+
from gi.repository import Gio, GLib
10+
11+
12+
class Slack:
13+
def __init__(self, directory):
14+
self.directory = Path(directory)
15+
self.theme_file = self.directory / 'theme.json'
16+
self.theme = ''
17+
self.script = Path(__file__).with_name('theme.js').read_text().strip()
18+
self.sessions = set()
19+
self.callbacks = {}
20+
self.next_id = 0
21+
self.buffer = b''
22+
self.outbox = []
23+
self.loop = GLib.MainLoop()
24+
25+
def start(self, arguments, binary='/usr/bin/slack', name=None):
26+
# Chromium reads descriptor 3 and writes descriptor 4. The inherited
27+
# pipes belong to this launch; no debugging socket or port is opened.
28+
outgoing, writer = os.pipe()
29+
reader, incoming = os.pipe()
30+
launcher = Gio.SubprocessLauncher.new(Gio.SubprocessFlags.NONE)
31+
launcher.take_fd(outgoing, 3)
32+
launcher.take_fd(incoming, 4)
33+
# Slack's restart actions relaunch the command line it started with,
34+
# and Electron quits when the pipe flag arrives without the pipe.
35+
# Starting Slack under the launcher's name brings those restarts back
36+
# through it, so the new Slack gets a pipe of its own.
37+
self.child = launcher.spawnv(['/bin/bash', '-c', 'exec -a "$0" "$@"', name or binary,
38+
binary, '--remote-debugging-pipe', *arguments])
39+
self.input = Gio.UnixInputStream.new(reader, True)
40+
self.output = Gio.UnixOutputStream.new(writer, True)
41+
self.input.read_bytes_async(65536, 0, None, self.received)
42+
self.child.wait_async(None, self.exited)
43+
self.monitor = Gio.File.new_for_path(str(self.directory)).monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, None)
44+
self.monitor.connect('changed', self.changed)
45+
self.theme = self.theme_file.read_text() if self.theme_file.exists() else ''
46+
self.send('Target.setAutoAttach', {'autoAttach': True, 'waitForDebuggerOnStart': False, 'flatten': True,
47+
'filter': [{'type': 'page'}, {'exclude': True}]})
48+
for number in (signal.SIGTERM, signal.SIGINT):
49+
GLib.unix_signal_add(0, number, self.stop, number)
50+
51+
def send(self, method, params=None, session=None, callback=None):
52+
self.next_id += 1
53+
message = {'id': self.next_id, 'method': method, 'params': params or {}}
54+
if session:
55+
message['sessionId'] = session
56+
if callback:
57+
self.callbacks[self.next_id] = callback
58+
self.outbox.append(json.dumps(message).encode() + b'\0')
59+
if len(self.outbox) == 1:
60+
self.write()
61+
62+
def write(self):
63+
self.output.write_all_async(self.outbox[0], 0, None, self.written)
64+
65+
def written(self, stream, result):
66+
try:
67+
stream.write_all_finish(result)
68+
self.outbox.pop(0)
69+
if self.outbox:
70+
self.write()
71+
except GLib.Error as error:
72+
print('Slack theme pipe:', error.message, file=sys.stderr)
73+
74+
def received(self, stream, result):
75+
try:
76+
data = stream.read_bytes_finish(result).get_data()
77+
if not data:
78+
return
79+
self.buffer += data
80+
while b'\0' in self.buffer:
81+
message, self.buffer = self.buffer.split(b'\0', 1)
82+
try:
83+
self.dispatch(json.loads(message))
84+
except (KeyError, ValueError) as error:
85+
# Stop reading and Slack queues every later message in memory.
86+
print('Slack theme pipe:', repr(error), file=sys.stderr)
87+
stream.read_bytes_async(65536, 0, None, self.received)
88+
except GLib.Error as error:
89+
print('Slack theme pipe:', error.message, file=sys.stderr)
90+
91+
def dispatch(self, message):
92+
callback = self.callbacks.pop(message.get('id'), None)
93+
if callback:
94+
callback(message)
95+
params, session = message.get('params', {}), message.get('sessionId')
96+
if message.get('method') == 'Target.attachedToTarget':
97+
session = params['sessionId']
98+
self.sessions.add(session)
99+
# Page events mark each new document of the window. Runtime.enable
100+
# would also stream all of Slack's console output, about 7 MB at
101+
# startup and a message a second after.
102+
self.send('Page.enable', session=session)
103+
self.apply(session)
104+
elif message.get('method') == 'Target.detachedFromTarget':
105+
self.sessions.discard(params['sessionId'])
106+
elif message.get('method') == 'Page.domContentEventFired':
107+
self.apply(session)
108+
109+
def apply(self, session):
110+
if self.theme and session in self.sessions:
111+
# Native preference actions are synchronous. Pipe ordering keeps
112+
# rapid changes in order, without stylesheet or navigation handles.
113+
self.send('Runtime.evaluate', {'expression': self.script + '(' + self.theme + ')'}, session)
114+
115+
def changed(self, monitor, file, other, event):
116+
if any(item and item.get_basename() == 'theme.json' for item in (file, other)):
117+
try:
118+
theme = self.theme_file.read_text()
119+
except OSError:
120+
return
121+
if theme != self.theme:
122+
self.theme = theme
123+
for session in self.sessions:
124+
self.apply(session)
125+
126+
def stop(self, number):
127+
self.child.send_signal(number)
128+
return GLib.SOURCE_REMOVE
129+
130+
def exited(self, child, result):
131+
child.wait_finish(result)
132+
self.monitor.cancel()
133+
self.loop.quit()
134+
135+
def run(self, arguments):
136+
self.start(arguments, name=os.environ['OMARCHY_PATH'] + '/bin/omarchy-launch-slack')
137+
self.loop.run()
138+
return self.child.get_exit_status() if self.child.get_if_exited() else 128 + self.child.get_term_sig()
139+
140+
141+
if __name__ == '__main__':
142+
directory = Path.home() / '.local/state/omarchy/slack'
143+
sys.exit(Slack(directory).run(sys.argv[1:]))

‎default/slack/theme.js‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
(colors => {
2+
const state = window.omarchySlackTheme ??= {};
3+
state.colors = colors;
4+
state.apply ??= () => {
5+
if (!state.runtime && window.rspackChunkGantryV2) {
6+
window.rspackChunkGantryV2.push([["omarchy-theme"], {}, runtime => { state.runtime = runtime; }]);
7+
}
8+
if (!state.runtime) return;
9+
if (!state.actions) {
10+
// A look walks every export of Slack's ~13,000 modules, about 20 ms, and
11+
// the observer calls this on each DOM change. Look at most once a second,
12+
// and give up after a minute on a Slack build whose actions have moved.
13+
const now = Date.now();
14+
if (state.lookedAt !== undefined && now - state.lookedAt < 1000) {
15+
// Slack can finish loading inside that second and then go quiet, so
16+
// look again once it's up instead of waiting for another DOM change.
17+
state.retry ??= setTimeout(() => { state.retry = undefined; state.apply(); }, 1000 - (now - state.lookedAt));
18+
return;
19+
}
20+
state.lookedAt = now;
21+
state.firstLook ??= now;
22+
const values = Object.values(state.runtime.c).flatMap(module => Object.values(module.exports || {}));
23+
const find = key => values.find(value => typeof value === "function" && (value.meta?.key === key || value.name === key));
24+
const actions = ["getStoreInstanceMap", "tryGetClientStoreInstance", "createActionSetUserPref", "createActionSetClientThemeInternal"].map(find);
25+
if (actions.includes(undefined)) {
26+
if (now - state.firstLook > 60000) state.observer?.disconnect();
27+
return;
28+
}
29+
state.actions = actions;
30+
}
31+
const [storeMap, clientStore, preference, mode] = state.actions;
32+
const stores = storeMap();
33+
const client = clientStore();
34+
if (!stores || !Object.keys(stores).length || !client) return;
35+
const { background, accent, green, red, mode: scheme } = state.colors;
36+
const color = hex => ({ palette: "gray", custom: hex });
37+
const theme = {
38+
primary: color(background), highlight1: color(accent),
39+
highlight2: color(green), important: color(red),
40+
brightness: scheme === "light" ? 1 : 6,
41+
sidebarInverted: false, useCustomHex: true
42+
};
43+
// These reducer actions stay local; the API setter would sync the account.
44+
for (const store of Object.values(stores)) {
45+
store.dispatch(preference({ pref: "ia_theme", value: theme }));
46+
store.dispatch(mode(scheme));
47+
}
48+
client.dispatch(mode(scheme));
49+
clearTimeout(state.retry);
50+
state.retry = undefined;
51+
state.observer?.disconnect();
52+
return true;
53+
};
54+
if (!state.apply()) {
55+
state.observer ??= new MutationObserver(state.apply);
56+
state.observer.observe(document, { childList: true, subtree: true });
57+
}
58+
})

0 commit comments

Comments
 (0)