feat(studio): add a setting to hide the macOS menu bar icon (#11937)

Closes #10157

## What

Adds a macOS-only **Settings → General → Startup → Show in menu bar**
switch. Turning it off hides the Unsloth menu bar icon immediately and
keeps it hidden on later launches; turning it on brings it back.

## Why this shape

A login start runs with `--hidden`, and `.setup` then switches the app
to `ActivationPolicy::Accessory` (no Dock icon). With the menu bar icon
also gone, a hidden start would leave a running app with no way back to
it. So a hidden start keeps its Dock icon whenever the menu bar icon is
hidden: the window still stays hidden, and clicking the Dock icon opens
it through the existing `RunEvent::Reopen` → `show_main_window` path.
"Run Unsloth at login" is left exactly as the user set it.

## Changes

**`studio/src-tauri/src/main.rs`**
- New `tray-icon-visible-v1` preference beside `close-to-tray-v1`, with
the same read/write helpers. A missing, unreadable or invalid file reads
as *visible*: hiding is opt-in, and a corrupt file can never strand the
app.
- The tray is built with `TrayIconBuilder::with_id("main")`, and on
macOS the stored visibility is applied right after it is built.
- `get_tray_icon_visible` returns `None` off macOS, so the row only
renders on macOS. `set_tray_icon_visible` applies
`TrayIcon::set_visible` first, then persists; if persisting fails it
restores the previous visibility and returns the error, so the switch
never disagrees with what the next launch will do.
- The Accessory policy is applied only when `launched_hidden` **and**
the icon is visible.
- Two unit tests: the default is visible and round-trips; an invalid
file reads as visible.

**`studio/frontend`**
- `api/tray-icon.ts` invoke wrappers, and a Startup row in
`general-tab.tsx` built on the existing `useDesktopBooleanSetting` hook,
mirroring the close-to-tray row.
- The row is indexed for Settings search only on the macOS desktop
(`createSettingsSearchIndex({ …, menuBarIcon })`), with a test extended
in `tests/settings-search.test.ts`.
- `menuBarIcon`, `menuBarIconDescription` and `menuBarIconSaveError`
added to `en.ts` and translated in all 11 other locales, since
`i18n:check:strict` requires them.

## Not in this PR

The issue comment also asked for a monochrome icon. That already shipped
in #10880 (`macos_tray.rs` picks light or dark artwork from the menu
bar's effective appearance), and #10196 is marked superseded.

## Verification (macOS 26, Apple M1 Pro)

- `cargo test --no-fail-fast`: 580 passed, including the two new tests.
`cargo fmt --check` / `cargo clippy` report only findings that already
exist on `main` (120 fmt diffs on `main` itself); none are in the lines
this PR touches.
- `npm run typecheck`, `npm test` (8776 passed), `npm run
i18n:check:strict`, `npm run build` all pass. `npm run lint` reports
only existing findings in files this PR does not touch.
- Ran the debug desktop build and read the menu bar and Dock state
through System Events:
  - normal launch → menu bar icon shown, Dock icon shown;
- `--hidden`, icon visible → menu bar icon shown, no Dock icon
(unchanged behaviour);
  - `--hidden`, icon hidden → no menu bar icon, Dock icon kept.
- Toggled the switch in the running debug build: off → the menu bar item
disappears and `tray-icon-visible-v1` holds `false`; on → it returns and
the file holds `true`. After switching it off, quitting and relaunching,
the icon stays hidden at startup and the switch reads off.


## macOS Desktop before/after

Captured on a GitHub `macos-26` runner: the Desktop app built from the
merge base (`31436b1`) and from this PR (`2a07993`), both attached to
the same installed Studio backend and driven through the Accessibility
API.

Settings > General > Startup. The new "Show in menu bar" row appears
only on the PR build:

![Settings General tab, merge base vs this
PR](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/11937/composite_settings-3da676a27886.png)

Flipping the switch on the PR build removes the Unsloth menu bar item,
and flipping it back restores it:

![Menu bar with the switch on and
off](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/11937/composite_menubar-f266878583c3.png)

Read from the running app at each step: the app's menu bar extras count
went 1 -> 0 -> 1, the switch value 1 -> 0 -> 1, and
`tray-icon-visible-v1` held `false`, then `true`.

---------

Co-authored-by: Nilay Yadav <nilayyadav10@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
This commit is contained in:
Tom Mathews 2026-10-19 19:11:35 +05:30 • committed by GitHub
parent 71bf7993c4
commit 2b20770c91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 290 additions and 21 deletions

View file

@ -0,0 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export async function loadTrayIconVisible(): Promise<boolean | null> {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<boolean | null>("get_tray_icon_visible");
}
export async function updateTrayIconVisible(visible: boolean): Promise<boolean> {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<boolean>("set_tray_icon_visible", { enabled: visible });
}

View file

@ -15,4 +15,5 @@ export const DIALOG_SETTINGS_SEARCH_INDEX = createSettingsSearchIndex({
(clientPlatform.startsWith("win") ||
clientPlatform.includes("windows") ||
clientPlatform.includes("linux")),
menuBarIcon: isTauri && clientPlatform.includes("mac"),
});

View file

@ -363,9 +363,11 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
export function createSettingsSearchIndex({
desktop,
closeToTray,
menuBarIcon,
}: {
desktop: boolean;
closeToTray: boolean;
menuBarIcon: boolean;
}): Record<SettingsTab, TranslationKey[]> {
if (!desktop) {
return SETTINGS_SEARCH_INDEX;
@ -378,6 +380,7 @@ export function createSettingsSearchIndex({
"settings.general.startup.sectionTitle",
"settings.general.startup.launchAtLogin",
...(closeToTray ? (["settings.general.startup.closeToTray"] as const) : []),
...(menuBarIcon ? (["settings.general.startup.menuBarIcon"] as const) : []),
// Desktop only, like the row itself: DesktopRepairControl renders nothing without a
// Tauri repair controller, so indexing it on the web build would scroll to a row
// that is not there. Worth indexing at all because the capability message for a host

View file

@ -59,6 +59,7 @@ import {
updateUploadLimitSettings,
} from "../api/upload-limit";
import { loadCloseToTray, updateCloseToTray } from "../api/close-to-tray";
import { loadTrayIconVisible, updateTrayIconVisible } from "../api/tray-icon";
import { loadLaunchAtLogin, updateLaunchAtLogin } from "../api/launch-at-login";
import { useIsAccountOwner } from "@/features/auth";
import {
@ -250,6 +251,13 @@ export function GeneralTab() {
loadError: t("settings.general.startup.loadError"),
saveError: t("settings.general.startup.closeToTraySaveError"),
});
const trayIconSetting = useDesktopBooleanSetting({
enabled: isTauri,
load: loadTrayIconVisible,
save: updateTrayIconVisible,
loadError: t("settings.general.startup.loadError"),
saveError: t("settings.general.startup.menuBarIconSaveError"),
});
const draftRef = useRef(draftToken);
const tokenEditedRef = useRef(false);
@ -655,6 +663,28 @@ export function GeneralTab() {
/>
</SettingsRow>
) : null}
{trayIconSetting.supported ? (
<SettingsRow
label={t("settings.general.startup.menuBarIcon")}
description={t("settings.general.startup.menuBarIconDescription")}
>
<div className="flex flex-col items-end gap-1">
<Switch
checked={trayIconSetting.value ?? false}
disabled={
trayIconSetting.value === null || trayIconSetting.saving
}
onCheckedChange={(enabled) => void trayIconSetting.update(enabled)}
/>
{trayIconSetting.error ? (
<span className="max-w-[calc(260px*var(--ui-space-scale,1))] text-right text-xs text-destructive">
{trayIconSetting.error}
</span>
) : null}
</div>
</SettingsRow>
) : null}
</SettingsSection>
) : null}

View file

@ -1836,6 +1836,11 @@ export const ar = {
closeToTrayDescription:
"إبقاء Unsloth وخادمه قيد التشغيل في الخلفية عند إغلاق النافذة الرئيسية.",
closeToTraySaveError: "تعذّر تحديث إعداد الإغلاق إلى علبة النظام.",
menuBarIcon: "الإظهار في شريط القوائم",
menuBarIconDescription:
"إظهار أيقونة Unsloth في شريط القوائم في macOS. عند إخفائها، يبقى Unsloth في الـ Dock، حتى عند بدء التشغيل عند تسجيل الدخول.",
menuBarIconSaveError: "تعذّر تحديث إعداد أيقونة شريط القوائم.",
loadError: "تعذر تحميل إعداد التشغيل عند تسجيل الدخول.",
saveError: "تعذر تحديث إعداد التشغيل عند تسجيل الدخول.",
},

View file

@ -1858,6 +1858,12 @@ export const de = {
"Unsloth und seinen Server im Hintergrund weiterlaufen lassen, wenn Sie das Hauptfenster schließen.",
closeToTraySaveError:
"Die Einstellung zum Schließen in den Infobereich konnte nicht aktualisiert werden.",
menuBarIcon: "In der Menüleiste anzeigen",
menuBarIconDescription:
"Zeigt das Unsloth-Symbol in der macOS-Menüleiste an. Wenn es ausgeblendet ist, bleibt Unsloth stattdessen im Dock – auch wenn es bei der Anmeldung startet.",
menuBarIconSaveError:
"Die Einstellung für das Menüleistensymbol konnte nicht aktualisiert werden.",
loadError: "Die Autostart-Einstellung konnte nicht geladen werden.",
saveError:
"Die Autostart-Einstellung konnte nicht aktualisiert werden.",

View file

@ -1916,6 +1916,11 @@ export const en = {
closeToTrayDescription:
"Keep Unsloth and its server running in the background when you close the main window.",
closeToTraySaveError: "Failed to update the close to system tray setting.",
menuBarIcon: "Show in menu bar",
menuBarIconDescription:
"Show the Unsloth icon in the macOS menu bar. When hidden, Unsloth stays in the Dock instead, including when it starts at login.",
menuBarIconSaveError: "Failed to update the menu bar icon setting.",
loadError: "Failed to load the launch at login setting.",
saveError: "Failed to update the launch at login setting.",
},

View file

@ -1854,6 +1854,12 @@ export const es = {
"Mantén Unsloth y su servidor ejecutándose en segundo plano al cerrar la ventana principal.",
closeToTraySaveError:
"No se pudo actualizar el ajuste de cierre en la bandeja del sistema.",
menuBarIcon: "Mostrar en la barra de menús",
menuBarIconDescription:
"Muestra el icono de Unsloth en la barra de menús de macOS. Cuando está oculto, Unsloth permanece en el Dock, incluso al iniciarse con el inicio de sesión.",
menuBarIconSaveError:
"No se pudo actualizar el ajuste del icono de la barra de menús.",
loadError: "No se pudo cargar el ajuste de inicio automático.",
saveError: "No se pudo actualizar el ajuste de inicio automático.",
},

View file

@ -1856,6 +1856,12 @@ export const fr = {
"Laisser Unsloth et son serveur fonctionner en arrière-plan lorsque vous fermez la fenêtre principale.",
closeToTraySaveError:
"Impossible de mettre à jour le réglage de fermeture dans la zone de notification.",
menuBarIcon: "Afficher dans la barre de menus",
menuBarIconDescription:
"Afficher l'icône Unsloth dans la barre de menus de macOS. Une fois masquée, Unsloth reste dans le Dock, y compris lors d'un démarrage à la connexion.",
menuBarIconSaveError:
"Impossible de mettre à jour le réglage de l'icône dans la barre de menus.",
loadError:
"Impossible de charger le réglage de lancement à la connexion.",
saveError:

View file

@ -1946,6 +1946,11 @@ export const he = {
closeToTrayDescription:
"השאר את Unsloth והשרת שלו פועלים ברקע בעת סגירת החלון הראשי.",
closeToTraySaveError: "עדכון הגדרת הסגירה למגש המערכת נכשל.",
menuBarIcon: "הצג בשורת התפריטים",
menuBarIconDescription:
"הצג את סמל Unsloth בשורת התפריטים של macOS. כשהוא מוסתר, Unsloth נשאר ב-Dock במקום זאת, גם כשהוא מופעל בכניסה למערכת.",
menuBarIconSaveError: "עדכון הגדרת סמל שורת התפריטים נכשל.",
loadError: "טעינת הגדרת ההפעלה בכניסה למערכת נכשלה.",
saveError: "עדכון הגדרת ההפעלה בכניסה למערכת נכשל.",
},

View file

@ -1840,6 +1840,11 @@ export const hi = {
closeToTrayDescription:
"मुख्य विंडो बंद करने पर Unsloth और उसके सर्वर को बैकग्राउंड में चलते रहने दें।",
closeToTraySaveError: "सिस्टम ट्रे में बंद करने की सेटिंग अपडेट नहीं हो सकी।",
menuBarIcon: "मेनू बार में दिखाएं",
menuBarIconDescription:
"macOS मेनू बार में Unsloth आइकन दिखाएं। छिपाने पर, Unsloth इसके बजाय Dock में बना रहता है, यहां तक कि लॉगिन पर शुरू होने पर भी।",
menuBarIconSaveError: "मेनू बार आइकन सेटिंग अपडेट नहीं हो सकी।",
loadError: "लॉगिन पर चलाने की सेटिंग लोड नहीं हो सकी।",
saveError: "लॉगिन पर चलाने की सेटिंग अपडेट नहीं हो सकी।",
},

View file

@ -1782,6 +1782,12 @@ export const it = {
"Mantieni Unsloth e il suo server in esecuzione in background quando chiudi la finestra principale.",
closeToTraySaveError:
"Impossibile aggiornare l'impostazione di chiusura nell'area di notifica.",
menuBarIcon: "Mostra nella barra dei menu",
menuBarIconDescription:
"Mostra l'icona di Unsloth nella barra dei menu di macOS. Se nascosta, Unsloth rimane nel Dock, anche quando si avvia all'accesso.",
menuBarIconSaveError:
"Impossibile aggiornare l'impostazione dell'icona nella barra dei menu.",
loadError: "Impossibile caricare l'impostazione di avvio all'accesso.",
saveError:
"Impossibile aggiornare l'impostazione di avvio all'accesso.",

View file

@ -1824,6 +1824,11 @@ export const ja = {
closeToTrayDescription:
"メインウィンドウを閉じても、Unsloth とサーバーをバックグラウンドで実行し続けます。",
closeToTraySaveError: "システムトレイに格納する設定を更新できませんでした。",
menuBarIcon: "メニューバーに表示",
menuBarIconDescription:
"macOS のメニューバーに Unsloth のアイコンを表示します。非表示にすると、ログイン時に起動した場合を含め、Unsloth は Dock に残ります。",
menuBarIconSaveError: "メニューバーアイコンの設定を更新できませんでした。",
loadError: "ログイン時起動の設定を読み込めませんでした。",
saveError: "ログイン時起動の設定を更新できませんでした。",
},

View file

@ -1833,6 +1833,11 @@ export const ko = {
closeToTrayDescription:
"기본 창을 닫아도 Unsloth와 서버가 백그라운드에서 계속 실행되도록 합니다.",
closeToTraySaveError: "시스템 트레이로 닫기 설정을 업데이트하지 못했습니다.",
menuBarIcon: "메뉴 막대에 표시",
menuBarIconDescription:
"macOS 메뉴 막대에 Unsloth 아이콘을 표시합니다. 숨기면 로그인 시 시작된 경우를 포함해 Unsloth는 Dock에 남아 있습니다.",
menuBarIconSaveError: "메뉴 막대 아이콘 설정을 업데이트하지 못했습니다.",
loadError: "로그인 시 실행 설정을 불러오지 못했습니다.",
saveError: "로그인 시 실행 설정을 업데이트하지 못했습니다.",
},

View file

@ -1845,6 +1845,12 @@ export const ptBR = {
"Mantém o Unsloth e seu servidor em execução em segundo plano ao fechar a janela principal.",
closeToTraySaveError:
"Não foi possível atualizar a configuração de fechar para a bandeja do sistema.",
menuBarIcon: "Mostrar na barra de menus",
menuBarIconDescription:
"Mostra o ícone do Unsloth na barra de menus do macOS. Quando oculto, o Unsloth permanece no Dock, inclusive quando é iniciado no login.",
menuBarIconSaveError:
"Não foi possível atualizar a configuração do ícone da barra de menus.",
loadError:
"Não foi possível carregar a configuração de inicialização no login.",
saveError:

View file

@ -1843,6 +1843,12 @@ export const ru = {
"Оставлять Unsloth и его сервер работающими в фоне при закрытии главного окна.",
closeToTraySaveError:
"Не удалось обновить настройку закрытия в системный трей.",
menuBarIcon: "Показывать в строке меню",
menuBarIconDescription:
"Показывать значок Unsloth в строке меню macOS. Если он скрыт, Unsloth остаётся в Dock, в том числе при запуске при входе в систему.",
menuBarIconSaveError:
"Не удалось обновить настройку значка в строке меню.",
loadError: "Не удалось загрузить настройку автозапуска.",
saveError: "Не удалось обновить настройку автозапуска.",
},

View file

@ -2009,6 +2009,12 @@ export const sv = {
"Låt Unsloth och dess server fortsätta köra i bakgrunden när du stänger huvudfönstret.",
closeToTraySaveError:
"Det gick inte att uppdatera inställningen för att stänga till systemfältet.",
menuBarIcon: "Visa i menyraden",
menuBarIconDescription:
"Visa Unsloth-ikonen i menyraden i macOS. När den är dold ligger Unsloth kvar i Dock i stället, även när programmet startar vid inloggning.",
menuBarIconSaveError:
"Det gick inte att uppdatera inställningen för menyradsikonen.",
loadError:
"Det gick inte att läsa in inställningen för start vid inloggning.",
saveError:

View file

@ -1817,6 +1817,11 @@ export const zhCN = {
closeToTrayDescription:
"关闭主窗口时,让 Unsloth 及其服务器继续在后台运行。",
closeToTraySaveError: "无法更新关闭到系统托盘设置。",
menuBarIcon: "在菜单栏中显示",
menuBarIconDescription:
"在 macOS 菜单栏中显示 Unsloth 图标。隐藏后,Unsloth 会保留在程序坞(Dock)中,包括在登录时启动的情况。",
menuBarIconSaveError: "无法更新菜单栏图标设置。",
loadError: "无法加载登录时启动设置。",
saveError: "无法更新登录时启动设置。",
},

View file

@ -173,6 +173,10 @@ function generalTab(initialToken: string) {
loadCloseToTray: noop,
updateCloseToTray: noop,
},
"../api/tray-icon": {
loadTrayIconVisible: noop,
updateTrayIconVisible: noop,
},
"../api/launch-at-login": {
loadLaunchAtLogin: noop,
updateLaunchAtLogin: noop,

View file

@ -15,14 +15,14 @@ const UPDATE_ENTRY = "settings.about.updates";
const INTERFACE_SCALE_ENTRY = "settings.appearance.custom.interfaceScale.label";
test("desktop update searches route to General", () => {
const index = createSettingsSearchIndex({ desktop: true, closeToTray: true });
const index = createSettingsSearchIndex({ desktop: true, closeToTray: true, menuBarIcon: false });
assert.ok(index.general.includes(UPDATE_ENTRY));
assert.ok(!index.about.includes(UPDATE_ENTRY));
});
test("browser update searches keep routing to About", () => {
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(!index.general.includes(UPDATE_ENTRY));
assert.ok(index.about.includes(UPDATE_ENTRY));
@ -31,8 +31,8 @@ test("browser update searches keep routing to About", () => {
test("interface scale is searchable on every build", () => {
// The browser build scales through the UI tokens, so the row renders there
// too and search has to find it.
const desktop = createSettingsSearchIndex({ desktop: true, closeToTray: true });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const desktop = createSettingsSearchIndex({ desktop: true, closeToTray: true, menuBarIcon: false });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(desktop.appearance.includes(INTERFACE_SCALE_ENTRY));
assert.ok(browser.appearance.includes(INTERFACE_SCALE_ENTRY));
@ -45,7 +45,7 @@ test("interface scale is searchable on every build", () => {
// The words a user types for this feature are not substrings of any of its
// labels, so without keywords the rows it is named after were unfindable.
test("model memory rows are reachable by the terms the feature is about", () => {
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
const rows = [
"settings.resources.modelMemory.title",
"settings.resources.modelMemory.keepResident",
@ -77,14 +77,14 @@ const CLOSE_TO_TRAY_ENTRY = "settings.general.startup.closeToTray";
const CURRENT_DATE_ENTRY = "settings.chat.currentDate.label";
test("the current date prompt setting is searchable under Chat", () => {
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(index.chat.includes(CURRENT_DATE_ENTRY));
});
test("desktop startup entries are absent from browser search", () => {
const desktop = createSettingsSearchIndex({ desktop: true, closeToTray: true });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const desktop = createSettingsSearchIndex({ desktop: true, closeToTray: true, menuBarIcon: false });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
for (const entry of DESKTOP_STARTUP_ENTRIES) {
assert.ok(desktop.general.includes(entry));
@ -97,25 +97,37 @@ test("the repair row is searchable on the desktop, where it exists", () => {
// "Repair installation in Settings", so searching Settings for it has to find it. Only
// on the desktop: DesktopRepairControl renders nothing in a browser, and an index entry
// there would scroll to a row that is not on the page.
const desktop = createSettingsSearchIndex({ desktop: true, closeToTray: true });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const desktop = createSettingsSearchIndex({ desktop: true, closeToTray: true, menuBarIcon: false });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(desktop.general.includes("settings.general.repairInstall.label"));
assert.ok(!browser.general.includes("settings.general.repairInstall.label"));
});
test("close to tray is searchable only on supported desktops", () => {
const supported = createSettingsSearchIndex({ desktop: true, closeToTray: true });
const mac = createSettingsSearchIndex({ desktop: true, closeToTray: false });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const supported = createSettingsSearchIndex({ desktop: true, closeToTray: true, menuBarIcon: false });
const mac = createSettingsSearchIndex({ desktop: true, closeToTray: false, menuBarIcon: false });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(supported.general.includes(CLOSE_TO_TRAY_ENTRY));
assert.ok(!mac.general.includes(CLOSE_TO_TRAY_ENTRY));
assert.ok(!browser.general.includes(CLOSE_TO_TRAY_ENTRY));
});
const MENU_BAR_ICON_ENTRY = "settings.general.startup.menuBarIcon";
test("menu bar icon is searchable only when the platform supports it", () => {
const mac = createSettingsSearchIndex({ desktop: true, closeToTray: false, menuBarIcon: true });
const windows = createSettingsSearchIndex({ desktop: true, closeToTray: true, menuBarIcon: false });
const browser = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(mac.general.includes(MENU_BAR_ICON_ENTRY));
assert.ok(!windows.general.includes(MENU_BAR_ICON_ENTRY));
assert.ok(!browser.general.includes(MENU_BAR_ICON_ENTRY));
});
test("the endpoint rows are searchable only while Hugging Face serves, as they render", () => {
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
const endpoint = ["settings.general.hub.endpoint", "settings.general.hub.datasetsServer"] as const;
const modelScope = renderedSearchEntries(index, "general", "modelscope");
const huggingFace = renderedSearchEntries(index, "general", "huggingface");
@ -129,6 +141,7 @@ test("agent access (MCP) is found by the protocol and the agents' names, by the
const index = createSettingsSearchIndex({
desktop: false,
closeToTray: false,
menuBarIcon: false,
});
assert.ok(index["api-keys"].includes(MCP_ENTRY));
const keywordsKey = SETTINGS_SEARCH_KEYWORDS[MCP_ENTRY];
@ -153,7 +166,7 @@ test("agent access (MCP) is found by the protocol and the agents' names, by the
const TOOL_RESULT_LIMIT_ENTRY = "settings.chat.toolResultLimit";
test("the tool output length is found by truncate and limit, by the owner only", () => {
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
assert.ok(index.chat.includes(TOOL_RESULT_LIMIT_ENTRY));
const haystack =
`${en.settings.chat.toolResultLimit} ${en.settings.chat.toolResultLimitKeywords}`.toLowerCase();
@ -165,7 +178,7 @@ test("the tool output length is found by truncate and limit, by the owner only",
});
test("the multi-model, image/video idle and keepKv rows are found where they render", () => {
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false });
const index = createSettingsSearchIndex({ desktop: false, closeToTray: false, menuBarIcon: false });
const rows = [
["resources", "settings.resources.modelMemory.multiModel", "settings.resources.modelMemory.multiModelKeywords", "evict"],
["resources", "settings.general.modelAutoSwitch.mediaIdleUnload", "settings.resources.modelMemory.mediaIdleUnloadKeywords", "ttl"],

View file

@ -73,6 +73,11 @@ const IN_APP_RELAUNCH_MARKER_FILE: &str = "in-app-relaunch-v1";
const CLOSE_TO_TRAY_PREFERENCE_FILE: &str = "close-to-tray-v1";
/// Absent or unreadable = shown, so a hidden start never loses its only way back (see `.setup`).
const TRAY_ICON_VISIBLE_PREFERENCE_FILE: &str = "tray-icon-visible-v1";
const TRAY_ID: &str = "main";
/// The user's answer to "Run Unsloth at login", kept beside the OS entry rather than derived
/// from it. The Windows entry is one HKCU Run value that outside things delete without asking:
/// the NSIS uninstaller drops it on any non-update run (installer.nsi), and an antivirus
@ -220,6 +225,60 @@ fn write_close_to_tray_preference(config_dir: &Path, enabled: bool) -> Result<()
})
}
fn tray_icon_visible_preference_path(config_dir: &Path) -> PathBuf {
config_dir.join(TRAY_ICON_VISIBLE_PREFERENCE_FILE)
}
fn read_tray_icon_visible_preference(config_dir: &Path) -> bool {
let path = tray_icon_visible_preference_path(config_dir);
match fs::read_to_string(&path) {
Ok(value) => match value.trim().parse::<bool>() {
Ok(visible) => visible,
Err(error) => {
warn!(
"Ignoring invalid tray icon visibility preference {}: {error}",
path.display()
);
true
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
Err(error) => {
warn!(
"Could not read tray icon visibility preference {}: {error}",
path.display()
);
true
}
}
}
fn write_tray_icon_visible_preference(config_dir: &Path, visible: bool) -> Result<(), String> {
fs::create_dir_all(config_dir).map_err(|error| {
format!(
"Failed to create app configuration directory {}: {error}",
config_dir.display()
)
})?;
let path = tray_icon_visible_preference_path(config_dir);
fs::write(&path, format!("{visible}\n")).map_err(|error| {
format!(
"Failed to save tray icon visibility preference {}: {error}",
path.display()
)
})
}
fn stored_tray_icon_visible_preference(app: &tauri::AppHandle) -> bool {
app.path()
.app_config_dir()
.map(|dir| read_tray_icon_visible_preference(&dir))
.unwrap_or_else(|error| {
warn!("Could not determine app configuration directory: {error}");
true
})
}
fn launch_at_login_preference_path(config_dir: &Path) -> PathBuf {
config_dir.join(LAUNCH_AT_LOGIN_PREFERENCE_FILE)
}
@ -326,6 +385,52 @@ fn set_close_to_tray(
Ok(enabled)
}
#[tauri::command]
fn get_tray_icon_visible(app: tauri::AppHandle) -> Option<bool> {
cfg!(target_os = "macos").then(|| stored_tray_icon_visible_preference(&app))
}
/// tray-icon rebuilds the status item on show, so the appearance observer must follow it.
fn apply_tray_icon_visible(tray: &tauri::tray::TrayIcon, visible: bool) -> tauri::Result<()> {
tray.set_visible(visible)?;
#[cfg(target_os = "macos")]
if visible {
if let Err(error) = macos_tray::install_appearance_observer(tray) {
// The template icon remains visible and adaptive if native observation is unavailable.
warn!("Could not install the macOS tray appearance observer: {error}");
}
} else {
macos_tray::remove_appearance_observer();
}
Ok(())
}
#[tauri::command]
fn set_tray_icon_visible(app: tauri::AppHandle, enabled: bool) -> Result<bool, String> {
if !cfg!(target_os = "macos") {
return Err("The menu bar icon is only configurable on macOS".to_string());
}
let tray = app
.tray_by_id(TRAY_ID)
.ok_or_else(|| "Menu bar icon is not available".to_string())?;
let previous = stored_tray_icon_visible_preference(&app);
apply_tray_icon_visible(&tray, enabled)
.map_err(|error| format!("Could not update the menu bar icon: {error}"))?;
let persisted = app
.path()
.app_config_dir()
.map_err(|error| format!("Could not determine app configuration directory: {error}"))
.and_then(|config_dir| write_tray_icon_visible_preference(&config_dir, enabled));
if let Err(error) = persisted {
// Keep the icon matching the preference the next launch will read.
if let Err(restore_error) = apply_tray_icon_visible(&tray, previous) {
warn!("Could not restore the previous menu bar icon visibility: {restore_error}");
}
return Err(error);
}
Ok(enabled)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MainWindowCloseAction {
Hide,
@ -1870,7 +1975,7 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(not(target_os = "macos"))]
let tray_icon = tauri::include_image!("./icons/tray-icon-color.png");
let tray = TrayIconBuilder::new()
let tray = TrayIconBuilder::with_id(TRAY_ID)
.menu(&menu)
.tooltip("Unsloth")
.icon(tray_icon)
@ -1896,9 +2001,11 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
.build(app)?;
#[cfg(target_os = "macos")]
if let Err(error) = macos_tray::install_appearance_observer(&tray) {
// The template icon remains visible and adaptive if native observation is unavailable.
warn!("Could not install the macOS tray appearance observer: {error}");
{
let visible = stored_tray_icon_visible_preference(app.handle());
if let Err(error) = apply_tray_icon_visible(&tray, visible) {
warn!("Could not apply the stored tray icon visibility: {error}");
}
}
#[cfg(not(target_os = "macos"))]
drop(tray);
@ -2383,6 +2490,8 @@ fn main() {
set_close_to_tray,
get_launch_at_login,
set_launch_at_login,
get_tray_icon_visible,
set_tray_icon_visible,
set_tray_server_status,
];
handler(invoke)
@ -2393,7 +2502,8 @@ fn main() {
#[cfg(not(target_os = "macos"))]
let _ = launched_hidden;
#[cfg(target_os = "macos")]
if launched_hidden {
// Without a menu bar icon the Dock icon is the only way back to a hidden start.
if launched_hidden && stored_tray_icon_visible_preference(app.handle()) {
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
}
@ -2754,6 +2864,25 @@ media-src 'self' https:"
assert!(!read_close_to_tray_preference(dir.path()));
}
#[test]
fn tray_icon_visible_preference_defaults_on_and_round_trips() {
let dir = tempfile::tempdir().unwrap();
assert!(read_tray_icon_visible_preference(dir.path()));
write_tray_icon_visible_preference(dir.path(), false).unwrap();
assert!(!read_tray_icon_visible_preference(dir.path()));
write_tray_icon_visible_preference(dir.path(), true).unwrap();
assert!(read_tray_icon_visible_preference(dir.path()));
}
#[test]
fn invalid_tray_icon_visible_preference_falls_back_to_visible() {
let dir = tempfile::tempdir().unwrap();
fs::write(tray_icon_visible_preference_path(dir.path()), b"maybe\n").unwrap();
assert!(read_tray_icon_visible_preference(dir.path()));
}
#[test]
fn launch_at_login_preference_round_trips_and_starts_with_no_record() {
let dir = tempfile::tempdir().unwrap();