fix: surface desktop menu update feedback
This commit is contained in:
@@ -796,7 +796,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces checking/available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### Frontend helpers
|
||||
|
||||
@@ -214,7 +214,7 @@ The main Tauri window derives its minimum width from the visible panes instead o
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that macOS uses for native menu clicks.
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable, and on Wayland sessions it re-execs once with the first available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
@@ -410,6 +410,7 @@ files:
|
||||
inspector.info.words: 6f15b8d4b7287d60a8ea3d1c5cbadc84
|
||||
inspector.info.size: 6f6cb72d544962fa333e2e34ce64f719
|
||||
update.available: 992477975168b876be8e77ce2975e390
|
||||
update.checking: a07813cc05571f23ce8dd7039583d7da
|
||||
update.releaseNotes: 5dd03e8d039863e563e049be198c3fd3
|
||||
update.updateNow: 99b1054c0f320be9f109c877a5efd0b2
|
||||
update.dismiss: c8a59e7135a20b362f9c768b09454fdb
|
||||
|
||||
@@ -136,6 +136,10 @@ const GIT_NO_REMOTE_DEPENDENT_IDS: &[&str] = &[VAULT_ADD_REMOTE];
|
||||
|
||||
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
|
||||
|
||||
fn app_menu_includes_services(target_os: &str) -> bool {
|
||||
target_os == "macos"
|
||||
}
|
||||
|
||||
fn build_app_menu(app: &App) -> MenuResult {
|
||||
let settings_item = MenuItemBuilder::new("Settings...")
|
||||
.id(APP_SETTINGS)
|
||||
@@ -145,21 +149,25 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Tolaria")
|
||||
let mut builder = SubmenuBuilder::new(app, "Tolaria")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
.separator()
|
||||
.item(&settings_item)
|
||||
.separator()
|
||||
.services()
|
||||
.separator()
|
||||
.hide()
|
||||
.hide_others()
|
||||
.show_all()
|
||||
.separator()
|
||||
.quit()
|
||||
.build()?)
|
||||
.separator();
|
||||
|
||||
if app_menu_includes_services(std::env::consts::OS) {
|
||||
builder = builder
|
||||
.services()
|
||||
.separator()
|
||||
.hide()
|
||||
.hide_others()
|
||||
.show_all()
|
||||
.separator();
|
||||
}
|
||||
|
||||
Ok(builder.quit().build()?)
|
||||
}
|
||||
|
||||
fn build_file_menu(app: &App) -> MenuResult {
|
||||
@@ -628,4 +636,11 @@ mod tests {
|
||||
assert!(seen.insert(id), "duplicate custom ID: {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_services_menu_is_macos_only() {
|
||||
assert!(app_menu_includes_services("macos"));
|
||||
assert!(!app_menu_includes_services("windows"));
|
||||
assert!(!app_menu_includes_services("linux"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ describe('UpdateBanner', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('shows immediate feedback while checking for updates', () => {
|
||||
renderBanner({ state: 'checking' })
|
||||
|
||||
expect(screen.getByTestId('update-banner')).toHaveTextContent('Checking for updates')
|
||||
})
|
||||
|
||||
it('shows version and action buttons when update is available', () => {
|
||||
renderBanner(makeAvailableStatus({
|
||||
version: '2026.4.16-alpha.3',
|
||||
|
||||
@@ -105,6 +105,15 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
|
||||
)
|
||||
}
|
||||
|
||||
function renderCheckingContent(locale: AppLocale) {
|
||||
return (
|
||||
<>
|
||||
<RefreshCw size={14} style={{ ...iconStyle, animation: 'spin 1s linear infinite' }} />
|
||||
<span>{translate(locale, 'update.checking')}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state: 'downloading' }>, locale: AppLocale) {
|
||||
return (
|
||||
<>
|
||||
@@ -153,6 +162,8 @@ function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready
|
||||
|
||||
function renderBannerContent(status: VisibleUpdateStatus, actions: UpdateActions, locale: AppLocale) {
|
||||
switch (status.state) {
|
||||
case 'checking':
|
||||
return renderCheckingContent(locale)
|
||||
case 'available':
|
||||
return renderAvailableContent(status, actions, locale)
|
||||
case 'downloading':
|
||||
|
||||
@@ -175,6 +175,36 @@ describe('useUpdater', () => {
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('shows checking state while a manual update check is in flight', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
let resolveCheck: (value: AppUpdateMetadata | null) => void = () => {}
|
||||
mockInvoke.mockImplementation((command: string) => {
|
||||
if (command === 'check_for_app_update') {
|
||||
return new Promise<AppUpdateMetadata | null>((resolve) => {
|
||||
resolveCheck = resolve
|
||||
})
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderUpdater('stable')
|
||||
|
||||
let checkPromise: Promise<unknown> | null = null
|
||||
await act(async () => {
|
||||
checkPromise = result.current.actions.checkForUpdates()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'checking' })
|
||||
|
||||
await act(async () => {
|
||||
resolveCheck(null)
|
||||
expect(await checkPromise).toEqual({ kind: 'up-to-date' })
|
||||
})
|
||||
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('returns available and sets status when an update exists', async () => {
|
||||
const { result, outcome } = await performManualCheck(
|
||||
'stable',
|
||||
@@ -219,7 +249,7 @@ describe('useUpdater', () => {
|
||||
message: 'Could not check for updates: network error',
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
expect(result.current.status).toEqual({ state: 'error' })
|
||||
})
|
||||
|
||||
it('dismiss resets the banner state', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface UpdateVersionInfo {
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: 'idle' }
|
||||
| { state: 'checking' }
|
||||
| ({ state: 'available'; notes: string | undefined } & UpdateVersionInfo)
|
||||
| ({ state: 'downloading'; progress: number } & UpdateVersionInfo)
|
||||
| ({ state: 'ready' } & UpdateVersionInfo)
|
||||
@@ -111,6 +112,8 @@ export function useUpdater(
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return { kind: 'up-to-date' }
|
||||
|
||||
setStatus({ state: 'checking' })
|
||||
|
||||
try {
|
||||
const update = await checkForAppUpdate(releaseChannel)
|
||||
if (!update) {
|
||||
@@ -125,6 +128,7 @@ export function useUpdater(
|
||||
return { kind: 'available', ...versionInfo }
|
||||
} catch (error) {
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
setStatus({ state: 'error' })
|
||||
return { kind: 'error', message: buildUpdateCheckErrorMessage(error) }
|
||||
}
|
||||
}, [releaseChannel])
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Wörter",
|
||||
"inspector.info.size": "Größe",
|
||||
"update.available": "ist verfügbar",
|
||||
"update.checking": "Nach Updates suchen …",
|
||||
"update.releaseNotes": "Versionshinweise",
|
||||
"update.updateNow": "Jetzt aktualisieren",
|
||||
"update.dismiss": "Ausblenden",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Words",
|
||||
"inspector.info.size": "Size",
|
||||
"update.available": "is available",
|
||||
"update.checking": "Checking for updates...",
|
||||
"update.releaseNotes": "Release Notes",
|
||||
"update.updateNow": "Update Now",
|
||||
"update.dismiss": "Dismiss",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Palabras",
|
||||
"inspector.info.size": "Tamaño",
|
||||
"update.available": "está disponible",
|
||||
"update.checking": "Buscando actualizaciones...",
|
||||
"update.releaseNotes": "Notas de la versión",
|
||||
"update.updateNow": "Actualizar ahora",
|
||||
"update.dismiss": "Omitir",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Palabras",
|
||||
"inspector.info.size": "Tamaño",
|
||||
"update.available": "está disponible",
|
||||
"update.checking": "Buscando actualizaciones...",
|
||||
"update.releaseNotes": "Notas de la versión",
|
||||
"update.updateNow": "Actualizar ahora",
|
||||
"update.dismiss": "Omitir",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Mots",
|
||||
"inspector.info.size": "Taille",
|
||||
"update.available": "est disponible",
|
||||
"update.checking": "Recherche de mises à jour en cours…",
|
||||
"update.releaseNotes": "Notes de version",
|
||||
"update.updateNow": "Mettre à jour maintenant",
|
||||
"update.dismiss": "Ignorer",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Parole",
|
||||
"inspector.info.size": "Dimensione",
|
||||
"update.available": "è disponibile",
|
||||
"update.checking": "Verifica degli aggiornamenti in corso...",
|
||||
"update.releaseNotes": "Note sulla versione",
|
||||
"update.updateNow": "Aggiorna ora",
|
||||
"update.dismiss": "Ignora",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "単語数",
|
||||
"inspector.info.size": "サイズ",
|
||||
"update.available": "利用可能",
|
||||
"update.checking": "更新を確認しています...",
|
||||
"update.releaseNotes": "リリースノート",
|
||||
"update.updateNow": "今すぐ更新",
|
||||
"update.dismiss": "却下",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "단어 수",
|
||||
"inspector.info.size": "크기",
|
||||
"update.available": "사용 가능",
|
||||
"update.checking": "업데이트 확인 중...",
|
||||
"update.releaseNotes": "릴리스 노트",
|
||||
"update.updateNow": "지금 업데이트",
|
||||
"update.dismiss": "닫기",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Palavras",
|
||||
"inspector.info.size": "Tamanho",
|
||||
"update.available": "está disponível",
|
||||
"update.checking": "Verificando atualizações...",
|
||||
"update.releaseNotes": "Notas da versão",
|
||||
"update.updateNow": "Atualizar agora",
|
||||
"update.dismiss": "Ignorar",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Palavras",
|
||||
"inspector.info.size": "Tamanho",
|
||||
"update.available": "está disponível",
|
||||
"update.checking": "A procurar atualizações...",
|
||||
"update.releaseNotes": "Notas de lançamento",
|
||||
"update.updateNow": "Atualizar agora",
|
||||
"update.dismiss": "Ignorar",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "Слов",
|
||||
"inspector.info.size": "Размер",
|
||||
"update.available": "доступно",
|
||||
"update.checking": "Проверка наличия обновлений...",
|
||||
"update.releaseNotes": "Примечания к выпуску",
|
||||
"update.updateNow": "Обновить сейчас",
|
||||
"update.dismiss": "Закрыть",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "字数",
|
||||
"inspector.info.size": "大小",
|
||||
"update.available": "可用",
|
||||
"update.checking": "正在检查更新……",
|
||||
"update.releaseNotes": "发行说明",
|
||||
"update.updateNow": "立即更新",
|
||||
"update.dismiss": "关闭",
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"inspector.info.words": "字數",
|
||||
"inspector.info.size": "大小",
|
||||
"update.available": "可用",
|
||||
"update.checking": "正在檢查更新……",
|
||||
"update.releaseNotes": "發行說明",
|
||||
"update.updateNow": "立即更新",
|
||||
"update.dismiss": "關閉",
|
||||
|
||||
Reference in New Issue
Block a user