diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0a2eff..dd9f95f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ Versions follow [Semantic Versioning](https://semver.org/). ## Unreleased +### Features +- **Settings shows the installed version.** On Windows and Linux the desktop app showed its version nowhere. Settings now ends with it, and it can be copied into a bug report. + ### Bug Fixes - **Security: a server that offers a host key of another type than the one saved for it is refused.** OmnySSH compared the offered key only with saved keys of the same type. A host saved with its Ed25519 key that suddenly offered only an RSA or ECDSA key was therefore taken for a new host: the key was saved and the login went ahead, password included, and the dashboard did this in the background without anyone connecting. Someone intercepting the connection could get past the saved key this way. Like `ssh`, OmnySSH now refuses any key that matches none of those saved for the host, whatever its type, and says which type is saved. A server that really dropped the type of key it was saved with needs its old key removed once, as `ssh` also asks. - **A changed host key names its type and, on Windows, whose list held the old one.** OmnySSH 1.1.3 and earlier kept host keys in `%USERPROFILE%\ssh\known_hosts`, a list that PuTTY and OpenSSH never read. When a server was reinstalled, or a new one took over its address, OmnySSH refused it while PuTTY and PowerShell's `ssh` connected, and nothing said why they disagree. A refusal from that file now says it is the list OmnySSH kept up to 1.1.3. Every refusal also names the type of the key the server offered, ED25519, ECDSA or RSA, and the command that prints that key's fingerprint on the server: a server has one key of each type, and comparing the wrong one shows a mismatch that is not there. @@ -18,6 +21,7 @@ Versions follow [Semantic Versioning](https://semver.org/). - **A server with no algorithm in common says which kind is missing and what it offers.** The error read "No common algorithm", and a key exchange mismatch even "Unknown algorithm", without saying whether the key exchange, host key, cipher or MAC was the problem, so there was nothing to go on. It now reads, for example, "no common cipher; the server offers aes128-cbc". Servers that offer only methods OmnySSH leaves out on purpose, such as SHA-1 Diffie-Hellman or CBC ciphers, still do not connect. - **macOS: a host on your local network that fails with "No route to host" says macOS may be blocking it.** macOS keeps an app away from devices on the local network until you allow it, and a blocked connection fails at once with "No route to host (os error 65)", while the router usually stays reachable because it is also your DNS server, so it looked like a fault on that one machine. The error now adds that macOS may be blocking the app and where to allow it: System Settings, Privacy & Security, Local Network, then reopen the app. The desktop app also now carries the explanation macOS shows when it asks for that permission. The terminal app needs the permission for the terminal it runs in; Apple's Terminal has it already. - **A terminal the server closes right after login stays open and shows why.** A Synology NAS gives a shell only to administrators: any other account logs in, gets DSM's "Permission denied, please try again." and the session ends. `ssh` shows that message, but OmnySSH closed the tab the moment the session ended, so the terminal seemed to vanish right after the password while file sessions to the same NAS worked. In both apps, a terminal tab that showed anything now stays open when the remote side ends its session, including after `exit` or a dropped connection, with "[Connection closed. Press Enter to close this tab.]" under the last output. Enter or Esc closes it, and it takes no other input. A terminal that fails before showing anything still closes, with the reason in the status bar. The desktop app could also drop the last lines a session printed just before it ended; they now always arrive. +- **Settings keeps its bottom margin when scrolled to the end.** The last section sat flush against the bottom of the window. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f86dfda4..fbd7a1a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -258,7 +258,7 @@ chore: bump ratatui to 0.29 Please open a GitHub Issue with: -- OmnySSH version (`omny --version`) +- OmnySSH version (`omny --version`, or Settings in the desktop app) - OS and terminal emulator - Steps to reproduce - Expected behaviour vs. actual behaviour diff --git a/crates/omnyssh-gui/ui/e2e/settings.spec.ts b/crates/omnyssh-gui/ui/e2e/settings.spec.ts index 80da25ab..25697a06 100644 --- a/crates/omnyssh-gui/ui/e2e/settings.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/settings.spec.ts @@ -4,7 +4,7 @@ import { expect, test, type Page } from '@playwright/test'; // absent, so we stub `__TAURI_INTERNALS__` at the boundary (§6.4). The stub backs the // update config in memory and returns an update from `check_update`; `update-available` // is fired after `reload_hosts` (which the layout calls once its listeners are attached), -// mirroring the startup check. +// mirroring the startup check. `plugin:app|version` is Tauri's `getVersion`. const HOSTS = [ { name: 'web-1', hostname: 'web-1.example.com', user: 'deploy', port: 22, tags: [], source: 'manual', hasKey: true, localForwards: [], tunnelAutostart: false, forwardAgent: false } ]; @@ -16,15 +16,18 @@ const UPDATE = { canSelfUpdate: true }; +const INSTALLED = '1.0.0'; + async function boot( page: Page, opts: { fireUpdateOnBoot: boolean; traySupport?: { available: boolean; minimize: boolean }; + version?: 'fails' | 'hangs'; } ): Promise { await page.addInitScript( - ({ hosts, update, fireUpdateOnBoot, traySupport }) => { + ({ hosts, update, installed, fireUpdateOnBoot, traySupport, version }) => { let cbid = 0; const listeners: Record = {}; const state = { @@ -61,6 +64,10 @@ async function boot( return Promise.resolve(null); case 'check_update': return Promise.resolve({ ...update }); + case 'plugin:app|version': + if (version === 'fails') return Promise.reject('no Tauri runtime'); + if (version === 'hangs') return new Promise(() => {}); + return Promise.resolve(installed); case 'set_tray_behavior': ((win.__tray ??= []) as unknown[]).push({ ...args }); return Promise.resolve({ ...traySupport }); @@ -83,8 +90,10 @@ async function boot( { hosts: HOSTS, update: UPDATE, + installed: INSTALLED, fireUpdateOnBoot: opts.fireUpdateOnBoot, - traySupport: opts.traySupport ?? { available: true, minimize: true } + traySupport: opts.traySupport ?? { available: true, minimize: true }, + version: opts.version } ); await page.goto('/'); @@ -121,6 +130,51 @@ test('the footer gear opens Settings; theme, interval, and update prefs work', a await expect(page.getByText('Update available — v2.0.0')).toBeVisible(); }); +test('About shows the installed version, selectable for a bug report', async ({ page }) => { + await boot(page, { fireUpdateOnBoot: false }); + await page.getByRole('button', { name: 'Settings' }).click(); + + const about = page.getByRole('heading', { name: 'About' }).locator('..'); + await expect(about.getByText('Version', { exact: true })).toBeVisible(); + const version = about.getByText(INSTALLED, { exact: true }); + await expect(version).toBeVisible(); + await expect(version).toHaveCSS('user-select', 'text'); +}); + +for (const version of ['fails', 'hangs'] as const) { + test(`a version read that ${version} leaves no row, and the update prefs still load`, async ({ + page + }) => { + await boot(page, { fireUpdateOnBoot: false, version }); + await page.getByRole('button', { name: 'Settings' }).click(); + + await expect( + page.getByRole('switch', { name: 'Check for updates on startup' }) + ).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('heading', { name: 'About' })).toHaveCount(0); + await expect(page.getByText('Version', { exact: true })).toHaveCount(0); + }); +} + +test('scrolled to the end, Settings keeps its bottom padding', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 480 }); + await boot(page, { fireUpdateOnBoot: false }); + await page.getByRole('button', { name: 'Settings' }).click(); + + const last = page.getByRole('heading', { name: 'About' }).locator('..'); + await expect(last).toBeVisible(); + const { overflows, gap } = await last.evaluate((card) => { + const scroller = card.closest('.overflow-auto') as HTMLElement; + scroller.scrollTop = scroller.scrollHeight; + return { + overflows: scroller.scrollHeight > scroller.clientHeight, + gap: scroller.getBoundingClientRect().bottom - card.getBoundingClientRect().bottom + }; + }); + expect(overflows).toBe(true); + expect(gap).toBeGreaterThanOrEqual(24); +}); + test('startup update-available raises the banner; dismiss hides it', async ({ page }) => { await boot(page, { fireUpdateOnBoot: true }); diff --git a/crates/omnyssh-gui/ui/src/lib/ipc/appInfo.ts b/crates/omnyssh-gui/ui/src/lib/ipc/appInfo.ts new file mode 100644 index 00000000..c460ad9f --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/ipc/appInfo.ts @@ -0,0 +1,4 @@ +import { getVersion } from '@tauri-apps/api/app'; + +// The version baked into the binary: the workspace one, as `omny --version` prints it. +export const installedVersion = (): Promise => getVersion(); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte index bc2e198a..b7cb61fd 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte @@ -14,11 +14,13 @@ import { offerUpdate } from '$lib/stores/update'; import { lastError } from '$lib/stores/notifications'; import { checkUpdate, loadUpdateConfig, saveUpdateConfig } from '$lib/ipc/commands'; + import { installedVersion } from '$lib/ipc/appInfo'; const message = (e: unknown): string => (e instanceof Error ? e.message : String(e)); const formatInterval = (secs: number): string => (secs < 60 ? `${secs}s` : `${secs / 60}m`); let updateConfig = $state(null); + let version = $state(null); type CheckState = | { kind: 'idle' } | { kind: 'checking' } @@ -28,6 +30,8 @@ let check = $state({ kind: 'idle' }); onMount(async () => { + // Not awaited, so the update prefs never wait on it; a failed read leaves the row out. + installedVersion().then((v) => (version = v), () => {}); try { updateConfig = await loadUpdateConfig(); } catch (e) { @@ -76,7 +80,7 @@ active ? 'bg-accent text-accent-fg' : 'text-muted hover:bg-surface-inset hover:text-fg'; -
+

Settings

@@ -250,6 +254,17 @@
+ + + {#if version} + +

About

+
+

Version

+

{version}

+
+
+ {/if}