mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-22 06:20:56 +00:00
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:

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

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:
parent
71bf7993c4
commit
2b20770c91
21 changed files with 290 additions and 21 deletions
12
studio/frontend/src/features/settings/api/tray-icon.ts
Normal file
12
studio/frontend/src/features/settings/api/tray-icon.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -1836,6 +1836,11 @@ export const ar = {
|
|||
closeToTrayDescription:
|
||||
"إبقاء Unsloth وخادمه قيد التشغيل في الخلفية عند إغلاق النافذة الرئيسية.",
|
||||
closeToTraySaveError: "تعذّر تحديث إعداد الإغلاق إلى علبة النظام.",
|
||||
|
||||
menuBarIcon: "الإظهار في شريط القوائم",
|
||||
menuBarIconDescription:
|
||||
"إظهار أيقونة Unsloth في شريط القوائم في macOS. عند إخفائها، يبقى Unsloth في الـ Dock، حتى عند بدء التشغيل عند تسجيل الدخول.",
|
||||
menuBarIconSaveError: "تعذّر تحديث إعداد أيقونة شريط القوائم.",
|
||||
loadError: "تعذر تحميل إعداد التشغيل عند تسجيل الدخول.",
|
||||
saveError: "تعذر تحديث إعداد التشغيل عند تسجيل الدخول.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1946,6 +1946,11 @@ export const he = {
|
|||
closeToTrayDescription:
|
||||
"השאר את Unsloth והשרת שלו פועלים ברקע בעת סגירת החלון הראשי.",
|
||||
closeToTraySaveError: "עדכון הגדרת הסגירה למגש המערכת נכשל.",
|
||||
|
||||
menuBarIcon: "הצג בשורת התפריטים",
|
||||
menuBarIconDescription:
|
||||
"הצג את סמל Unsloth בשורת התפריטים של macOS. כשהוא מוסתר, Unsloth נשאר ב-Dock במקום זאת, גם כשהוא מופעל בכניסה למערכת.",
|
||||
menuBarIconSaveError: "עדכון הגדרת סמל שורת התפריטים נכשל.",
|
||||
loadError: "טעינת הגדרת ההפעלה בכניסה למערכת נכשלה.",
|
||||
saveError: "עדכון הגדרת ההפעלה בכניסה למערכת נכשל.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1840,6 +1840,11 @@ export const hi = {
|
|||
closeToTrayDescription:
|
||||
"मुख्य विंडो बंद करने पर Unsloth और उसके सर्वर को बैकग्राउंड में चलते रहने दें।",
|
||||
closeToTraySaveError: "सिस्टम ट्रे में बंद करने की सेटिंग अपडेट नहीं हो सकी।",
|
||||
|
||||
menuBarIcon: "मेनू बार में दिखाएं",
|
||||
menuBarIconDescription:
|
||||
"macOS मेनू बार में Unsloth आइकन दिखाएं। छिपाने पर, Unsloth इसके बजाय Dock में बना रहता है, यहां तक कि लॉगिन पर शुरू होने पर भी।",
|
||||
menuBarIconSaveError: "मेनू बार आइकन सेटिंग अपडेट नहीं हो सकी।",
|
||||
loadError: "लॉगिन पर चलाने की सेटिंग लोड नहीं हो सकी।",
|
||||
saveError: "लॉगिन पर चलाने की सेटिंग अपडेट नहीं हो सकी।",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -1824,6 +1824,11 @@ export const ja = {
|
|||
closeToTrayDescription:
|
||||
"メインウィンドウを閉じても、Unsloth とサーバーをバックグラウンドで実行し続けます。",
|
||||
closeToTraySaveError: "システムトレイに格納する設定を更新できませんでした。",
|
||||
|
||||
menuBarIcon: "メニューバーに表示",
|
||||
menuBarIconDescription:
|
||||
"macOS のメニューバーに Unsloth のアイコンを表示します。非表示にすると、ログイン時に起動した場合を含め、Unsloth は Dock に残ります。",
|
||||
menuBarIconSaveError: "メニューバーアイコンの設定を更新できませんでした。",
|
||||
loadError: "ログイン時起動の設定を読み込めませんでした。",
|
||||
saveError: "ログイン時起動の設定を更新できませんでした。",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1833,6 +1833,11 @@ export const ko = {
|
|||
closeToTrayDescription:
|
||||
"기본 창을 닫아도 Unsloth와 서버가 백그라운드에서 계속 실행되도록 합니다.",
|
||||
closeToTraySaveError: "시스템 트레이로 닫기 설정을 업데이트하지 못했습니다.",
|
||||
|
||||
menuBarIcon: "메뉴 막대에 표시",
|
||||
menuBarIconDescription:
|
||||
"macOS 메뉴 막대에 Unsloth 아이콘을 표시합니다. 숨기면 로그인 시 시작된 경우를 포함해 Unsloth는 Dock에 남아 있습니다.",
|
||||
menuBarIconSaveError: "메뉴 막대 아이콘 설정을 업데이트하지 못했습니다.",
|
||||
loadError: "로그인 시 실행 설정을 불러오지 못했습니다.",
|
||||
saveError: "로그인 시 실행 설정을 업데이트하지 못했습니다.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1843,6 +1843,12 @@ export const ru = {
|
|||
"Оставлять Unsloth и его сервер работающими в фоне при закрытии главного окна.",
|
||||
closeToTraySaveError:
|
||||
"Не удалось обновить настройку закрытия в системный трей.",
|
||||
|
||||
menuBarIcon: "Показывать в строке меню",
|
||||
menuBarIconDescription:
|
||||
"Показывать значок Unsloth в строке меню macOS. Если он скрыт, Unsloth остаётся в Dock, в том числе при запуске при входе в систему.",
|
||||
menuBarIconSaveError:
|
||||
"Не удалось обновить настройку значка в строке меню.",
|
||||
loadError: "Не удалось загрузить настройку автозапуска.",
|
||||
saveError: "Не удалось обновить настройку автозапуска.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1817,6 +1817,11 @@ export const zhCN = {
|
|||
closeToTrayDescription:
|
||||
"关闭主窗口时,让 Unsloth 及其服务器继续在后台运行。",
|
||||
closeToTraySaveError: "无法更新关闭到系统托盘设置。",
|
||||
|
||||
menuBarIcon: "在菜单栏中显示",
|
||||
menuBarIconDescription:
|
||||
"在 macOS 菜单栏中显示 Unsloth 图标。隐藏后,Unsloth 会保留在程序坞(Dock)中,包括在登录时启动的情况。",
|
||||
menuBarIconSaveError: "无法更新菜单栏图标设置。",
|
||||
loadError: "无法加载登录时启动设置。",
|
||||
saveError: "无法更新登录时启动设置。",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue