50 KiB
How the original works (and what this rebuild copies)
Original: Grok Bot 0.78.1, an Electron app.
The native main program (decompiled)
Contents/MacOS/Grok Bot (Mach-O arm64, 53 040 bytes, sha256 df5b07de…). Opened with
core_target_open, and every function read with native_function_pseudocode:
| Address | Function | What it does |
|---|---|---|
0x100000460 |
entry |
Calls 0x1000004c0, then electron::fuses::IsRunAsNodeEnabled(). If the fuse is on and getenv("ELECTRON_RUN_AS_NODE") is a non-empty string, it runs ElectronInitializeICUandStartNode, otherwise ElectronMain. |
0x1000004c0 |
sub_1000004c0 |
For stdin/stdout/stderr (fd 0, 1, 2): if fstat fails with errno == 9 (EBADF), reopens that stream on /dev/null. |
0x1000005bc–0x100000604 |
import stubs | ElectronInitializeICUandStartNode, ElectronMain, getenv, IsRunAsNodeEnabled, freopen, __error, fstat. |
These are all 10 functions in the file (evidence ev_407aa9c3…). native_strings_find for
model|grok|provider|chat|bot|theme|api finds 0 strings (evidence ev_1b74b817…), so none of the
chat, model or settings behaviour is native code. It lives in the JavaScript inside
Contents/Resources/app.asar. The sources below therefore give file + byte offset (@N) + minified
function name, the JavaScript equivalent of a function address.
Pseudocode evidence: ev_7c151be5… (entry), ev_29ffc0d8… (sub_1000004c0).
Bundle map evidence: ev_2bb06b4aef290dafe0a74085416f36482d30fc7ecc476b97f343e324ceb9c9f7 (js_app_map).
Errors, retries and timeouts
From dist/electron-main/main-core.cjs.
| Rule | Value | Source |
|---|---|---|
| Retry attempts | 3 | @1082927 D3={name:"gateway-create-agent-retry",maxAttempts:3,…} |
| Retry delay | starts at 1000 ms, ×2 each time, at most 4000 ms → 1 s, 2 s, 4 s | same object: initialDelayMs:1e3,maxDelayMs:4e3,backoffFactor:2 |
| Connect deadline | 15 000 ms | @1082927 $D=15e3 → connectDeadline "gateway-sse-connect" (@1084759 L3) |
| Stream silence counts as a stall | 35 000 ms | @1082927 VD=35e3 → stallWatchdog "gateway-sse-stall", idleMs:VD (@1084759 L3) |
| Provider overloaded / rate limit | retry | @689060 SAND-E0401 providerOverloaded, retryable:!0 |
| Nothing streamed by the first-token deadline | retry | @689284 SAND-E0402 firstTokenStall, retryable:!0. The deadline itself is server-side: estimated 120 s |
| Transport reset | retry | @689501 SAND-E0403 streamReset, retryable:!0 |
| Context window overflow | no retry | @689722 SAND-E0404 contextWindowOverflow, retryable:!1 |
| Backend rejected | no retry | @689948 SAND-E0405 backendRejected, retryable:!1 |
| Server-paced retry-after | wait the given delay, then retry | @690623 SAND-E0408 backendCapacityDeferred (30 s cap: estimated) |
| Silence after output started | cancel; resumed only from a checkpoint | @691348 SAND-E0415 streamIdleStall, so the rebuild never auto-retries once text has appeared |
| Backend unreachable (refused / DNS) | retry | @692409 SAND-E0418 turnBackendUnreachable, retryable:!0 |
| Rate-limit banner | "Rate limited by model provider, retrying…" | main-app.cjs rate_limited_reconnect_message_config |
| Error texts | provider_overloaded → wbQwKx; first_token_stall → Ly7/5m "模型未能及時開始回應。請重試。" |
index.eager-app @1592250, map YN |
How the rebuild applies them: 429, 408, 5xx, 529 and connection failures retry up to 3 tries, waiting 1 s, then 2 s, then 4 s (or the server's Retry-After). The reply shows "…秒後重試(第 n 次,共 3 次)" meanwhile. A 400 that says the prompt is too long stops with a "對話太長了" message, and 401/403/404 stop at once. A reply that goes 35 s silent after it started is stopped, and the partial text is kept.
Models
| What | Value | Source |
|---|---|---|
| Default model | grok-4.5 |
main-core.cjs, var Wp="grok-4.5", exported as SAND_DEFAULT_MODEL_ID |
| Model choice shape | {modelId, maxMode, parameters:[{id,value}]} |
main-core.cjs, schema UEe / bh |
| Model list | fetched from the server, not stored in the app | model_picker_experiments in main-app.cjs names only grok-4.5 and grok-4.6 |
| Setting text | "模型:選擇 Grok Bot 用來思考的模型" | index.eager-app settings map b$.model → ids scu3wk / 2RPCms |
| Overload message | "模型供應商已超載" / "模型供應商目前負載過高。通常是暫時的。" | zh-TW catalog ids Rdu4z7, wbQwKx |
The original only talks to its own cloud service. The rebuild's change, as the person asked: every bot picks its own provider (xAI Grok, OpenAI, Claude, Gemini, OpenRouter, Ollama, or any OpenAI-compatible service) and model. Keys live in Settings → AI 供應商.
Message box
| Rule | Value | Source |
|---|---|---|
| Send | Enter sends; Shift+Enter adds a new line; Enter is ignored while an input method is composing | index.eager-app @2002346 mwt: e.key==="Enter"&&!e.shiftKey&&!e.nativeEvent.isComposing&&e.preventDefault() |
| Placeholder | "傳訊息給 {name}" (fallback "想問什麼都可以,或拖入檔案") | index.eager-app function E6t, ids pqr+oY / HOCPPm |
Sidebar
From dist/renderer/assets/index.eager-app-D1juOWZp.js.
| Rule | Value | Source |
|---|---|---|
| Default width | 280 px | @912025 FT={expandedWidth:280,isCollapsed:!1} |
| Width limits | 240–400 px | @914394 sKe: jT(UT(t.expandedWidth,280),240,400); @969523 QJe: Math.max(240,Math.min(400,…)) |
| Folded width | 88 px rail | @969523 fD=88 |
| Fold when dragged below | 210 px | @969523 JJe=210 |
| Unfold from the rail when dragged to | 220 px | @969523 YJe=220 |
| Saved keys | sand.sidebar.width, sand.sidebar.collapsed |
@912025 BT |
| Chat minimum width | 424 px | index CSS --sand-chat-min-width:424px (class sand-1fiktjy); window minimum = 240 + 424 |
| Sections | 主要 Bot, 已釘選的 Bot, Bot | zh-TW ids V8a0q9, vPVg0Q, ixkTse |
| Search | "搜尋 Bot"; empty result "沒有相符的 Bot" | ids YjO4Og, 12i8o8 |
Chat
| What | Value | Source |
|---|---|---|
| Bubble corner | 18 px | index CSS --sand-transcript-prose-bubble-radius:18px |
| Bubble text | 15 px on a 22 px line | --sand-transcript-prose-line-height:22px with --sand-font-size-lg:15px |
| One-line bubble height | 36 px (7 px above and below) | --sand-transcript-prose-one-line-height:36px, --sand-transcript-prose-block-padding:7px |
| UI text sizes | 11/12/13/14/15/17/22/26 px, base 14 px | --sand-font-size-xxs … --sand-font-size-xxxl |
| Transcript item kinds | user, thinking, assistant-text, tool-call, send-message | index.eager-app function zfn |
| Thinking label | "思考中" | id AUV+TY |
| Message actions | 複製, 重新傳送, 停止 | ids he3ygx, MyjAbr, NnvXp5 |
Bots
| What | Value | Source |
|---|---|---|
| Bot fields | 名稱, 描述, 頭像 (shape + colour) | ids RFtRvq, NDq8h1, fopc9x |
| Avatar shapes | 26: arch, bean, blob, capsule, cloud, clover, crystal, cylinder, dome, egg, flower, gem, heart, hex, house, leaf, pebble, pumpkin, shield, sparkle, square, squircle, star, tablet, teardrop, wedge | dist/electron-main/menubar-tray/assets/bot-*.png |
| Rebuild shapes | 12 of them, drawn as simple shapes (look estimated) | own drawing |
Look (light theme)
From dist/renderer/assets/index-Dh7EWA8t.css, block :root,.sand-1vbcnu7:
pane #fff, chrome #fcfcfc, sidebar #f5f5f5, text #1c1c1c, secondary #1c1c1cad,
tertiary #1c1c1c8f, hover #1c1c1c0f, active #1c1c1c14, border #1c1c1c1f,
user bubble #1c1c1c with white text, agent bubble #1c1c1c0f, focus #1e86fc, accent #1680f3.
Bot colours come from --sand-fill-*-bubble-user: red #c82532, orange #d2550a, yellow #c2730b,
green #0d9655, cyan #0d9381, blue #126fd4, purple #7442d1, magenta #c82779, brown #86592e.
Settings → 主題: 淺色 / 深色 / 跟隨系統 (ids 1njn7W, pvnfJD, Ea4oxV).
強調色: Bot 顏色 / 黑色 (ids p7v5PZ, THokF3, description eeSkqM).
Dark theme colours weren't found in the stylesheet or the scripts: estimated. Chat column 720 px: estimated. No transcript max-width was found; the 708, 640 and 960 px max-widths in the CSS belong to other views.
Computer tools and the work loop (taken over from the neighbouring deep investigation)
The deep investigation in investigation.cany (findings fnd_5–fnd_12) showed that the original's
agent loop runs on a remote "box". The desktop app only hosts a local tool runner
(dist/local-exec-daemon/main.cjs) and the permission layer. That session planned its own clean-room
harness (plan step 1 "doing", no code written yet). This version builds that plan here, in Rust, with
the model loop running locally against whichever provider the bot uses.
| Rule | Value | Source |
|---|---|---|
| Search deadline | 25 000 ms | local-exec-daemon @2983388 Mu.MAIN_TIMEOUT_MS=25e3 |
| Search hard cap | 10 000 lines | same, Mu.HARD_MAX_OUTPUT_LINES=1e4 |
| Lines handed to the model | 2 000 | @2966784 Jut=2e3 (client limit) |
| Largest file read | 8 388 608 bytes (8 MiB) | @2951190 gut |
| Long command | not killed: moved to the background, read later | @3010873 Mye (stop kind "background", reason TIMEOUT), @3016213 timeoutBehavior===BACKGROUND |
| Background read-back | read_terminal |
@2959283 readTerminalFile |
| Command output kept | start + end, middle counted | ShellFailure fields output_head, output_tail, elided_chars (@2186946); 6 000 + 6 000 chars estimated |
| Foreground wait before background | 30 s, model may ask up to 600 s | estimated (the original gets it per call from the server) |
| Permission order | refuse > hidden files > allowed > ask | fnd_8: shouldBlockShellCommand, isShellCommandFullyAllowlisted, cursorignore/repo-block globs, approval callbacks |
| Chained commands | split on &&, ` |
|
| "Always allow" | remembered per bot (e.g. "修改檔案", "npm install") | fnd_12 recordLocalToolStandingGrant |
| Never claim done without a result | in the bot's instructions | fnd_10 design lesson (paraphrased, not copied) |
| Summarize at | 70 % of the context | main-app.cjs client_speculative_summarization_config.tokenUsageThresholdPercentage:70 |
| Extra room mid-task | +5 % (summarize again at 75 % between steps) | same config, tolerancePercentage:5 (how it's applied is our reading) |
| Context size | 128K tokens by default, per bot | estimated |
| Token estimate | 4 Latin chars or 1 Chinese char ≈ 1 token | estimated |
| Steps per turn | 25 | estimated |
| Messages kept word for word | newest 6, cut only before a user message | estimated |
Native helpers behind the tools (decompiled)
cursor_proclist.node (Mach-O arm64 bundle, 125 536 bytes, sha256 d5df3fb7…), the original's
process scanner. Strings found with native_strings_find (evidence ev_3d585c56…), and functions read with
native_function_pseudocode:
| Address | Function | Rule taken |
|---|---|---|
0x6748 |
getDescendantPids(vector<int>, bool) |
Breadth-first over children: proc_listpids(6 = PROC_PPID_ONLY, pid, buf, 0x1000), de-duplicated in a hash set, and the loop stops once 0xfff pids are collected, at most 4096 processes (evidence ev_768ea7aa…) |
0x3d00 |
ProclistScanWorker::Execute |
Calls getDescendantPids(roots, true) (roots included), then getProcessInformation for each and drops any that fail (evidence ev_6aba01a8…) |
0x5234 |
getProcessInformation(int, ProcessInfo&) |
proc_pidinfo(pid, 3 = PIDTBSDINFO, 0x88) for the parent; name via proc_name, else ""; arguments via sysctl KERN_PROCARGS2 only when under 0x40000 bytes; agent tags read from the environment (…CONVERSATION_ID= 0x17 chars, …REQUEST_ID= 0x12 chars); proc_pidinfo(pid, 4 = PIDTASKINFO, 0x60): memoryMB = resident bytes >> 20, cpuTimeMs = (user + system) × timebase.numer / timebase.denom / 1 000 000 (magic-number divide 0x431bde82d7b634db >> 18) (evidence ev_257a505c…) |
0x1fdc, 0x6cc0 |
cursorProclistSystemMemory, getSystemMemoryInfo |
Reads kern.memorystatus_vm_pressure_level (not used here) |
How the rebuild applies it (app tools.rs descendants, tree_stats, kill_tree): every command is
tagged with ANYBOT_CONVERSATION_ID (the bot) and ANYBOT_REQUEST_ID (the tool call). These are our
own names for the original's vendor-prefixed tags. Pressing stop, or quitting, ends the command and
everything it started (up to 4096 processes, newest first), not just the shell. read_terminal reports
process count, CPU seconds and memory MB with the formulas above (via ps).
sand-op-launcher (universal Mach-O, 136 368 bytes, sha256 1337d620…). Strings in evidence
ev_7d29f77e…; _main read with native_function_pseudocode (evidence ev_8006d51c…):
- It verifies the 1Password CLI's code signature (
SecStaticCodeCheckValidityagainstidentifier "com.1password.op" … OU 2BUA8C4S2C), and re-checks the file beforeexecve("changed before launch"). - It accepts only exact argument shapes (
item move,item list,vault list/create,account list,service-account create,--version); anything else is refused ("unsupported invocation"). - It applies the sandbox profile
(version 1)(allow default)(deny file-read* (with no-report) (subpath (param "ONEPASSWORD_GROUP_CONTAINER"))). - It builds a fresh environment (
OP_CACHE=false,OP_BIOMETRIC_UNLOCK_ENABLED=true,OP_LOAD_DESKTOP_APP_SETTINGS=false).
How the rebuild applies it (app tools.rs build_command): on macOS every command runs under
sandbox-exec with the same profile pattern, extended to deny reading and writing the app's own data
folder (where API keys are saved), ~/.ssh, ~/.gnupg, ~/Library/Keychains, ~/.aws and
~/.config/gcloud. Paths are resolved first, because the sandbox matches real paths. The provider API-key
variables are removed from the command's environment, and the app's data folder is also on the
never-read list for file tools.
Read-only commands that run without asking: ls, pwd, cat, head, tail, wc, echo, rg, grep, find, file, stat, du, df, date, which, tree, sort, uniq, diff, git status/diff/log/show (own list). Always refused: sudo, su, rm -rf /, rm -rf ~, mkfs, dd, shutdown, reboot, diskutil erase. Never read or written: .git internals, .ssh, .gnupg, Keychains, .env files, *.pem, *.key, id_rsa/id_ed25519, .netrc.
Turns, permission hand-over, computers and app control
What counts as one turn
| Rule | Value | Source |
|---|---|---|
| A turn starts with | a person's message, or a "wake" (a routine or other trigger firing) | main-app.cjs @3100283 w3n/P3n: turn counter n+=1 only for kind human or wake (SimulatedMsgReason.SUBSCRIPTION → wake) |
| A message sent while it works | steers the running turn and is not a new turn | same, P3n: e.turnSteer===!0 ? null (steer messages don't count) |
| Turn outcomes | success, cancelled, error | node-agent-coordinator wl=["success","cancelled","error"], read back by k6 as {turnId, outcome, settledAtMs} |
| Bot is idle only when | not running, no helper tasks running, not waiting for the person, no computer hand-off pending | node-agent-coordinator ZF: !isRunning && hasRunningSubagents!==true && awaiting===undefined && boxHandoff===undefined |
Rebuild (app agent.rs, app.rs): a message typed during a turn goes into the turn's steer queue and reaches the model at its next step. If the model has just finished, the turn keeps going. Anything that arrives after the turn has settled starts the next turn. Each turn records {turn id, outcome, settled time}: stop is cancelled, a failure or stall is error, otherwise success.
Computers
| Rule | Value | Source |
|---|---|---|
| Default place to work | the bot's own computer ("Bot 的電腦", yGkA3X) | settings map b$.computers / y$.botComputer |
| This Mac | only after the person turns on "允許 Grok Bot 在這台電腦上執行任務,而不只是在它自己的電腦上" | setting local-execution, ids fuUvu2 / Hd1KB+ |
| Approvals name the machine | "允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?", "請在 {targetMachineDisplay} 上回應" | ids eQViQE / ykDLyL |
| Taking over the bot's computer | "接管" (take over), then hand back | id yqwjpd; renderer handBack/handBackForeverBox @2601598 (not rebuilt; see below) |
Rebuild: "Bot 的電腦" is a sealed folder per bot (~/AnyBot 工作區/bot-N). Its commands run under
the macOS sandbox with nothing else in the home folder readable or writable, like a separate machine, so
it works there without asking. "你的電腦" is this Mac with the layered permission rules, and only after the
setting is on. Other computers are added in Settings → 電腦 and reached over SSH (read-only commands run
directly, others ask). Every tool has a computer choice, so "在我的 Mac 上…" picks this Mac.
Permission hand-over
| Rule | Value | Source |
|---|---|---|
| Rule choices | 先詢問 / 每次詢問 / 一律允許 / 永不允許 | ids 4YjKLA, 6CTZeX, NoKBgy, wvd4WD |
| Auto-review | "依照自訂規則審核 Bot 的操作", two lists: 「無需詢問直接允許」(continue automatically) and 「一律詢問」(stop and ask) | ids 9yhl+F, geCUKn/JOecNC, 1YRRmk/Pca/iV |
| Conflicts | "規則衝突時,「先詢問」優先"; built-in safety checks always apply | ids NFRGrQ, n9YiIv |
| Standing grants | "always allow" remembered (machine-scoped in the original) | fnd_12 recordLocalToolStandingGrant |
Rebuild: built-in refusals run first. For an action that would ask, auto-review (when on) asks the model to match it against the two rule lists, and an "always ask" match wins. "Allow" continues without a card, and the result notes which rule matched. Otherwise the approval card names the computer.
Asking for permission when it's needed (pop-up)
The original asks on the spot and names the machine ("允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?", eQViQE; "請在 {targetMachineDisplay} 上回應", ykDLyL). The rebuild never lets a step fail quietly for lack of permission:
- Computer tools are always offered to the model. If they're off for this bot, the step asks "允許 {bot} 使用電腦工具嗎?": 允許這一次 / 一律允許這個 Bot 使用電腦 (turns the bot's tools on) / 拒絕.
- If the step targets 你的電腦 while 在這台電腦上執行 is off, it asks "允許 {bot} 在你的電腦上執行任務嗎?" (wording from Hd1KB+): 允許這一次 / 一律允許 (turns the setting on) / 拒絕.
- Every request, including single actions, shows as a centred pop-up over a dimmed window, even when
another bot's chat is open. The Dock icon bounces (
RequestUserAttention) when AnyBot isn't in front. "稍後再決定" hides the pop-up; the chat card, the header pill "● 需要你允許" (click to reopen) and the sidebar's highlighted "需要你允許" stay until answered. - The bot is told to just call the tool and let the app ask, rather than telling the person to change
settings. Tested by
missing_permission_pops_up_and_continues.
Notifications when you're away (decompiled)
sand_notification_settings.node (Mach-O arm64, 102 368 bytes, sha256 6b2b9fa3…). Strings in evidence
ev_a3c3cb3d…, functions read with native_function_pseudocode:
| Address | Function | Rule taken |
|---|---|---|
0x35b4 |
getNotificationSettings(NotificationSettingsInfo&) |
Only on macOS ≥ 10.14 (__isPlatformVersionAtLeast(1,10,14,0)) and only inside a real .app bundle (bundlePath must end in ".app"). Asks UNUserNotificationCenter and waits on a semaphore for at most 3 s (dispatch_time(0, 0xb2d05e00) ns). authorizationStatus 0..4 → notDetermined/denied/authorized/provisional/ephemeral, otherwise "unknown"; alertSetting 0..2 → notSupported/disabled/enabled, otherwise "unknown" (evidence ev_7feb06d3…) |
0x3898 |
its completion block | Stores authorizationStatus and alertSetting, then signals the semaphore (evidence ev_32393aac…) |
0x2de8 |
NotificationSettingsWorker::OnOK |
Resolves {platform:"darwin", setting, alertSetting} (evidence ev_ec969729…) |
From main-app.cjs:
| Rule | Value | Source |
|---|---|---|
| Permission mapping | authorized/provisional/ephemeral → granted, denied → denied, notDetermined → undetermined | @3183391 hBt, mur |
| Banners allowed | only when authorized and alerts enabled | fBt |
| You count as present | signed in, screen not locked, not idle, window alive | K4 |
| Idle after | 90 s without input | Z_e=9e4 (10 min cBt=10*6e4 while a phone push is being held) |
| Presence re-checked every | 10 s | uBt=1e4 |
| Setting text | "Bot 完成任務或等你處理時通知你" | id ttdOeI |
Rebuild (notify.rs): presence = window in front and input within 90 s (idle read from macOS
HIDIdleTime every 10 s). When you're not present and a bot needs permission, or finishes a turn, a
macOS banner appears with a sound, on top of the pop-up and the Dock bounce (Settings → 一般 → 通知,
on by default). A build-folder app isn't a .app bundle, so, as the original's module shows, it can't
read its own notification permission. Banners go through macOS's display notification instead, which
may need allowing once under System Settings → Notifications. The 3 s settings read and the permission
mapping aren't used for that reason.
直接操作 (work like Grok Bot without a stream of questions)
Why the original rarely asks: it does most work on its own cloud computer and browser ("Bot 的電腦"),
so nothing on the Mac needs permission. Working on the Mac is one switch (local-execution,
Hd1KB+), after which auto-review checks actions instead of the person ("讓 Grok Bot 在你的電腦上開啟
檔案和執行任務。自動審核仍會先檢查所有操作", ENHi8c; hrkSSQ). Per-app grants are remembered
(recordLocalToolStandingGrant, listComputerUseAllowedApps, fnd_12).
Rebuild: Settings → 電腦 → ⚡ 直接操作, or the "⚡ 全部允許,之後直接操作" link on any request.
- One switch turns on 在這台電腦上執行, every bot's computer tools and auto-review. Running turns stop asking at once, and other bots' waiting "may I use the computer" requests are answered yes.
- Actions that would ask go ahead with the note "直接操作模式:已自動允許". The only exceptions are actions that match an "always ask" rule (checked by the model, as in auto-review). The default rules are own wording: 刪除檔案或資料夾 / 付款、購買或轉帳 / 用我的帳號發文、留言,或傳訊息給別人.
- Still blocked: the built-in refusals (sudo, mkfs, rm -rf /…), secret files and AnyBot's own data (sandbox), as in the original ("內建的安全檢查一律有效", n9YiIv).
- macOS's own one-time prompt to let AnyBot control a browser can't be skipped by any app.
- Tested by
direct_mode_asks_nothing_but_still_blocks_danger.
Admin locks (decompiled). sand_notification_settings.node managedPolicyValueSync at 0x1870
(evidence ev_86419910…) takes (productName, valueName), both of which must be strings (napi type 4),
otherwise it throws "managed_policy_value_sync(productName, valueName)". On macOS it then returns null:
it reads nothing on a Mac. The app side (main-core.cjs @727949, EZ/TZ/Ak/mZ/gZ) reads policy
from macOS user defaults (systemPreferences.getUserDefault(key, "string"), which MDM profiles can
force), from ~/.cursor/policy.json on Linux, and from the native call on Windows. Missing or blank
values mean unmanaged, a valid value means pinned, and anything else is invalid (its URLs must be
plain https origins).
Rebuild (policy.rs): the same three kinds for two switches, read from macOS defaults domain
com.anybot.AnyBot (AllowDirectMode, AllowLocalExecution; Linux: ~/.anybot/policy.json):
- unmanaged lets the person decide; pinned true/false fixes it; invalid locks it off (fail safe).
- A locked switch is greyed out with "🔒 由你的管理員設定,無法變更". The ⚡ link is hidden.
- A locked "你的電腦" is refused at once instead of asking. Locks are re-read each time Settings opens.
- Example:
defaults write com.anybot.AnyBot AllowDirectMode false.
Websites and the person's browser
The original reaches sites through its own browser (its old Chrome login copy is retired, see below). The rebuild (web.rs) gives every bot:
-
web_fetch: public pages as text (20 s timeout, 3 MB read, 20 000 characters to the model: estimated). No permission needed. -
browser_open: opens a link in the person's own browser on this Mac (Chrome, Arc, Edge, Brave, Chromium or Safari, whichever is open), where they're already logged in, and waits 4 s (estimated). -
browser_read: title, address and visible text of the current tab, via AppleScript. It asks first (grant 「讀取瀏覽器分頁」). -
Both browser tools count as working on 你的電腦, so the computer pop-ups apply when those are off. macOS asks itself the first time AnyBot controls a browser. If that's refused (-1743), the app opens System Settings → 隱私權與安全性 → 自動化 and says what to switch on. If page text is blocked, it says which menu item to turn on (Chrome: 檢視 → 開發人員 → 允許來自 Apple 事件的 JavaScript).
-
Which browser (decompiled).
sand_window_capture.node(Mach-O arm64, 128 064 bytes, sha256 f2b48533…; strings in evidenceev_6c197ec3…):Address Function Rule taken 0x52c0listWindows()CGWindowListCopyWindowInfo(0x11, 0): on-screen windows only (0x1) without desktop elements (0x10), front to back. A window is kept only if its alpha > 0 and its bounds have width > 0 and height > 0. Each entry records window id, owner pid, layer, bounds and owner name (evidenceev_fbc46bcb…)0x5258screenCaptureAccess()macOS ≥ 14 ( __isPlatformVersionAtLeast(1,14,0,0)), else 2 = unsupported; otherwiseCGPreflightScreenCaptureAccess() ^ 1(0 = granted, 1 = not granted) (evidenceev_e0e9c7c2…)0x651cstatusForError(NSError*)error code −3801 (the user declined capture) → status 2 "denied"; any other → 5 "failed" (evidence ev_7c417898…)Rebuild:
browser_read/browser_openread the same front-to-back window list (through macOS's own JavaScript bridge) with the same filter, plus layer 0 for ordinary app windows (ours), and use the browser whose window is closest to the front, the tab the person is actually looking at. If no browser window is visible they fall back to the first open browser, then Chrome, then Safari. Screen capture itself isn't rebuilt: reading the page text needs no screenshot. -
The bot is told not to say it has no tools, and to try these first. Checked against xAI grok-4.7: "幫我看一下我的threads 看一下我的帳號" now gives
browser_open(threads.net)+browser_read.
AnyBot's own browser (replaces driving the person's browser)
Grok Bot never asks the person to change browser settings because it browses in its own browser (its Chromium-based browser and webviews on the bot's computer). Driving the person's Chrome through AppleScript, the first approach here, needed Chrome's "允許來自 Apple 事件的 JavaScript" plus a macOS Automation prompt.
Copying logins from Chrome: retired in the original, not rebuilt.
- Native main program (
Grok BotMach-O, opened with core_target_open): searching its strings for browser / chrome / cdp / devtools / remote / port / debug finds 0 strings (evidenceev_3dc3f004…), so no browser or cookie logic lives in native code; it's all in the JavaScript. - The renderer still has the old approval card (chunk-view @3041: 「{botName} 想要使用你現有的登入」,
「從你電腦上的 Chrome 複製 Cookie…」, 檢視網站 / 允許一次 / 一律允許 / 拒絕, sites grouped by Chrome
profile), but the error catalogue in main-core.cjs and index.eager-app (domain
chrome-import, codes SAND-E0801 … SAND-E0812: cookieApprovalNoMachine … chromeCookieImportFailed) marks every one "retired: the agent-prompted cookie sync that raised it was removed" / "retired: the Chrome cookie import that raised it was removed", and the inject was behind a gate (sand_import_chrome_cookies, SAND-E0811). - So the shipping original no longer copies Chrome cookies. The rebuild matches that: nothing is copied, and the bot is told never to offer it. (Also: this Mac's Chrome/Brave data folders refuse reads without Full Disk Access, "Operation not permitted", so it couldn't work quietly anyway.) The person logs in once in AnyBot's own browser window instead.
Rebuild (botbrowser.rs): AnyBot starts a separate Chrome-family profile (Chrome, Brave, Edge or
Chromium, whichever is installed) as its own browser window, with logins kept in
…/AnyBot/browser. It talks to it over Chrome's DevTools protocol on local port 9333
(estimated, overridable for tests):
- browser_open: starts the window (waits up to 20 s, estimated) or adds a tab (
PUT /json/new?url). - browser_read: picks the first real page tab (
/json/list) and reads{title, location.href, document.body.innerText}withRuntime.evaluate(20 s limit, estimated). - No browser setting, no Automation prompt, no "your computer" question: it's the bot's browser. The person logs in to a site in that window once; a short page mentioning 登入 / log in gets a note asking them to.
- The bot is told never to send the person to browser menus. The AppleScript path remains only as a fallback when no Chrome-family browser is installed (Safari only).
- Checked live against a hidden Chrome: example.com was read as "標題:Example Domain".
Browser profiles (設定檔)
Grok Bot lets the person use any Chrome profile because on "your computer" it drives the real screen
through its Computer Use helper app (local-exec-daemon: Grok Bot Computer Use,
co.anysphere.grok-bot-computer-use, CUGrokBotService): it clicks in their normal Chrome like a
person would. The original has no browser-profile code of its own (no --profile-directory,
Local State or info_cache anywhere; profileDisplayName only on the retired login-copy card).
AnyBot doesn't control the screen, and its own browser couldn't see the person's Chrome profiles
(macOS refuses other apps …/Google/Chrome/Local State: "Operation not permitted"). So
(rebuild, estimated rules):
- AnyBot's browser has its own profiles: 「預設」 (the existing
…/AnyBot/browserfolder, logins kept) plus any added ones in…/AnyBot/browser-profiles/<name>. Each is its own window with its own logins. - Names: 1–30 characters, no
/or:, not starting with.; matched ignoring case. - 「預設」 keeps port 9333; added profiles start with port 0, so Chrome picks a free port and writes
it to
DevToolsActivePortin the profile folder, which AnyBot reads back (checked live: Chrome 156 wrote 62337 and answered on it). - The profile in use is remembered in
…/AnyBot/browser-profile.txt. - Bot tools:
browser_profiles(list / add / use, no permission needed: it's the bot's browser), and an optionalprofileon browser_open / browser_read (opening in a profile makes it the one in use). The bot is told to use these when the person says profile / 設定檔 / another account. - Settings → 電腦 → 「AnyBot 瀏覽器設定檔」: pick the one in use, 「開啟視窗」 to log in, 「新增設定檔」. Removing a profile isn't offered (it would delete its logins).
「你的瀏覽器」: reading the person's own Chrome, any profile (decompiled)
How Grok Bot reads the person's real Chrome in whatever profile is open: its Computer Use helper
(Helpers/Grok Bot Computer Use.app/Contents/MacOS/CUGrokBotService, Mach-O fat, sha256
1004a8cb…) turns on Chrome's accessibility tree and reads the screen's elements. Strings:
com.google.Chrome, com.google.Chrome., Google Chrome Framework.framework (evidence
ev_75dff652…), AXManualAccessibility / AXEnhancedUserInterface (ev_bef6c29e…), the 3
framework names (ev_a8cda95a…). Functions read:
| Address | Function | Rule taken |
|---|---|---|
0x1000a1b6c |
CURendererApp.isRenderer(bundleIdentifier:executablePath:) |
renderer if bundle id == com.github.Electron or == com.google.Chrome, or has prefix com.google.Chrome., or the executable path contains /Electron.app/Contents/MacOS/Electron, else isRendererBundle (evidence ev_4973f594…) |
0x1000a19ec |
CURendererApp.isRendererBundle(atPath:) |
<bundle>/Contents/Frameworks/<name> exists for one of 3 names: Electron Framework.framework, Chromium Embedded Framework.framework, Google Chrome Framework.framework (evidence ev_f0dc9836…) |
0x1000a1cdc |
CURendererApp.isRenderer(NSRunningApplication) |
takes the running app's bundle id, bundle URL path and executable URL path, then calls the rule above (evidence ev_179b4f10…) |
0x1000a1554 |
CURendererAccessibility.writeFlags(on:) |
AXUIElementSetAttributeValue(app, "AXManualAccessibility", on) then (app, "AXEnhancedUserInterface", on); result from both error codes (evidence ev_f661d29e…) |
Rebuild (axread.rs), used by the profile choice 「你的瀏覽器」 (aliases 我的 Chrome, 你的 Chrome…):
- Same renderer rule (
is_renderer, constants copied as written) and the same two flags, in order, on the person's browser before reading. Safari etc. need no flags. - macOS asks once for 輔助使用 (
AXIsProcessTrustedWithOptionswith the prompt). If it's off, AnyBot opens 系統設定 → 隱私權與安全性 → 輔助使用 and says to switch on AnyBot. No browser setting is needed. - Reads the focused window → first
AXWebArea→ text ofAXStaticText(AXValue), one line per group; titleAXTitle, addressAXURL. Up to 20 000 elements, depth 80, 5 tries 0.7 s apart while Chrome builds the tree, 2 s per answer (all estimated). - The person's browser process is told apart from AnyBot's own window by its arguments (AnyBot's
runs with
--user-data-dir=…/AnyBot/browser; helper processes carry--type=). - browser_open on 「你的瀏覽器」 starts the browser's own executable with the link, so Chrome hands it to the person's running window (their normal profile) instead of AnyBot's window.
- It counts as using 你的電腦: the computer pop-ups apply and reading asks first (「讀取你瀏覽器目前 分頁」), like before. Switching Chrome profiles is done by the person in Chrome; the bot reads the window in front.
- Not checked live yet: this test process isn't allowed 輔助使用 (trusted=false).
Talking to the bot runs the app
Routines are made in chat ("例行任務是這個 Bot 依排程重複執行的任務。請在聊天中請它設定一個。", ZJmAzX), and the primary bot can coordinate other bots (mQQ/Uq). App tools in the rebuild (appctl.rs): list_routines, create_routine, update_routine, delete_routine (asks first, wording VAgxC4), run_routine_now, list_bots, create_bot, update_bot (provider, model, name, pin, default computer), message_bot (wakes another bot), list_computers, update_settings (theme, accent, time zone). Turning on "在這台電腦上執行" stays person-only.
Routine schedules
Shape from index.eager-app @1508626 (Prt, Irt): {frequency: daily|weekly|monthly|yearly, interval, daysOfWeek: MO..SU (yA), monthDay | weekdayOfMonth {week, day}, month, time "HH:MM", timezone}.
Descriptions use the original's zh-TW strings: 每天 / 每{n} 天 / 每個工作日 / 每週的{days} / 每月{d}日 /
每月的{week}{day} / 每年{date}, then "{repeats} {time}" and "({zone})" when not the person's time zone.
"Every weekday" means exactly MO–FR (isEveryWeekday, WORKWEEK). Next-run rules (ours): intervals
count from the creation date; a month day past the month's end runs on the last day; summer-time gaps
take the earliest valid time. A routine that comes due while the bot is busy waits until it is idle.
Screen control on 你的電腦 (computer use)
The original's local daemon (local-exec-daemon main.cjs) drives its Computer Use helper with these
tools; the rebuild (computer.rs) uses the same names and arguments:
computer_use_screenshot {}, computer_use_click {method: coordinate, x, y, button, count,
modifier_keys}, computer_use_scroll {x, y, direction, amount, modifier_keys},
computer_use_mouse_move {x, y}, computer_use_drag {button, path, modifier_keys},
computer_use_typing {value}, computer_use_press_key {key, hold_duration_ms} (main.cjs @2860904).
-
Screenshot size,
zct(main.cjs): long edge fitted to 1024 (Gct); if the short edge is under 8 (Hs) nothing is resized; after scaling, an edge under 8 is raised so the short edge is 8. Largest image 1 048 576 bytes (mg = 1024*1024). JPEG is tried at quality 85, 70, 55 ($3r) and the first that fits is used, else the smallest one if it's under the cap (j3r). -
Rebuild:
screencapture -x -mof the main screen, fitted and encoded by those rules, sent to the model as a picture (Claude: inside the tool result; OpenAI-style: one user message with the pictures right after the run of tool results). Only the newest 3 screenshots are sent; older ones say 「較早的截圖已省略」 (estimated). Screenshots aren't saved with the chat. -
Coordinates are pixels of the latest screenshot; AnyBot turns them into screen points with the screen size (
CGDisplayBounds) ÷ picture size. Shortcuts likecmd+shift+tuse the US key codes. -
Mouse and keyboard timing, decompiled from
CUGrokBotService(evidence ids after each):Address Function Rule taken 0x100010120CUActuator.click(pid:screenPoint:windowBounds:…button:count:holdSeconds:)event source CGEventSourceCreate(1)(HID system state) withCGEventSourceSetLocalEventsSuppressionInterval(0); press → hold → release where hold =holdSeconds > 0 ? holdSeconds : 0.03;countclicks with 0.08 s between them; error "Could not create click event"; background windows get a window-local click (fields 91/92 = window id, "skylight-no-raise") (ev_f3077201…)0x100010a24CUActuator.typeCharacters(pid:text:)empty text → " valueis required and must be a non-empty string"; same event source, thenpostChunks(ev_77b2182b…)0x1000beb00CUTypedCharacters.utf16Chunkschunks of at most 20 UTF-16 units (buffer 0x14; a new chunk when the next character would reach 21), never splitting a character ( ev_0dce1c89…)0x1000beebcCUTypedCharacters.postChunkseach chunk: CGEventCreateKeyboardEvent(src, 0x80 = 128, down/up)+CGEventKeyboardSetUnicodeString, flags 0; down → 0.035 s → up; 0.02 s between chunks; error "Could not create character keyboard event" (ev_714c5253…)0x100031764CUCompanionToolService.handlePressKeypresses a list of keys in order: each down → 0.035 s → up, 0.02 s between keys; result "Pressed {keys joined by spaces}."; error "Could not create keyboard event" ( ev_730d67a4…)|
0x10008c34c|CURemoteEventPoster.drag(path:button:steps:holdSeconds:flags:)| fewer than 2 points → "Drag path requires at least two points."; mouse down at the first point (click state 1), then if hold > 0 sleep it in 10 000 µs slices, then for every segment poststepsdragged events atstart + delta × i / steps(i = 1…steps, steps at least 1), then mouse up at the last point (ev_f0d60f2e…) | |0x10009dee4|CURemoteToolService.handleDrag(closure) | calls drag with steps 6 and holdSeconds 0.04; result "Dragged from (%.0f, %.0f) to (%.0f, %.0f) with left/right/middle button." (ev_65c07c1e…) | | strings |handleScroll/handleDrag| "directionmust beup,down,left, orright.", "amountmust be an integer.", "pathmust be an array of at least two {x,y} points in model coordinates.", "Could not create scroll event" (ev_0b3df00c…) |Drag in the rebuild: same press, 0.04 s hold, 6 interpolated steps per segment, release at the end, and the reply 「已從 (x, y) 拖曳到 (x, y)(左鍵)」. Scroll refuses a non-integer amount.
Address Function Rule taken 0x10009e200CURemoteToolService.handleScrolldirectionis lowercased and must be up/down/left/right;amountmust be an integer, default 3, kept to 1…20 (max(amount,1),min(…,20)); distance =(amount + amount) << 3= amount × 16 pixels; down and right are negative;xandy"must be provided together", and without them the current pointer position is used ("Could not read the current cursor position."); needs Accessibility and Screen Recording (ev_98625ed7…)0x10009e998handleScroll closure posts a mouse-moved event (type 5) at the spot, then CGEventCreateScrollWheelEvent2(src, units 0 = pixel, wheels 2, vertical, horizontal, 0)at the same spot with the modifier flags; result "Scrolled {direction} x{amount}." (ev_ee9d8372…)Rebuild scroll: the same default, range, 16-pixel steps in pixel units, the both-or-neither x/y rule (no screenshot needed when scrolling where the pointer is), and 「已捲動 down ×3。」. Rebuild: the same event source, the same holds and gaps, the same chunking on key 128, and a key press may be several keys separated by spaces (
cmd+a delete).hold_duration_msreplaces the 0.035 s hold (at most 5 s, estimated). Window-local background clicks (clicking without bringing the window forward) aren't rebuilt: AnyBot clicks where the pointer is, like a person. Pauses between mouse-move and drag steps are 8 ms (estimated). -
Permissions: macOS asks once for 螢幕與系統錄音 (screenshots,
CGPreflightScreenCaptureAccesslikescreenCaptureAccess()@0x5258) and once for 輔助使用 (mouse and keyboard); if off, AnyBot opens the right System Settings pane and says what to switch on. In AnyBot, looking at the screen asks once (grant 「看你的螢幕畫面」) and using the mouse/keyboard asks once (「操作你的滑鼠和鍵盤」), both with "always allow"; 直接操作 skips the questions except for "一律詢問" rules. It always counts as 你的電腦, so the computer pop-ups and admin locks apply. -
Needs a model that can see pictures.
-
Not checked live: this test process has neither permission.
Still different from the original
- "接管" (take over the bot's computer) is not rebuilt: the bot's computer here is a sealed folder, not a cloud machine with a screen. Screen control works on 你的電腦 only.
- Routine triggers from events (GitHub, PagerDuty, Sentry:
GITHUB_EVENT_KINDSetc.) are not rebuilt; only time schedules. - The look still uses the estimated layout: the signed-in original's window hasn't been captured.
Restarting and recovering
From the native main program (decompiled again for this feature)
Contents/MacOS/Grok Bot. native_strings_find for relaunch|restart|ELECTRON|dev/null|crash finds two
strings (evidence ev_6af348eb…), and both lead to these functions:
| Address | Function | Rule taken |
|---|---|---|
0x1000004c0 |
sub_1000004c0 |
Before anything else, for fd 0/1/2: if fstat fails with errno == 9 (EBADF), freopen("/dev/null", …), so a copy started without terminal streams still has valid ones (evidence ev_29ffc0d8…) |
0x100000460 |
entry |
Calls 0x1000004c0, then a set ELECTRON_RUN_AS_NODE (fuse on, non-empty) changes how the program starts (ElectronInitializeICUandStartNode instead of ElectronMain) (evidence ev_7c151be5…) |
From the app code
| Rule | Value | Source |
|---|---|---|
| Relaunch helper | detached shell, all output ignored: spawn(shell, ["-c", script, "grok-bot-relaunch", pid, app, …args], {detached:true, stdio:"ignore"}) |
main-app.cjs @3195964 Pur |
| Wait before starting | while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done; exec "$app" "$@": the new copy starts only after the old one exited, polled every 0.2 s |
same, PBt |
| Inherited files | closes fds > 2 first (rEe, Linux /proc only) |
same |
| Button while it happens | "正在重新啟動…" (Restarting…) | renderer ljn → id fvg2KT |
| Bot's computer stuck | "Grok Bot 的電腦卡住時,從你上次儲存的快照重新啟動它"; recovering keeps files and logins, removes installed apps and packages | ids O+wR5I, ixKoax |
How the rebuild applies them
- 重新啟動 AnyBot (Settings → 一般): stops running turns as cancelled and background commands, saves,
then starts a detached
/bin/shhelper. The helper waits for this copy's pid to disappear, checking every 0.2 s, and then starts the app, so the two copies never overlap and the last save can't be overwritten. Its input and output go to/dev/null. Start-up switches (ANYBOT_SHOT_VIEW, the per-command tags,ELECTRON_RUN_AS_NODE) are removed so the new copy opens normally. The button reads "正在重新啟動…" while it happens. Tested byrelaunch_waits_for_the_old_copy. - 重新啟動這個 Bot (⋯ menu): ends the bot's turn as cancelled, stops its background commands and everything they started (by its conversation tag), and marks half-finished replies as interrupted. Chats and the files on its computer are kept. The snapshot "recover" step isn't rebuilt: the bot's computer here is a folder, not a machine image.
- Interrupted turns on load: a reply that was still streaming when the app closed is saved empty and can't continue. On load it becomes "(這次的回應被中斷了…)" with an error settlement, so "重新產生" appears. A message with no reply shows "重新傳送" (MyjAbr).
Versions and updating
Native main program: native_strings_find for version|update|0.78|x.y.z in Contents/MacOS/Grok Bot
finds 0 strings (evidence ev_df0e0f59…). The version (0.78.1) lives only in Info.plist
(CFBundleShortVersionString), and all update logic is in main-app.cjs.
| Rule | Value | Source |
|---|---|---|
| First check after launch | 30 s | main-app.cjs @3372407 bhr=3e4 (initialCheckDelayMs) |
| Then every | 1 h + random 0–5 min | khr=3600*1e3, Ihr=300*1e3, nextPeriodicDelayMs = interval + floor(random·jitter) |
| Apply by itself only when | auto-update on and update ready and (screen locked or screensaver running) and idle ≥ 600 s and no agent work | @3365216 uQt, phr, pQt=600, uhr (confirmed-idle) |
| Auto-update default | on | getAutoUpdateWhenIdle?.() ?? true |
| Screen locked | powerMonitor.getSystemIdleState(1) === "locked" |
Aj/Cle |
| Screensaver running | pgrep -x ScreenSaverEngine (2 s timeout) |
NRn, DRn |
| Labels | "重新啟動以更新" (FzpXvM), "正在重新啟動…" (fvg2KT), "在你離開時自動更新用戶端" (4vprnG) | catalogs |
Rebuild: the version shown is the program file's build time (sidebar footer, Settings → 一般 → 版本).
A newer build on disk shows the sidebar button "⬆ 重新啟動以更新(time)". Noticing it is a file check
every 10 s (estimated; it's local and cheap). Switching by itself follows the original's schedule:
first try 30 s after start, then hourly plus jitter, and only when the screen is locked or the
screensaver is running (ioreg session flag / pgrep -x ScreenSaverEngine), idle ≥ 600 s, and no
bot is working or waiting for you. It uses the same wait-then-start relaunch helper. Setting: 在你離開時
自動更新用戶端 (on by default). Tested by update_applies_only_when_truly_away.
Left out of this version
Done since this list was first written: routines (例行任務), screen control on 你的電腦, AnyBot's own browser with profiles, reading the person's own Chrome, removing an added browser profile. Still left out: Slack/Telegram/WhatsApp/email messaging, team bots and sharing, payments (these need the original's cloud service and accounts), routines started by events (GitHub, PagerDuty, Sentry need webhooks into that cloud), "接管" of a cloud bot computer, voice input, file attachments, and the exact signed-in window layout and dark-theme colours (never captured).
Look check (full pass over every screen)
- One font for everything: PingFang TC Regular (macOS system asset, found under
/System/Library/AssetsV2/com_apple_MobileAsset_Font*/…/PingFang.ttc, face 2), falling back to Hiragino Sans GB. Before, English came from egui's own font and Chinese from a fallback, on different baselines and sizes. egui's fonts stay behind it only for missing symbols. - That font's 「·」 already has space on both sides, so separators are written without extra spaces.
- Sidebar names and previews end in "…" before the status dot instead of being cut mid-letter.
- A step's copy/model line only shows under the bot's final words, so a tool's result sits right under the step that asked for it.
- Settings and Bot 設定 stop 80 px short of the window height (estimated) and scroll inside; the provider list is 200 px wide (estimated) so 「自訂(OpenAI 相容)」 fits on one line; all avatar shapes fit on one row; the model list button says 「選擇」.
- Decompile note: the native main program (
Grok Bot, Mach-O 53 040 bytes, sha256 df5b07de…) is only Electron's launcher: a search for font / width / height / title finds 0 strings (evidenceev_7cf8e974…). The look lives in the renderer styles (index-Dh7EWA8t.css), read here:- Type scale
--sand-font-size-*: xxs 11, xs 12, sm 13, base 14, lg 15, xl 17, xxl 22, xxxl 26 px;--sand-line-height-*: 16, 16, 18, 20, 22, 24, 28, 32 px. --cursor-font-family-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, which on a zh-TW Mac means SF for Latin and PingFang for Chinese.--cursor-font-family-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, ….- Rebuild: body/buttons 14 (base), small 12 (xs), headings and window titles 17 (xl), code 13 (sm)
on 18 px lines, chat text 15 (lg) on 22 px lines, Markdown headings 17 on 24 px lines; code in SF
Mono (
/System/Library/Fonts/SFNSMono.ttf, then Menlo). Text stays in PingFang TC for both scripts, which matches -apple-system's Chinese and is close to SF for Latin (egui can't line up two fonts' baselines the way the browser does).
- Type scale