From 5f6f16c800028850d989410805566f38d71d5218 Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 18:25:44 -0300 Subject: [PATCH 1/8] ui: localize the property sheet buttons The wizard and the profile editor are comctl32 property sheets, so their own buttons (OK, Cancel, Back, Next, Finish) come from the comctl32 resources, in the language of Windows rather than ours. Wire PSH_USECALLBACK in both sheets and give the buttons the catalog texts on PSCB_INITIALIZED through the new ui_win_sheet_callback, with the five new SD_MSG_UI_SHEET_* strings in every locale. --- apps/shelldrive-ui/ui_editor.c | 4 +++- apps/shelldrive-ui/ui_win.c | 20 ++++++++++++++++++++ apps/shelldrive-ui/ui_win.h | 3 +++ apps/shelldrive-ui/ui_wizard.c | 4 +++- l10n/ui/de.po | 30 ++++++++++++++++++++++++++++++ l10n/ui/en.po | 30 ++++++++++++++++++++++++++++++ l10n/ui/es.po | 30 ++++++++++++++++++++++++++++++ l10n/ui/fr.po | 30 ++++++++++++++++++++++++++++++ l10n/ui/it.po | 30 ++++++++++++++++++++++++++++++ l10n/ui/pt-BR.po | 30 ++++++++++++++++++++++++++++++ 10 files changed, 209 insertions(+), 2 deletions(-) diff --git a/apps/shelldrive-ui/ui_editor.c b/apps/shelldrive-ui/ui_editor.c index e3896cd..b9fad5d 100644 --- a/apps/shelldrive-ui/ui_editor.c +++ b/apps/shelldrive-ui/ui_editor.c @@ -30,6 +30,7 @@ #include "ui_int.h" #include "ui_measure.h" #include "ui_profile_model.h" +#include "ui_win.h" #define ED_WIDTH 300 /* dialog units; every page of the sheet has this width */ #define ED_PAGES 5 @@ -823,7 +824,8 @@ int ui_editor_run(HWND owner, ui_edit_mode mode, sd_profile *p) } memset(&hdr, 0, sizeof(hdr)); hdr.dwSize = sizeof(hdr); - hdr.dwFlags = PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP; + hdr.dwFlags = PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP | PSH_USECALLBACK; + hdr.pfnCallback = ui_win_sheet_callback; hdr.hwndParent = owner; hdr.hInstance = g_app.inst; hdr.pszCaption = sd_l10n_get(mode == UI_EDIT_NEW ? SD_MSG_UI_EDITOR_CAPTION_NEW diff --git a/apps/shelldrive-ui/ui_win.c b/apps/shelldrive-ui/ui_win.c index a8e5eed..668d0e5 100644 --- a/apps/shelldrive-ui/ui_win.c +++ b/apps/shelldrive-ui/ui_win.c @@ -3,6 +3,7 @@ * Fonts follow the message font scaled to the window DPI (7.3), as the main window does. Windows are created * on demand and destroyed when closed; the process keeps none of them alive. */ #include +#include #include #include @@ -149,6 +150,25 @@ void ui_win_apply_font(HWND wnd, HFONT font) EnumChildWindows(wnd, set_font_cb, (LPARAM)font); } +/* Control ids of the property sheet buttons (comctl32; the SDK headers do not name them). */ +#define SHEET_ID_BACK 0x3023 +#define SHEET_ID_NEXT 0x3024 +#define SHEET_ID_FINISH 0x3025 + +int CALLBACK ui_win_sheet_callback(HWND sheet, UINT msg, LPARAM lp) +{ + (void)lp; + if (msg == PSCB_INITIALIZED) { + /* a missing button (Back and Next on a plain sheet, OK on a wizard) is skipped by SetDlgItemTextW */ + SetDlgItemTextW(sheet, IDOK, sd_l10n_get(SD_MSG_UI_SHEET_OK)); + SetDlgItemTextW(sheet, IDCANCEL, sd_l10n_get(SD_MSG_UI_SHEET_CANCEL)); + SetDlgItemTextW(sheet, SHEET_ID_BACK, sd_l10n_get(SD_MSG_UI_SHEET_BACK)); + SetDlgItemTextW(sheet, SHEET_ID_NEXT, sd_l10n_get(SD_MSG_UI_SHEET_NEXT)); + SetDlgItemTextW(sheet, SHEET_ID_FINISH, sd_l10n_get(SD_MSG_UI_SHEET_FINISH)); + } + return 0; +} + /* ---- commands and glue ---- */ void ui_windows_command(UINT cmd) diff --git a/apps/shelldrive-ui/ui_win.h b/apps/shelldrive-ui/ui_win.h index 112387f..2270834 100644 --- a/apps/shelldrive-ui/ui_win.h +++ b/apps/shelldrive-ui/ui_win.h @@ -50,6 +50,9 @@ void ui_win_error(HWND owner, sd_msg_id msg, sd_err err); int ui_win_copy_text(HWND owner, const wchar_t *text, size_t len); /* Sets the font of every child of wnd. */ void ui_win_apply_font(HWND wnd, HFONT font); +/* PROPSHEETHEADER pfnCallback (with PSH_USECALLBACK): gives the sheet's own buttons (OK, Cancel, Back, Next, + Finish) the catalog texts. comctl32 takes them from its resources, in the language of Windows, not ours. */ +int CALLBACK ui_win_sheet_callback(HWND sheet, UINT msg, LPARAM lp); /* ---- ui_win.c: glue used by the main window and the control window ---- */ void ui_windows_command(UINT cmd); diff --git a/apps/shelldrive-ui/ui_wizard.c b/apps/shelldrive-ui/ui_wizard.c index e46c5fb..decc61a 100644 --- a/apps/shelldrive-ui/ui_wizard.c +++ b/apps/shelldrive-ui/ui_wizard.c @@ -20,6 +20,7 @@ #include "ui_dlg.h" #include "ui_int.h" #include "ui_measure.h" +#include "ui_win.h" #include "ui_wizard.h" #include "ui_wizard_model.h" @@ -432,7 +433,8 @@ void ui_wizard_run(HWND owner) } memset(&hdr, 0, sizeof(hdr)); hdr.dwSize = sizeof(hdr); - hdr.dwFlags = PSH_WIZARD97 | PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP; + hdr.dwFlags = PSH_WIZARD97 | PSH_PROPSHEETPAGE | PSH_NOAPPLYNOW | PSH_NOCONTEXTHELP | PSH_USECALLBACK; + hdr.pfnCallback = ui_win_sheet_callback; hdr.hwndParent = owner; hdr.hInstance = g_app.inst; hdr.pszCaption = sd_l10n_get(SD_MSG_UI_WIZARD_CAPTION); diff --git a/l10n/ui/de.po b/l10n/ui/de.po index 87cfe79..44c0e6f 100644 --- a/l10n/ui/de.po +++ b/l10n/ui/de.po @@ -1612,6 +1612,36 @@ msgid "Help" msgstr "Hilfe" +#. Property sheets (wizard, profile editor): the OK button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.ok" +msgid "OK" +msgstr "OK" + +#. Property sheets (wizard, profile editor): the Cancel button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.cancel" +msgid "Cancel" +msgstr "Abbrechen" + +#. First-run wizard: the Back button. Mnemonic B. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.back" +msgid "< &Back" +msgstr "< &Zurück" + +#. First-run wizard: the Next button. Mnemonic N. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.next" +msgid "&Next >" +msgstr "&Weiter >" + +#. First-run wizard: the Finish button on the last page. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.finish" +msgid "Finish" +msgstr "Fertig stellen" + #. First-run wizard: the title of the sheet. msgctxt "ui.wizard.caption" msgid "Set up ShellDrive" diff --git a/l10n/ui/en.po b/l10n/ui/en.po index 35fe289..3d46f2b 100644 --- a/l10n/ui/en.po +++ b/l10n/ui/en.po @@ -1612,6 +1612,36 @@ msgid "Help" msgstr "" +#. Property sheets (wizard, profile editor): the OK button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.ok" +msgid "OK" +msgstr "" + +#. Property sheets (wizard, profile editor): the Cancel button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.cancel" +msgid "Cancel" +msgstr "" + +#. First-run wizard: the Back button. Mnemonic B. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.back" +msgid "< &Back" +msgstr "" + +#. First-run wizard: the Next button. Mnemonic N. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.next" +msgid "&Next >" +msgstr "" + +#. First-run wizard: the Finish button on the last page. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.finish" +msgid "Finish" +msgstr "" + #. First-run wizard: the title of the sheet. msgctxt "ui.wizard.caption" msgid "Set up ShellDrive" diff --git a/l10n/ui/es.po b/l10n/ui/es.po index b0e25af..d2c7357 100644 --- a/l10n/ui/es.po +++ b/l10n/ui/es.po @@ -1621,6 +1621,36 @@ msgid "Help" msgstr "Ayuda" +#. Property sheets (wizard, profile editor): the OK button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.ok" +msgid "OK" +msgstr "Aceptar" + +#. Property sheets (wizard, profile editor): the Cancel button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.cancel" +msgid "Cancel" +msgstr "Cancelar" + +#. First-run wizard: the Back button. Mnemonic B. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.back" +msgid "< &Back" +msgstr "< &Atrás" + +#. First-run wizard: the Next button. Mnemonic N. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.next" +msgid "&Next >" +msgstr "&Siguiente >" + +#. First-run wizard: the Finish button on the last page. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.finish" +msgid "Finish" +msgstr "Finalizar" + #. First-run wizard: the title of the sheet. msgctxt "ui.wizard.caption" msgid "Set up ShellDrive" diff --git a/l10n/ui/fr.po b/l10n/ui/fr.po index 9bcc274..8cf9293 100644 --- a/l10n/ui/fr.po +++ b/l10n/ui/fr.po @@ -1621,6 +1621,36 @@ msgid "Help" msgstr "Aide" +#. Property sheets (wizard, profile editor): the OK button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.ok" +msgid "OK" +msgstr "OK" + +#. Property sheets (wizard, profile editor): the Cancel button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.cancel" +msgid "Cancel" +msgstr "Annuler" + +#. First-run wizard: the Back button. Mnemonic B. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.back" +msgid "< &Back" +msgstr "< &Précédent" + +#. First-run wizard: the Next button. Mnemonic N. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.next" +msgid "&Next >" +msgstr "&Suivant >" + +#. First-run wizard: the Finish button on the last page. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.finish" +msgid "Finish" +msgstr "Terminer" + #. First-run wizard: the title of the sheet. msgctxt "ui.wizard.caption" msgid "Set up ShellDrive" diff --git a/l10n/ui/it.po b/l10n/ui/it.po index 1aabebf..0f87270 100644 --- a/l10n/ui/it.po +++ b/l10n/ui/it.po @@ -1621,6 +1621,36 @@ msgid "Help" msgstr "Aiuto" +#. Property sheets (wizard, profile editor): the OK button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.ok" +msgid "OK" +msgstr "OK" + +#. Property sheets (wizard, profile editor): the Cancel button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.cancel" +msgid "Cancel" +msgstr "Annulla" + +#. First-run wizard: the Back button. Mnemonic B. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.back" +msgid "< &Back" +msgstr "< &Indietro" + +#. First-run wizard: the Next button. Mnemonic N. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.next" +msgid "&Next >" +msgstr "&Avanti >" + +#. First-run wizard: the Finish button on the last page. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.finish" +msgid "Finish" +msgstr "Fine" + #. First-run wizard: the title of the sheet. msgctxt "ui.wizard.caption" msgid "Set up ShellDrive" diff --git a/l10n/ui/pt-BR.po b/l10n/ui/pt-BR.po index f769fe6..1ab1d26 100644 --- a/l10n/ui/pt-BR.po +++ b/l10n/ui/pt-BR.po @@ -1621,6 +1621,36 @@ msgid "Help" msgstr "Ajuda" +#. Property sheets (wizard, profile editor): the OK button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.ok" +msgid "OK" +msgstr "OK" + +#. Property sheets (wizard, profile editor): the Cancel button. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.cancel" +msgid "Cancel" +msgstr "Cancelar" + +#. First-run wizard: the Back button. Mnemonic B. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.back" +msgid "< &Back" +msgstr "< &Voltar" + +#. First-run wizard: the Next button. Mnemonic N. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.next" +msgid "&Next >" +msgstr "&Avançar >" + +#. First-run wizard: the Finish button on the last page. +#. sdl10n:maxlen=32 +msgctxt "ui.sheet.finish" +msgid "Finish" +msgstr "Concluir" + #. First-run wizard: the title of the sheet. msgctxt "ui.wizard.caption" msgid "Set up ShellDrive" From 3f951004d47691cd52ddbcdee6fac1d346835a09 Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 18:39:29 -0300 Subject: [PATCH 2/8] ui: cap the profile editor to the work area and scroll the pages The sheet is as tall as its tallest page, so on a 1080 px screen at 150 % DPI the Connection page pushed the sheet's own OK and Cancel off the screen. Cap the template height to the work area minus the sheet chrome, and give a page whose content is taller a vertical scroll bar: the children below the fold are moved with ScrollWindowEx, scrolled with the bar, the wheel and Tab, and the error box of a failed apply scrolls into view when the page comes to the front. --- apps/shelldrive-ui/ui_editor.c | 161 ++++++++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 3 deletions(-) diff --git a/apps/shelldrive-ui/ui_editor.c b/apps/shelldrive-ui/ui_editor.c index b9fad5d..db2d793 100644 --- a/apps/shelldrive-ui/ui_editor.c +++ b/apps/shelldrive-ui/ui_editor.c @@ -12,7 +12,11 @@ * * Ad-hoc mount (UI_EDIT_ADHOC): the same pages without the name; the caller sends the connection to the agent * (ui_mount_params_json). A drop-down list grows while it is open (combo_drop): the template height of a combo - * box is its closed height. */ + * box is its closed height. + * + * The sheet is one size, as tall as its tallest page; on a small screen at high DPI (1080 px at 150 %) that + * would push the sheet's own OK and Cancel off the screen, so the page height is capped to the work area and + * a page taller than the cap scrolls (page_scroll_*). */ #include #include #include @@ -27,6 +31,7 @@ #include "sd_l10n_ui_ids.h" #include "sd_policy/sd_policy.h" #include "ui_dlg.h" +#include "ui_dpi.h" #include "ui_int.h" #include "ui_measure.h" #include "ui_profile_model.h" @@ -43,6 +48,7 @@ #define ED_ID_LEGACY 1503 #define ED_ID_WMNOTE 1504 #define ED_ERR_CHARS 1024 /* wide characters of the error text of one page */ +#define ED_SHEET_CHROME 112 /* 96-DPI pixels of the sheet around the page: tab strip, buttons, slack */ enum { PG_GENERAL = 0, PG_AUTH, PG_CONN, PG_MOUNT, PG_SECURITY }; @@ -68,6 +74,8 @@ typedef struct ed_page { ed_ctx *ed; int index; HWND hwnd; /* set by WM_INITDIALOG */ + int scroll; /* px the content is scrolled up; 0 while the page fits its template */ + int scroll_max; /* px of content hidden below the capped template height */ } ed_page; struct ed_ctx { @@ -541,6 +549,91 @@ static void browse_identity(ed_ctx *ed, HWND page) SetWindowTextW(edit, path); } +/* ---- scrolling a page that does not fit the capped sheet ---- */ + +/* The pages of one sheet all get the height of the tallest page (build_pages), capped to the work area. A + page whose content is taller keeps its controls below the fold: they are created and work, and are brought + into view with the page's scroll bar, the mouse wheel, Tab and the error box of a failed apply. The hidden + range is measured from the real controls, so no build-time geometry is carried into the page procedure. */ + +/* Greatest bottom of a child window, in client coordinates of page. */ +static int page_content_bottom(HWND page) +{ + HWND c = GetWindow(page, GW_CHILD); + int bottom = 0; + for (; c != NULL; c = GetWindow(c, GW_HWNDNEXT)) { + RECT rc; + POINT tl, br; + if (!GetWindowRect(c, &rc)) + continue; + tl.x = rc.left; tl.y = rc.top; + br.x = rc.right; br.y = rc.bottom; + ScreenToClient(page, &tl); + ScreenToClient(page, &br); + if (br.y > bottom) + bottom = br.y; + } + return bottom; +} + +/* Scrolls the page so pos is the new offset. ScrollWindowEx moves the children with the content; the closed + height of a combo box travels with it (combo_drop reads the box position back on every open). */ +static void page_scroll_to(ed_page *pg, HWND page, int pos) +{ + SCROLLINFO si; + int old = pg->scroll, dy; + if (pos < 0) + pos = 0; + if (pos > pg->scroll_max) + pos = pg->scroll_max; + pg->scroll = pos; + dy = pg->scroll - old; + if (dy != 0) + ScrollWindowEx(page, 0, dy, NULL, NULL, NULL, NULL, SW_SCROLLCHILDREN | SW_INVALIDATE); + memset(&si, 0, sizeof(si)); + si.cbSize = sizeof(si); + si.fMask = SIF_POS; + si.nPos = pg->scroll; + SetScrollInfo(page, SB_VERT, &si, TRUE); +} + +/* Brings ctl into view; a no-op while the page fits. */ +static void page_scroll_visible(ed_page *pg, HWND page, HWND ctl) +{ + RECT rc, cl; + POINT tl, br; + if (pg->scroll_max <= 0 || ctl == NULL || !GetWindowRect(ctl, &rc) || !GetClientRect(page, &cl)) + return; + tl.x = rc.left; tl.y = rc.top; + br.x = rc.right; br.y = rc.bottom; + ScreenToClient(page, &tl); + ScreenToClient(page, &br); + if (tl.y < 0) + page_scroll_to(pg, page, pg->scroll + tl.y); + else if (br.y > cl.bottom) + page_scroll_to(pg, page, pg->scroll + br.y - cl.bottom); +} + +/* Adds a vertical scroll bar when the content is taller than the page (the capped template height). */ +static void page_scroll_setup(ed_page *pg, HWND page) +{ + RECT cl; + SCROLLINFO si; + int bottom = page_content_bottom(page); + if (!GetClientRect(page, &cl) || bottom <= cl.bottom) + return; + pg->scroll_max = bottom - cl.bottom; + SetWindowLongPtrW(page, GWL_STYLE, GetWindowLongPtrW(page, GWL_STYLE) | WS_VSCROLL); + SetWindowPos(page, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + memset(&si, 0, sizeof(si)); + si.cbSize = sizeof(si); + si.fMask = SIF_RANGE | SIF_PAGE | SIF_POS; + si.nMin = 0; + si.nMax = pg->scroll_max + cl.bottom; + si.nPage = (UINT)cl.bottom; + SetScrollInfo(page, SB_VERT, &si, TRUE); +} + /* ---- the page procedure ---- */ static INT_PTR CALLBACK page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) @@ -554,6 +647,7 @@ static INT_PTR CALLBACK page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) SetWindowLongPtrW(hwnd, DWLP_USER, (LONG_PTR)pg); pg->hwnd = hwnd; write_page(pg->ed, pg->index, hwnd); + page_scroll_setup(pg, hwnd); return TRUE; } case WM_NOTIFY: { @@ -561,6 +655,13 @@ static INT_PTR CALLBACK page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) ed_page *pg = (ed_page *)GetWindowLongPtrW(hwnd, DWLP_USER); if (pg == NULL) break; + if (nm->code == PSN_SETACTIVE) { + /* a page that failed the apply comes to the front; show its error box, which may sit below the fold */ + HWND err = GetDlgItem(hwnd, ED_ID_ERR); + if (err != NULL && GetWindowTextLengthW(err) > 0) + page_scroll_visible(pg, hwnd, err); + break; + } if (nm->code == PSN_KILLACTIVE) { SetWindowLongPtrW(hwnd, DWLP_MSGRESULT, FALSE); return TRUE; @@ -588,12 +689,48 @@ static INT_PTR CALLBACK page_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) browse_identity(pg->ed, hwnd); return TRUE; } + if (code == EN_SETFOCUS || code == CBN_SETFOCUS) { + page_scroll_visible(pg, hwnd, (HWND)lp); + return TRUE; + } if (code == CBN_DROPDOWN || code == CBN_CLOSEUP) { combo_drop((HWND)lp, code == CBN_DROPDOWN); return TRUE; } break; } + case WM_VSCROLL: { + /* the scroll bar of a capped page (page_scroll_setup); a page that fits has none and never gets here */ + ed_page *pg = (ed_page *)GetWindowLongPtrW(hwnd, DWLP_USER); + RECT cl; + int line, page_px; + if (pg == NULL || pg->scroll_max <= 0) + break; + line = GetSystemMetrics(SM_CYVSCROLL); + if (!GetClientRect(hwnd, &cl) || cl.bottom <= 0) + break; + page_px = cl.bottom; + switch (LOWORD(wp)) { + case SB_LINEUP: page_scroll_to(pg, hwnd, pg->scroll - line); break; + case SB_LINEDOWN: page_scroll_to(pg, hwnd, pg->scroll + line); break; + case SB_PAGEUP: page_scroll_to(pg, hwnd, pg->scroll - page_px); break; + case SB_PAGEDOWN: page_scroll_to(pg, hwnd, pg->scroll + page_px); break; + case SB_THUMBPOSITION: + case SB_THUMBTRACK: page_scroll_to(pg, hwnd, (short)HIWORD(wp)); break; + default: break; + } + return TRUE; + } + case WM_MOUSEWHEEL: { + /* DefWindowProc passes the wheel on to the parent when the focused child does not use it */ + ed_page *pg = (ed_page *)GetWindowLongPtrW(hwnd, DWLP_USER); + RECT cl; + int delta = (short)HIWORD(wp); + if (pg == NULL || pg->scroll_max <= 0 || delta == 0 || !GetClientRect(hwnd, &cl) || cl.bottom <= 0) + break; + page_scroll_to(pg, hwnd, pg->scroll - delta / WHEEL_DELTA * 3 * GetSystemMetrics(SM_CYVSCROLL)); + return TRUE; + } default: break; } @@ -703,14 +840,29 @@ static void add_page_security(ui_dlg *d) add_errors(d); } -/* Builds the five page templates. All pages get the height of the tallest one, so the sheet is one size. */ +/* Height of a page template, in dialog units, beyond which the sheet's own buttons would leave the work area: + the work area minus the caption, the frame and the rest of the sheet (tab strip, buttons, slack). 0 when the + work area cannot be read; the pages are then left uncapped. */ +static int page_height_cap(int dpi, const ui_measure *m) +{ + RECT wa; + int chrome; + if (!SystemParametersInfoW(SPI_GETWORKAREA, 0, &wa, 0) || m->base_y <= 0) + return 0; + chrome = GetSystemMetrics(SM_CYCAPTION) + 2 * GetSystemMetrics(SM_CYFIXEDFRAME) + + ui_dpi_scale(ED_SHEET_CHROME, dpi); + return ((wa.bottom - wa.top) - chrome) * 8 / m->base_y; +} + +/* Builds the five page templates. All pages get the height of the tallest one, so the sheet is one size; a + tallest page beyond the work area is capped and the pages that do not fit scroll (page_scroll_setup). */ static sd_err build_pages(ed_ctx *ed, unsigned char *blob[ED_PAGES], size_t len[ED_PAGES]) { static const sd_msg_id titles[ED_PAGES] = { SD_MSG_UI_TAB_GENERAL, SD_MSG_UI_TAB_AUTH, SD_MSG_UI_TAB_CONN, SD_MSG_UI_TAB_MOUNT, SD_MSG_UI_TAB_SECURITY }; wchar_t face[UI_DLG_FACE_MAX]; WORD points = 8; - int dpi = 96, tallest = 0, pg; + int dpi = 96, tallest = 0, cap, pg; ui_measure m; ui_dlg *d[ED_PAGES]; sd_err e = SD_OK; @@ -741,6 +893,9 @@ static sd_err build_pages(ed_ctx *ed, unsigned char *blob[ED_PAGES], size_t len[ if (d[pg]->cursor + d[pg]->margin > tallest) tallest = d[pg]->cursor + d[pg]->margin; } + cap = page_height_cap(dpi, &m); + if (cap > 0 && tallest > cap) + tallest = cap; /* the hidden content of the capped pages scrolls at run time */ for (pg = 0; pg < ED_PAGES && e == SD_OK; pg++) { d[pg]->min_height = tallest; e = ui_dlg_build(d[pg], &blob[pg], &len[pg]); From 269ae7302dfdb274c749ca8146a73c0021cb6d27 Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 19:32:08 -0300 Subject: [PATCH 3/8] shellext: resolve the menu language through SHELLDRIVE_LANG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context menu verbs came out in the Windows UI language: sd_l10n_init got a NULL override and nothing read SHELLDRIVE_LANG, so resolution fell through to GetUserDefaultUILanguage. 03 §9.3 ranks SHELLDRIVE_LANG above the user UI language for every catalog-loaded binary, and the CLI and the tray app read it (03 §9.1 leaves no Windows-loaded exception for these verbs); a shell extension has no command line, so the environment variable is its first choice. Found in the M3 lab (run 9): with SHELLDRIVE_LANG=es the This PC submenu stayed English while the tray followed es. --- apps/shelldrive-shellext/shellext_com.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/shelldrive-shellext/shellext_com.c b/apps/shelldrive-shellext/shellext_com.c index 74b3fcd..9103f92 100644 --- a/apps/shelldrive-shellext/shellext_com.c +++ b/apps/shelldrive-shellext/shellext_com.c @@ -75,7 +75,13 @@ static void sx_l10n_ready(void) { unsigned spins; if (InterlockedCompareExchange(&g_l10n_state, 1, 0) == 0) { - sd_l10n_init(&sd_l10n_catalog_shell, NULL); + /* Language resolution (03 §9.3): a shell extension has no command line, so SHELLDRIVE_LANG (a user + variable, inherited by Explorer) ranks before the user UI language, as in the CLI and the tray app. */ + wchar_t lang[32]; + if (GetEnvironmentVariableW(L"SHELLDRIVE_LANG", lang, SD_COUNTOF(lang)) - 1u < SD_COUNTOF(lang) - 1u) + sd_l10n_init(&sd_l10n_catalog_shell, lang); + else + sd_l10n_init(&sd_l10n_catalog_shell, NULL); InterlockedExchange(&g_l10n_state, 2); return; } From 9c953aa52f72af9c0dac89a1df59848577347601 Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 19:33:07 -0300 Subject: [PATCH 4/8] docs: record the M3 lab acceptance (runs 6-9) The GUI flow and Explorer steps of the M3 outcome ran in the Hyper-V lab on 2026-10-30; the acceptance doc carried only the local gate and a placeholder for them. Record per demo step what is verified and what is not: wizard, editor, Network locations, This PC mount, offline state and unmount verified in Spanish and German at 150 % DPI; the host-key prompt verified agent-side (no TM-R03 violation, the key is stored only after the consent click) with the dialog self-close left open; the Explorer menu language defect noted as fixed on this branch with the lab re-verification pending. List the open defects (prompt self-close, tray/status-bar mismatch on a failed mount, WOW64 drive item) and the hostkey list doc skew the harness found. --- docs/dev/m3-acceptance.md | 80 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/dev/m3-acceptance.md b/docs/dev/m3-acceptance.md index a3292cf..ac37102 100644 --- a/docs/dev/m3-acceptance.md +++ b/docs/dev/m3-acceptance.md @@ -5,9 +5,10 @@ from the `wip/m2-backend` tree at `635832b` plus the uncommitted acceptance doc. The run covers the local CI gate (build, warnings, import audit and CTest with the interop tests) and the automated UI, shell-extension, state, agent and localization evidence. The M3 demoable outcome is a -GUI flow in Spanish and German at 150 % DPI on Windows 11, and the Explorer behaviours of 03 §8. Neither was -run here, because the agent cannot be isolated from the user's configuration (see "End-to-end through the agent"). -They are listed under "Not covered" and "Known gaps". +GUI flow in Spanish and German at 150 % DPI, and the Explorer behaviours of 03 §8: that part ran later in the +disposable Hyper-V lab on 2026-10-30 (harness runs 6–9) and is recorded in the "Lab acceptance" section below. +The local run could not cover it because the agent cannot be isolated from the user's configuration +(see "End-to-end through the agent"); its "Not covered" list stands as the record of this local run. ## Environment @@ -162,6 +163,79 @@ through `shelldrive status`. It needs a disposable Windows user account, or a la | Unmount from the drive context menu | not run; covered by the snapshot and menu tests only | | 32-bit app file dialog shows the menu | not run; the x86 DLL is built and tested, but not registered on WOW64 (see gaps) | +Every one of these steps was later exercised in the Hyper-V lab (runs 6–9, 2026-10-30); see "Lab acceptance". + +## Lab acceptance — GUI flow, Explorer and the host-key prompt (2026-10-30) + +The GUI flow and the Explorer behaviours of 03 §8 ran in the disposable Hyper-V lab: a Windows 10 IoT +Enterprise LTSC 2021 VM (10.0.19044, 1920×1080 at 150 % DPI) restored from a clean snapshot per run, with the +WSL sshd fixture (port 2222) reachable from the guest. Four harness runs on the day: runs 6–8 (afternoon) and +run 9 (evening; interrupted once, resumed over the saved state, then re-run green after a one-line harness +fix). The evidence is the harness logs, the UIA dumps and about 85 screenshots in the lab tree, which is not +committed (machine-specific paths are not recorded here); the copy-worthy findings live in the repo's +`lab-results.md` maintenance notes. + +### Demo steps, verified and not verified + +| Demo step (00 §6, M3) | Status | +|---|---| +| Full GUI flow in Spanish (voseo) and German at 150 % DPI | **verified.** The wizard was driven to the end in Spanish and in German; the German pass reached full depth at 1920×1080 (wizard pages 1–4, the profile editor with all five tabs, tab-order dumps, main window and tray menu). | +| Create a profile in the editor | **verified** (run 7): the Spanish wizard created the profile "Laboratorio SFTP". | +| The profile appears under "Network locations" | **verified** (run 7): "Laboratorio SFTP" shows under "Ubicaciones de red" in This PC. | +| Mount from the This PC context menu | **verified** (run 8): This PC ▸ "Mount with ShellDrive" ▸ "Laboratorio SFTP" mounted the drive; tray menu showed "Desmontar Z: (Laboratorio SFTP)"; main window status bar "1 unidad montada."; dark mode on at 150 %. | +| Host-key prompt before the first mount | **verified agent-side, not fully verified in the UI** — see the verdict below. | +| Offline tray state | **verified** (run 9): with the network down, `status` reports reconnecting, the drive menu gains "Reconnect", the main-window row shows "Reconectando"; on recovery the mount reconnects and the agent verifies the stored key. | +| Unmount from the drive context menu | **verified**: the menu item removed the drive within seconds and the Network location shortcut retargets to the `places\` path. | +| 32-bit app file dialog shows the menu | **not verified: reproduced defect (c).** The 32-bit common Open dialog opens and reaches This PC, but UIA does not find the Z: item there (reproduced twice in run 9). | + +### Host-key prompt verdict (TM-R03) + +**No TM-R03 violation. The agent never trusted a key without an accepted user answer.** In run 8 the prompt +died unanswered and the agent's log shows the handshake parked — no key exchange, no authentication, no +"host key stored" — until the connection timed out and the mount failed. In run 9 and its green re-run the +harness clicked "Confiar y conectar" within about a second; the agent then completed the handshake, stored +the key exactly once, and every later connection verified against the stored fingerprint (which equals the +fixture server's key). The store entry is visible through `shelldrive hostkey list`. + +Not verified: the prompt dialog itself. In run 8 it vanished unanswered about two seconds after appearing +(caught mid-walk by the UIA dump); the cause is unidentified and the immediate-answer harness of run 9 cannot +reproduce it by design. A no-answer probe run (leave the prompt open for more than five seconds and record its +lifetime) is needed. This is open defect (a). + +### Explorer context-menu language + +**Defect, not per-design.** With `SHELLDRIVE_LANG=es` inherited by Explorer, the This PC submenu and the drive +menu stayed English while the tray followed Spanish. 03 §9.3 ranks `SHELLDRIVE_LANG` above the user UI +language for every catalog-loaded binary, and 03 §9.1 leaves no Windows-loaded exception for these verbs. The +shell extension passed a NULL override to `sd_l10n_init` and never read the variable; fixed on +`wip/m3-acceptance` (the shell-extension language commit). **Lab re-verification pending.** + +### Accessibility and dark mode + +From the UIA and tab-order dumps of runs 9 and the re-run: all wizard and editor controls expose names (the +labels mirror the edit fields) with the documented control ids, the editor's tab order cycles without traps, +and the host-key dialog exposes its texts and three buttons with mnemonics, initial focus on the fingerprint +edit and no default button. Observation, not audited deeper: with system apps dark, the ShellDrive main +window rendered light-themed in the same session — worth a follow-up if the app is expected to follow dark +mode on Windows 10. + +### Open defects (follow-ups) + +- **(a) Host-key prompt self-close.** The dialog can vanish unanswered ~2 s after appearing (run 8). Agent + behaviour is correct (no trust without an answer); the UI-side cause is unidentified and needs a no-answer + probe run. +- **(b) Status mismatch.** While the agent's mount state was `failed`, the tray and the main-window status bar + reported "1 unidad montada." and "Desmontar Z: (Laboratorio SFTP)" (run 8); the DataGrid row itself showed + "Falló" correctly. The counters and status bar follow stale state. +- **(c) WOW64 drive item.** The 32-bit common Open dialog shows This PC but the Z: drive item is not found by + UIA in that view (reproduced twice in run 9); the drive-menu step cannot run there. + +### Doc skew found by the harness + +`shelldrive hostkey list` requires `` in the build under test (`hostkey list --json` without a +host exits 2 with usage), while 03 §6.1 documents the host argument as optional. The harness adapted; the +design doc needs updating. + ## Reviewer commands Build and gate: From 4af9caf59131e5966977444cf761e9fedab166ad Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 19:41:44 -0300 Subject: [PATCH 5/8] os-imports: add the editor page-scroll imports to the XP floor lists The editor height fix imports GetWindow, ScrollWindowEx and SetScrollInfo; all three are documented from Windows 2000, so they belong on the XP SP2/SP3 floor lists the same way the earlier UI rounds were added. Found by the local gate: the x64-xp import audit of shelldrive-ui.exe failed with 3 violations. --- tests/os-imports/xp-sp2-x64.txt | 6 ++++++ tests/os-imports/xp-sp3-x86.txt | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/os-imports/xp-sp2-x64.txt b/tests/os-imports/xp-sp2-x64.txt index 1416a10..d4e7ee5 100644 --- a/tests/os-imports/xp-sp2-x64.txt +++ b/tests/os-imports/xp-sp2-x64.txt @@ -556,3 +556,9 @@ user32.dll!EnableMenuItem version.dll!GetFileVersionInfoSizeW version.dll!GetFileVersionInfoW version.dll!VerQueryValueW + +# Round 20 (profile editor page scroll, apps/shelldrive-ui/ui_editor.c): documented from Windows 2000, +# so present on XP SP2/SP3. Pending the clean-VM capture (04 section 11.4). +user32.dll!GetWindow +user32.dll!ScrollWindowEx +user32.dll!SetScrollInfo diff --git a/tests/os-imports/xp-sp3-x86.txt b/tests/os-imports/xp-sp3-x86.txt index e954368..5e995ee 100644 --- a/tests/os-imports/xp-sp3-x86.txt +++ b/tests/os-imports/xp-sp3-x86.txt @@ -574,3 +574,9 @@ user32.dll!EnableMenuItem version.dll!GetFileVersionInfoSizeW version.dll!GetFileVersionInfoW version.dll!VerQueryValueW + +# Round 20 (profile editor page scroll, apps/shelldrive-ui/ui_editor.c): documented from Windows 2000, +# so present on XP SP2/SP3. Pending the clean-VM capture (04 section 11.4). +user32.dll!GetWindow +user32.dll!ScrollWindowEx +user32.dll!SetScrollInfo From fc0fda2969c217ed9cc8dc413060ff80a03ed1cc Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 21:01:52 -0300 Subject: [PATCH 6/8] ui: count only mounted drives in the tray and the status bar A mount whose agent state is failed was still counted as a mounted drive: the tray tooltip and the status bar said "1 drive mounted." and the tray menu offered "Unmount Z: (profile)" while the main window's row already showed "Failed". The tooltip and the status bar now count drives that are mounted or reconnecting (03 7.2), and the tray menu skips a failed mount, which gets no Unmount item (the Reconnect of 03 1.11 needs an agent method this build does not have yet). Regression test in tests/ui. --- apps/shelldrive-ui/ui_int.h | 5 ++++- apps/shelldrive-ui/ui_main.c | 3 ++- apps/shelldrive-ui/ui_mainview_model.c | 11 +++++++++++ apps/shelldrive-ui/ui_mainview_model.h | 9 +++++++++ apps/shelldrive-ui/ui_model.c | 9 +++++++++ apps/shelldrive-ui/ui_tray.c | 23 ++++++++++++++++------- tests/ui/test_mainview.c | 23 +++++++++++++++++++++++ 7 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/shelldrive-ui/ui_int.h b/apps/shelldrive-ui/ui_int.h index b0a4c71..24786c3 100644 --- a/apps/shelldrive-ui/ui_int.h +++ b/apps/shelldrive-ui/ui_int.h @@ -39,7 +39,7 @@ /* Command ids of the tray menu and the main window controls. */ #define UI_CMD_OPEN 1001 #define UI_CMD_EXIT 1002 -#define UI_CMD_UNMOUNT_BASE 2000 /* + index of the mount in the model */ +#define UI_CMD_UNMOUNT_BASE 2000 /* + index of the tray menu's unmount item (drive captured at build time) */ #define UI_CMD_MOUNT 3001 #define UI_CMD_UNMOUNT 3002 #define UI_CMD_REFRESH 3003 @@ -150,6 +150,9 @@ void ui_model_set_list(ui_model *m, const sd_json *result); ui_mount *ui_model_find_id(ui_model *m, const char *id); ui_mount *ui_model_find_drive(ui_model *m, const wchar_t *drive); void ui_model_remove(ui_model *m, ui_mount *item); +/* The drives that count as mounted (tray tooltip, tray menu, status bar): really mounted or reconnecting + (ui_mv_is_mounted); a failed mount is not one (03 §1.11, §7.2). */ +size_t ui_model_mounted_count(const ui_model *m); /* connection.session and connection.attempt of a list item or a mount.state event. */ void ui_model_set_connection(ui_mount *it, const sd_json *data); const wchar_t *ui_model_state_text(const ui_mount *item); diff --git a/apps/shelldrive-ui/ui_main.c b/apps/shelldrive-ui/ui_main.c index c165dc4..36892b1 100644 --- a/apps/shelldrive-ui/ui_main.c +++ b/apps/shelldrive-ui/ui_main.c @@ -373,7 +373,8 @@ static void set_status_text(void) SendMessageW(g_app.status, SB_SETTEXTW, 0, (LPARAM)text); return; } - t = ui_format_count(SD_MSG_UI_STATUS_COUNT, (long long)g_app.model.count); + /* The status bar counts the drives that are mounted (reconnecting included), like the tray (03 §7.2). */ + t = ui_format_count(SD_MSG_UI_STATUS_COUNT, (long long)ui_model_mounted_count(&g_app.model)); if (t != NULL) { SendMessageW(g_app.status, SB_SETTEXTW, 0, (LPARAM)t); sd_free(t); diff --git a/apps/shelldrive-ui/ui_mainview_model.c b/apps/shelldrive-ui/ui_mainview_model.c index 13f7cf8..912b6b1 100644 --- a/apps/shelldrive-ui/ui_mainview_model.c +++ b/apps/shelldrive-ui/ui_mainview_model.c @@ -199,6 +199,17 @@ ui_mv_status ui_mv_status_of(const char *state, const char *session, int attempt return UI_MV_ST_UNKNOWN; } +int ui_mv_is_mounted(const char *state, const char *session, int attempt) +{ + ui_mv_status st = ui_mv_status_of(state, session, attempt); + return st == UI_MV_ST_MOUNTED || st == UI_MV_ST_RECONNECTING; +} + +int ui_mv_is_failed(const char *state, const char *session, int attempt) +{ + return ui_mv_status_of(state, session, attempt) == UI_MV_ST_FAILED; +} + ui_mv_icon ui_mv_status_icon(ui_mv_status st) { switch (st) { diff --git a/apps/shelldrive-ui/ui_mainview_model.h b/apps/shelldrive-ui/ui_mainview_model.h index 482ac06..4d20866 100644 --- a/apps/shelldrive-ui/ui_mainview_model.h +++ b/apps/shelldrive-ui/ui_mainview_model.h @@ -73,6 +73,15 @@ typedef enum ui_mv_status { RECONNECTING. */ ui_mv_status ui_mv_status_of(const char *state, const char *session, int attempt); +/* The mount counts as a mounted drive for the tray tooltip, the tray menu and the status bar: really mounted, or + reconnecting (03 §7.2 counts a reconnecting drive among the connected ones). Connecting, authenticating, offline, + auth_required, failed and unmounting mounts do not count. */ +int ui_mv_is_mounted(const char *state, const char *session, int attempt); + +/* The mount is failed: the tray offers no Unmount item for it (03 §1.11 offers Reconnect there, which needs an + agent method this build does not have yet). */ +int ui_mv_is_failed(const char *state, const char *session, int attempt); + typedef enum ui_mv_icon { UI_MV_ICON_NONE = 0, UI_MV_ICON_OK, UI_MV_ICON_IDLE, UI_MV_ICON_WARN } ui_mv_icon; ui_mv_icon ui_mv_status_icon(ui_mv_status st); ui_mv_icon ui_mv_sec_icon(unsigned sec); diff --git a/apps/shelldrive-ui/ui_model.c b/apps/shelldrive-ui/ui_model.c index 2bd3d69..0e3f90e 100644 --- a/apps/shelldrive-ui/ui_model.c +++ b/apps/shelldrive-ui/ui_model.c @@ -209,6 +209,15 @@ void ui_model_remove(ui_model *m, ui_mount *item) memset(&m->items[m->count], 0, sizeof(ui_mount)); } +size_t ui_model_mounted_count(const ui_model *m) +{ + size_t i, n = 0; + for (i = 0; i < m->count; i++) + if (ui_mv_is_mounted(m->items[i].state, m->items[i].session, m->items[i].attempt)) + n++; + return n; +} + const wchar_t *ui_model_state_text(const ui_mount *item) { const char *s = item->state; diff --git a/apps/shelldrive-ui/ui_tray.c b/apps/shelldrive-ui/ui_tray.c index 4e664ef..f54c9c6 100644 --- a/apps/shelldrive-ui/ui_tray.c +++ b/apps/shelldrive-ui/ui_tray.c @@ -22,6 +22,7 @@ #include "sd_platform/sd_platform.h" #include "sd_platform/sd_time.h" #include "ui_int.h" +#include "ui_mainview_model.h" #include "ui_notice_model.h" #include "ui_notify_model.h" #include "ui_win.h" @@ -113,7 +114,8 @@ static void ensure_sizes(void) static void fill_nid(ui_nid *nid) { wchar_t *count_text = NULL; - size_t n = g_app.model.count; + /* The tooltip counts the drives that are mounted: reconnecting ones count, failed ones do not (03 §7.2). */ + size_t n = ui_model_mounted_count(&g_app.model); memset(nid, 0, sizeof(*nid)); nid->cbSize = (DWORD)g_nid_size; @@ -553,7 +555,7 @@ static void show_menu(void) HMENU menu; POINT pt; UINT cmd; - size_t i, n = g_app.model.count; + size_t i, n = g_app.model.count, n_unmount = 0; wchar_t drives[UI_MAX_MOUNTS][3]; menu = CreatePopupMenu(); @@ -566,14 +568,21 @@ static void show_menu(void) AppendMenuW(menu, MF_SEPARATOR, 0, NULL); } for (i = 0; i < n; i++) { - wchar_t *t = unmount_item_text(g_app.model.items[i].drive, g_app.model.items[i].profile); - wcscpy_s(drives[i], SD_COUNTOF(drives[i]), g_app.model.items[i].drive); + const ui_mount *it = &g_app.model.items[i]; + wchar_t *t; + /* A failed mount is not a mounted drive (03 §1.11): it offers no Unmount item; the Reconnect the design + gives it has no agent method in this build yet. */ + if (ui_mv_is_failed(it->state, it->session, it->attempt)) + continue; + t = unmount_item_text(it->drive, it->profile); + wcscpy_s(drives[n_unmount], SD_COUNTOF(drives[n_unmount]), it->drive); if (t != NULL) { - AppendMenuW(menu, MF_STRING, UI_CMD_UNMOUNT_BASE + (UINT)i, t); + AppendMenuW(menu, MF_STRING, UI_CMD_UNMOUNT_BASE + (UINT)n_unmount, t); sd_free(t); } + n_unmount++; } - if (n == 0) + if (n_unmount == 0) AppendMenuW(menu, MF_STRING | MF_GRAYED, 0, sd_l10n_get(SD_MSG_UI_TRAY_NO_DRIVES)); else AppendMenuW(menu, MF_SEPARATOR, 0, NULL); @@ -604,7 +613,7 @@ static void show_menu(void) (void)ui_svc_launch_admin(g_app.main); } else if (cmd == UI_CMD_EXIT) { ui_app_quit(); - } else if (cmd >= UI_CMD_UNMOUNT_BASE && cmd < UI_CMD_UNMOUNT_BASE + n) { + } else if (cmd >= UI_CMD_UNMOUNT_BASE && cmd < UI_CMD_UNMOUNT_BASE + n_unmount) { /* The drive letter was captured when the menu was built; the list may have changed meanwhile. */ ui_ipc_unmount(drives[cmd - UI_CMD_UNMOUNT_BASE]); } diff --git a/tests/ui/test_mainview.c b/tests/ui/test_mainview.c index 13e406c..d06a9dd 100644 --- a/tests/ui/test_mainview.c +++ b/tests/ui/test_mainview.c @@ -103,6 +103,29 @@ SD_TEST(mainview_status_kinds_and_icons) SD_CHECK_EQ_INT((int)ui_mv_sec_icon(UI_MV_SEC_TERRAPIN), (int)UI_MV_ICON_WARN); } +/* The tray tooltip, the tray menu and the status bar follow the state the agent reports (03 §1.11, §7.2): a failed + mount is not a mounted drive and offers no Unmount item; a reconnecting one stays a mounted drive. */ +SD_TEST(mainview_mounted_count_follows_the_real_state) +{ + SD_CHECK(ui_mv_is_mounted("mounted", "ready", 0)); + SD_CHECK(ui_mv_is_mounted("offline", "reconnect_wait", 3)); /* reconnecting counts as mounted */ + SD_CHECK(!ui_mv_is_mounted("offline", "reconnect_wait", 0)); /* offline without a retry does not */ + SD_CHECK(!ui_mv_is_mounted("offline", "disconnected", 2)); + SD_CHECK(!ui_mv_is_mounted("failed", "failed", 0)); + SD_CHECK(!ui_mv_is_mounted("connecting", "", 0)); + SD_CHECK(!ui_mv_is_mounted("authenticating", "", 0)); + SD_CHECK(!ui_mv_is_mounted("auth_required", "", 0)); + SD_CHECK(!ui_mv_is_mounted("unmounting", "", 0)); + SD_CHECK(!ui_mv_is_mounted("", "", 0)); + SD_CHECK(!ui_mv_is_mounted(NULL, NULL, 0)); + /* the tray menu drops its Unmount item for a failed mount only */ + SD_CHECK(ui_mv_is_failed("failed", "failed", 0)); + SD_CHECK(!ui_mv_is_failed("mounted", "ready", 0)); + SD_CHECK(!ui_mv_is_failed("offline", "disconnected", 2)); + SD_CHECK(!ui_mv_is_failed("", "", 0)); + SD_CHECK(!ui_mv_is_failed(NULL, NULL, 0)); +} + SD_TEST(mainview_groups_follow_the_scope) { SD_CHECK_EQ_INT((int)ui_mv_group_of_scope(SD_PROFILE_SCOPE_USER), (int)UI_MV_GROUP_MINE); From ad88527b47eb55d9bd5815cbc81851adcb8e036e Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 21:25:28 -0300 Subject: [PATCH 7/8] agent: offer prompts it still holds to a UI that reconnects When the UI's agent pipe was reported down, the dialogs were destroyed without an answer and nothing showed the prompts again: the agent keeps them (no prompt-capable UI left when it checked, or the reconnect won the race), subscribe redelivers queued prompts only, and prompt.list only closed dialogs. The mount waited for the server to drop the connection. prompt.list now carries the prompt.request fields of every prompt it lists (like the service's list), and the UI builds dialogs from the entries it is not already showing, so a prompt that was showing when the connection dropped is shown again with its remaining deadline; an open dialog stays, so no prompt is shown twice. The agent logs UI client connect, disconnect and prompt subscribe at INFO. --- apps/shelldrive-host/agent.c | 42 +++++++++++-------- apps/shelldrive-ui/main.c | 21 ++++++---- apps/shelldrive-ui/ui_ipc.c | 2 +- docs/design/03-apps-platform.md | 2 +- src/sd_ipc/ipc_server.c | 2 +- tests/apps/main.c | 2 + tests/apps/test_agent_m3.c | 72 +++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 26 deletions(-) diff --git a/apps/shelldrive-host/agent.c b/apps/shelldrive-host/agent.c index 38df1c8..2cc1d15 100644 --- a/apps/shelldrive-host/agent.c +++ b/apps/shelldrive-host/agent.c @@ -1194,18 +1194,26 @@ static void wr_u64(sd_wr *w, uint64_t v) } } -/* prompt.request: the stored data object (without its closing brace) plus queued and the remaining - deadline in ms at send time (03 §2.5, 1.7.4). `out` must be initialized by the caller. */ +/* The data object of prompt.request: the stored fields plus queued and the remaining deadline in ms at + send time (03 §2.5, 1.7.4). prompt.list emits the same object for every prompt it still holds + (03 §2.1 row 11), so a UI that (re)connects late can show the dialogs it was not sent. `out` must be + initialized. */ +static void prompt_request_data(const host_prompt *r, int queued, uint64_t now, sd_wr *out) +{ + uint64_t remain = r->deadline > now ? r->deadline - now : 0; + sd_wr_bytes(out, r->body, r->body_len); + wr_raw(out, queued ? ",\"queued\":true,\"deadline_ms\":" : ",\"queued\":false,\"deadline_ms\":"); + wr_u64(out, remain); + wr_raw(out, "}"); +} + +/* prompt.request: the data object above as one event frame. `out` must be initialized by the caller. */ static sd_err prompt_request_event(const host_prompt *r, int queued, uint64_t now, sd_wr *out) { sd_wr d; sd_err e; - uint64_t remain = r->deadline > now ? r->deadline - now : 0; sd_wr_init(&d, r->body_len + 128); - sd_wr_bytes(&d, r->body, r->body_len); - wr_raw(&d, queued ? ",\"queued\":true,\"deadline_ms\":" : ",\"queued\":false,\"deadline_ms\":"); - wr_u64(&d, remain); - wr_raw(&d, "}"); + prompt_request_data(r, queued, now, &d); e = sd_ipc_build_event("prompt.request", (const char *)d.p, d.len, out); sd_wr_free(&d); return e; @@ -1578,6 +1586,7 @@ static void ipc_on_subscribe(void *ctx, sd_ipc_client *client, const char *topic if (strcmp(topic, "prompt") != 0 || !(sd_ipc_client_caps(client) & SD_IPC_CAP_PROMPTS_UI)) return; cid = sd_ipc_client_id(client); + SD_LOGI("agent", "client %llu subscribed to prompt", (unsigned long long)cid); sd_mutex_lock(&g.lock); for (;;) { host_prompt *r = NULL, *o; @@ -3044,7 +3053,8 @@ static sd_err handle_prompt_answer(sd_ipc_client *client, const sd_json *params, return SD_OK; } -/* prompt.list {}: the open and queued prompts (03 §2.1 row 11). */ +/* prompt.list {}: the open and queued prompts, each with the fields of prompt.request (03 §2.1 row 11), + so a UI that (re)connects late shows the dialogs of prompts it was not sent. */ static sd_err handle_prompt_list(sd_json_writer *res) { host_prompt *r; @@ -3055,14 +3065,13 @@ static sd_err handle_prompt_list(sd_json_writer *res) for (r = g.prompts; r; r = r->next) { if (r->expire) continue; - sd_jw_obj_begin(res); - sd_jw_key(res, "prompt_id"); sd_jw_cstr(res, r->pid); - sd_jw_key(res, "mount_id"); sd_jw_cstr(res, r->mount_id); - sd_jw_key(res, "profile_id"); sd_jw_cstr(res, r->profile_id); - sd_jw_key(res, "kind"); sd_jw_cstr(res, r->kind); - sd_jw_key(res, "queued"); sd_jw_bool(res, r->queued); - sd_jw_key(res, "deadline_ms"); sd_jw_int(res, (int64_t)(r->deadline > now ? r->deadline - now : 0)); - sd_jw_obj_end(res); + { + sd_wr d; + sd_wr_init(&d, r->body_len + 128); + prompt_request_data(r, r->queued, now, &d); + sd_jw_raw(res, (const char *)d.p, d.len); + sd_wr_free(&d); + } } sd_jw_arr_end(res); sd_mutex_unlock(&g.lock); @@ -3491,6 +3500,7 @@ static void ipc_on_disconnect(void *ctx, uint64_t client_id) { uint64_t *p = (uint64_t *)sd_alloc(sizeof(uint64_t)); SD_UNUSED(ctx); + SD_LOGI("agent", "client %llu disconnected", (unsigned long long)client_id); if (p == NULL) return; *p = client_id; post(NULL, work_cancel_client, p); diff --git a/apps/shelldrive-ui/main.c b/apps/shelldrive-ui/main.c index 11e2c74..33812e4 100644 --- a/apps/shelldrive-ui/main.c +++ b/apps/shelldrive-ui/main.c @@ -528,16 +528,23 @@ void ui_app_on_event(sd_json *ev) sd_json_free(ev); } +/* the list carries the same fields as prompt.request: a prompt this UI does not show becomes a dialog + (ui_prompt.c keeps the dialog of one that is already open, so none is shown twice) */ +static void prompts_from_list(const sd_json *result) +{ + const sd_json *arr = sd_json_get(result, "prompts"); + size_t i, n = sd_json_count(arr); + for (i = 0; i < n; i++) + ui_prompt_on_request(sd_json_at(arr, i)); +} + void ui_app_on_prompt_list(sd_json *result) { ui_prompt_on_list(result); - if (g_app.elevated_admin) { - /* the admin mode shows every open service prompt: the list carries the same fields as prompt.request */ - const sd_json *arr = sd_json_get(result, "prompts"); - size_t i, n = sd_json_count(arr); - for (i = 0; i < n; i++) - ui_prompt_on_request(sd_json_at(arr, i)); - } + /* the agent's list (every mode) and the service's list (admin mode, ui_svc.c) both carry every prompt + their sender still holds: one that was showing when the connection dropped is shown again, with its + remaining deadline (03 §2.1 prompt.list) */ + prompts_from_list(result); sd_json_free(result); } diff --git a/apps/shelldrive-ui/ui_ipc.c b/apps/shelldrive-ui/ui_ipc.c index 6a6ad92..ac3fc3c 100644 --- a/apps/shelldrive-ui/ui_ipc.c +++ b/apps/shelldrive-ui/ui_ipc.c @@ -261,7 +261,7 @@ static DWORD WINAPI ipc_thread(void *arg) } else { sd_json_free(res); } - /* the open prompts of the agent: dialogs for prompts it no longer holds are closed (ui_prompt.c) */ + /* the open prompts of the agent: dialogs it no longer holds are closed, ones it still holds are shown again (ui_prompt.c) */ if (sd_ipc_conn_has_feature(c, "prompt.list")) { res = NULL; memset(&err, 0, sizeof(err)); diff --git a/docs/design/03-apps-platform.md b/docs/design/03-apps-platform.md index 0966dd6..504b614 100644 --- a/docs/design/03-apps-platform.md +++ b/docs/design/03-apps-platform.md @@ -527,7 +527,7 @@ implemented in M3 and are not listed in `hello` `features` (2.8). | `cache.clear {profile_id \| mount_id \| target \| all, force?}` | x | x (admin, M5) | M3 (agent): drops the memory blocks and the disk-tier files of the matching mounts. While write-behind data is queued on one of them the whole request is refused with `write_behind_pending` (busy, exit 8) unless `force`; queued data is never dropped by a clear | | `config.reload {scope?}` | x (user) | x (admin, M5) | reloads `settings.ini` and counts the profiles. Reply `{profiles:{loaded, errors:[]}, settings: "reloaded"\|"unchanged", restart_required:[]}`. `scope: "machine"` on the agent is `unsupported`. `restart_required` is empty in M3 | | `prompt.answer {prompt_id, action, responses?, save?, save_comment?}` | x | x (admin, elevated, M5) | `action` accept, once, answer, reject or cancel. The first accepted answer wins; a second answer, or one for a prompt that is not open, is `not_found` (2.5) | -| `prompt.list` | x | x (admin, M5) | `{prompts:[{prompt_id, mount_id, profile_id, kind, queued, deadline_ms}]}`, `deadline_ms` is the time left | +| `prompt.list` | x | x (admin, M5) | `{prompts:[...]}`, one entry per prompt the agent or service still holds, with the fields of `prompt.request` plus `queued` and `deadline_ms`, the time left. A UI that (re)connects builds the dialogs of the entries it is not already showing | | `log.tail {lines?, cursor?, level?, component?, follow?}` | x | x (admin, M5) | the ring of 4096 records (2.4.1). `follow: true` is `unsupported` in M3 (the `log` topic is M4) | | `selftest` | x | x | `{healthy, checks:[{name, result: pass\|fail\|skip, code?, detail}]}`; the CLI maps `healthy: false` to exit 17 | | `diag.collect` | x | x (admin, M5) | M4 (10.5) | diff --git a/src/sd_ipc/ipc_server.c b/src/sd_ipc/ipc_server.c index f324f92..5b18f15 100644 --- a/src/sd_ipc/ipc_server.c +++ b/src/sd_ipc/ipc_server.c @@ -224,7 +224,7 @@ static void on_connected(sd_ipc_server *s) c->next = s->clients; s->clients = c; sd_mutex_unlock(&s->lock); - SD_LOGD("ipc", "client %llu connected", (unsigned long long)c->id); + SD_LOGI("ipc", "client %llu connected", (unsigned long long)c->id); /* 2.2 (TM-R25): the class of the instance is decided here, from the token, not at hello. The reserved instances and the hello deadline of the service depend on it, so a connection that never says hello is counted correctly. */ c->connect_ms = sd_tick_ms(); diff --git a/tests/apps/main.c b/tests/apps/main.c index 683b727..6fa953e 100644 --- a/tests/apps/main.c +++ b/tests/apps/main.c @@ -24,6 +24,7 @@ SD_TEST_DECL(m3_prompt_deadline_timeout); SD_TEST_DECL(m3_queue_evicts_and_drains_to_ui); SD_TEST_DECL(m3_initiator_gone_keeps_prompts_for_ui); SD_TEST_DECL(m3_initiator_gone_without_ui_cancels); +SD_TEST_DECL(m3_prompt_list_offers_delivered_prompt_again); SD_TEST_DECL(m3_new_methods_and_list_warnings); SD_TEST_DECL(m4_events_recent_history_and_limits); SD_TEST_DECL(m4_update_consent_and_disabled_check); @@ -84,6 +85,7 @@ static const sd_test_case k_tests[] = { SD_TEST_ENTRY(m3_queue_evicts_and_drains_to_ui), SD_TEST_ENTRY(m3_initiator_gone_keeps_prompts_for_ui), SD_TEST_ENTRY(m3_initiator_gone_without_ui_cancels), + SD_TEST_ENTRY(m3_prompt_list_offers_delivered_prompt_again), SD_TEST_ENTRY(m3_new_methods_and_list_warnings), SD_TEST_ENTRY(m4_events_recent_history_and_limits), SD_TEST_ENTRY(m4_update_consent_and_disabled_check), diff --git a/tests/apps/test_agent_m3.c b/tests/apps/test_agent_m3.c index 82971a2..f25c02c 100644 --- a/tests/apps/test_agent_m3.c +++ b/tests/apps/test_agent_m3.c @@ -749,6 +749,78 @@ SD_TEST(m3_initiator_gone_without_ui_cancels) sd_ipc_close(x); } +/* Run 8 regression: a prompt delivered to a UI whose pipe dropped has no queued copy, so a new + subscriber is not sent it again; the UI that (re)connects takes it from prompt.list, which carries the + prompt.request fields (03 §2.1 row 11), and answering it completes the mount. */ +SD_TEST(m3_prompt_list_offers_delivered_prompt_again) +{ + sd_ipc_conn *u = NULL, *v = NULL; + sd_json *res = NULL; + sd_ipc_error err; + evlog ul, vl; + char pid[32], buf[160]; + const sd_json *p0; + memset(&ul, 0, sizeof(ul)); + memset(&vl, 0, sizeof(vl)); + SD_REQUIRE(setup_agent(L"again", NULL, NULL, NULL)); + SD_REQUIRE(connect_as("ui", SD_IPC_CAP_PROMPTS_UI, &u) == SD_OK); + subscribe(u, "\"prompt\",\"mount\""); + SD_REQUIRE(call(u, "mount", "{\"adhoc\":{\"host\":\"new5.test\",\"user\":\"alice\"}}", &res, &err) == SD_OK); + sd_json_free(res); + res = NULL; + SD_REQUIRE(pump_until(u, &ul, "prompt.request", NULL, 1, 10000)); + prompt_id_of(&ul, "prompt.request", 0, pid); + + /* the reconnecting UI subscribes: nothing is queued, so it is sent nothing (the run 8 gap) */ + SD_REQUIRE(connect_as("ui", SD_IPC_CAP_PROMPTS_UI, &v) == SD_OK); + subscribe(v, "\"prompt\",\"mount\""); + pump_idle(v, &vl, 300); + SD_CHECK(count_ev(&vl, "prompt.request", NULL) == 0); + + /* the first UI's pipe drops while v is subscribed: the prompt is not cancelled (decision 5) */ + sd_ipc_close(u); + u = NULL; + Sleep(300); + pump_idle(v, &vl, 300); + SD_CHECK(count_ev(&vl, "prompt.request", NULL) == 0); + SD_CHECK(count_ev(&vl, "prompt.closed", NULL) == 0); + + /* the list offers the prompt with the fields the dialog needs */ + SD_REQUIRE(call(v, "prompt.list", "{}", &res, &err) == SD_OK); + SD_CHECK_EQ_U64(sd_json_count(sd_json_get(res, "prompts")), 1); + p0 = sd_json_at(sd_json_get(res, "prompts"), 0); + SD_CHECK_STR(sd_json_get_str(p0, "kind"), "hostkey_unknown"); + SD_CHECK_STR(sd_json_get_str(p0, "host"), "new5.test"); + SD_CHECK_STR(sd_json_get_str(p0, "user"), "alice"); + SD_CHECK(sd_json_get_str(p0, "fingerprint") != NULL); + SD_CHECK(jbool3(p0, "queued", NULL) == 0); + SD_CHECK(jint3(p0, "deadline_ms", NULL) > 0); + strcpy_s(pid, sizeof(pid), sd_json_get_str(p0, "prompt_id")); + sd_json_free(res); + res = NULL; + + /* answering the offered prompt completes the mount */ + answer_json(buf, sizeof(buf), pid, "\"action\":\"accept\""); + SD_REQUIRE(call(v, "prompt.answer", buf, &res, &err) == SD_OK); + SD_CHECK(jbool3(res, "accepted", NULL) == 1); + sd_json_free(res); + res = NULL; + SD_REQUIRE(pump_until(v, &vl, "prompt.request", NULL, 1, 10000)); + prompt_id_of(&vl, "prompt.request", 0, pid); + answer_json(buf, sizeof(buf), pid, "\"action\":\"answer\",\"responses\":[\"secret\"]"); + SD_REQUIRE(call(v, "prompt.answer", buf, &res, &err) == SD_OK); + sd_json_free(res); + res = NULL; + SD_REQUIRE(pump_until(v, &vl, "mount.state", "mounted", 1, 15000)); + SD_REQUIRE(pump_until(v, &vl, "prompt.closed", NULL, 2, 5000)); + SD_CHECK_STR(sd_json_get_str(ev_data(nth_ev(&vl, "prompt.closed", NULL, 0)), "reason"), "answered"); + SD_CHECK_STR(sd_json_get_str(ev_data(nth_ev(&vl, "prompt.closed", NULL, 1)), "answered_by"), "ui"); + free_log(&ul); + free_log(&vl); + shutdown_agent(v); + sd_ipc_close(v); +} + /* The read-only methods and the reply shapes that changed in M3, plus the hello features. */ SD_TEST(m3_new_methods_and_list_warnings) { From 33889c418d2e3942bf5742da822986accec7f551 Mon Sep 17 00:00:00 2001 From: FacuM Date: Fri, 30 Oct 2026 21:04:26 -0300 Subject: [PATCH 8/8] cli: make the host optional in hostkey list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 03 §6.1 documents `shelldrive hostkey list [host]`, but the parser still required the host and exited 2. Without a host every stored key of the machine and user stores is listed through sd_known_hosts_list_all (the host key manager read of 5.1; live sources are not enumerated), with the same JSON shape as the per-host list and a Host column in the text table. The --help synopsis now matches the command set. --- apps/shelldrive/cli_args.c | 5 +- apps/shelldrive/cli_cmds.c | 2 +- apps/shelldrive/cli_cmds2.c | 125 ++++++++++++++++++++++++++++++++++++ l10n/cli/de.po | 15 +++++ l10n/cli/en.po | 15 +++++ l10n/cli/es.po | 15 +++++ l10n/cli/fr.po | 15 +++++ l10n/cli/it.po | 15 +++++ l10n/cli/pt-BR.po | 15 +++++ tests/apps/test_cli_args.c | 6 +- tests/apps/test_smoke.c | 8 ++- 11 files changed, 229 insertions(+), 7 deletions(-) diff --git a/apps/shelldrive/cli_args.c b/apps/shelldrive/cli_args.c index 9f86d1d..df87c55 100644 --- a/apps/shelldrive/cli_args.c +++ b/apps/shelldrive/cli_args.c @@ -608,9 +608,8 @@ cli_usage_err cli_parse(int argc, wchar_t **argv, cli_args *a) if (!a->positional) return set_err(a, CLI_E_MISSING_ARGUMENT, a->cmd_name, NULL); if (wcscmp(a->positional, L"list") == 0) { - /* with --announced the pending updates are listed without a host (03 §6.1) */ - if (!a->arg2 && !a->announced) - return set_err(a, CLI_E_MISSING_ARGUMENT, a->positional, NULL); + /* the host is optional (03 §6.1): without it every stored key is listed; with --announced + the pending updates, also without a host */ if (a->hk_type || a->from || a->add_only) return set_err(a, CLI_E_UNKNOWN_OPTION, a->hk_type ? L"--type" : a->from ? L"--from" : L"--add-only", NULL); /* --machine is the service's list with --announced (03 §5.6, §6.1); a plain list has no machine form */ diff --git a/apps/shelldrive/cli_cmds.c b/apps/shelldrive/cli_cmds.c index a94631a..90893f5 100644 --- a/apps/shelldrive/cli_cmds.c +++ b/apps/shelldrive/cli_cmds.c @@ -877,7 +877,7 @@ void cli_print_help(const wchar_t *topic) cli_print(sd_l10n_get(SD_MSG_CLI_HELP_OPTIONS)); help_line(L"--dry-run", SD_MSG_CLI_HELP_OPT_DRY_RUN); } else if (topic && wcscmp(topic, L"hostkey") == 0) { - help_line(L"hostkey list [--announced]", SD_MSG_CLI_HELP_HOSTKEY); + help_line(L"hostkey list [host[:port]] [--announced]", SD_MSG_CLI_HELP_HOSTKEY); help_line(L"hostkey import --from putty|openssh [FILE]", SD_MSG_CLI_HELP_HOSTKEY); help_line(L"hostkey remove [--type T]", SD_MSG_CLI_HELP_HOSTKEY); help_line(L"hostkey accept-announced [--add-only] [--machine]", SD_MSG_CLI_HELP_HOSTKEY); diff --git a/apps/shelldrive/cli_cmds2.c b/apps/shelldrive/cli_cmds2.c index 78c3564..ef2332c 100644 --- a/apps/shelldrive/cli_cmds2.c +++ b/apps/shelldrive/cli_cmds2.c @@ -756,6 +756,129 @@ static int hk_list_cmd(const cli_args *a, const char *lookup) return rc; } +/* hostkey list without a host (03 §6.1, §5.1): every stored key of the machine and user stores, as + the host key manager reads them (sd_known_hosts_list_all). Live sources are not enumerated (5.1); + the per-host list above still consults them. */ +static const sd_kh_store_kind k_hk_all_stores[] = { SD_KH_STORE_MACHINE, SD_KH_STORE_USER }; + +typedef struct hk_all_row { + int store; + char host[256]; /* the pattern as written (sd_kh_all_entry: at most 255 bytes) */ + char type[48]; + char fp[64]; + uint32_t line; +} hk_all_row; + +typedef struct hk_all_list { + hk_all_row row[256]; + uint32_t n; + int truncated; +} hk_all_list; + +static int hk_all_collect(void *ctx, const sd_kh_all_entry *e) +{ + hk_all_list *l = (hk_all_list *)ctx; + hk_all_row *r; + if (l->n >= SD_COUNTOF(l->row)) { + l->truncated = 1; + return 1; + } + r = &l->row[l->n++]; + r->store = 0; /* filled by the caller (one call per store) */ + strcpy_s(r->host, sizeof(r->host), e->pattern ? e->pattern : "?"); + strcpy_s(r->type, sizeof(r->type), e->type ? e->type : "?"); + if (sd_hostkey_fingerprint_sha256(e->blob, e->len, r->fp) != SD_OK) + r->fp[0] = 0; + r->line = e->line; + return 0; +} + +/* The collected rows (about 90 KB) live on the heap: a stack copy trips C6262 under /analyze. */ +static int hk_list_all_run(const cli_args *a, hk_all_list *l) +{ + sd_known_hosts *kh = NULL; + size_t i, k; + if (!kh_open(0, &kh)) + return fail_code(a, "io_error", "cannot open the known_hosts stores", NULL); + for (k = 0; k < SD_COUNTOF(k_hk_all_stores); k++) { + uint32_t before = l->n; + if (sd_known_hosts_list_all(kh, k_hk_all_stores[k], hk_all_collect, l) != SD_OK) + continue; /* SD_E_NOT_FOUND: the store has no file yet */ + for (i = before; i < l->n; i++) + l->row[i].store = (int)k_hk_all_stores[k]; + } + sd_known_hosts_close(kh); + if (a->json) { + sd_wr w; + sd_json_writer jw; + sd_wr_init(&w, SD_IPC_MAX_MESSAGE); + sd_jw_init(&jw, &w); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "ok"); sd_jw_bool(&jw, 1); + sd_jw_key(&jw, "result"); + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "keys"); + sd_jw_arr_begin(&jw); + for (i = 0; i < l->n; i++) { + sd_jw_obj_begin(&jw); + sd_jw_key(&jw, "store"); sd_jw_cstr(&jw, kh_store_name((sd_kh_store_kind)l->row[i].store)); + sd_jw_key(&jw, "host"); sd_jw_cstr(&jw, l->row[i].host); + sd_jw_key(&jw, "type"); sd_jw_cstr(&jw, l->row[i].type); + sd_jw_key(&jw, "fingerprint"); sd_jw_cstr(&jw, l->row[i].fp); + sd_jw_key(&jw, "line"); sd_jw_int(&jw, l->row[i].line); + sd_jw_obj_end(&jw); + } + sd_jw_arr_end(&jw); + sd_jw_obj_end(&jw); + sd_jw_obj_end(&jw); + if (sd_jw_finish(&jw) == SD_OK) + cli_print_utf8_line((const char *)w.p, w.len); + sd_wr_free(&w); + return 0; + } + if (l->n == 0) { + say(SD_MSG_CLI_HK_LIST_ALL_EMPTY, NULL, 0, 0); + return 0; + } + { + wchar_t line[1024]; + size_t len = 0; + line[0] = 0; + pad_print(line, SD_COUNTOF(line), &len, sd_l10n_get(SD_MSG_CLI_HK_COL_HOST), 24); + pad_print(line, SD_COUNTOF(line), &len, sd_l10n_get(SD_MSG_CLI_HK_COL_STORE), 10); + pad_print(line, SD_COUNTOF(line), &len, sd_l10n_get(SD_MSG_CLI_HK_COL_TYPE), 18); + pad_print(line, SD_COUNTOF(line), &len, sd_l10n_get(SD_MSG_CLI_HK_COL_FP), 0); + cli_print(line); + } + for (i = 0; i < l->n; i++) { + wchar_t line[1024]; + size_t len = 0; + wchar_t *wh = wide(l->row[i].host), *ws = wide(kh_store_name((sd_kh_store_kind)l->row[i].store)); + wchar_t *wt = wide(l->row[i].type), *wf = wide(l->row[i].fp); + line[0] = 0; + pad_print(line, SD_COUNTOF(line), &len, wh ? wh : L"", 24); + pad_print(line, SD_COUNTOF(line), &len, ws ? ws : L"", 10); + pad_print(line, SD_COUNTOF(line), &len, wt ? wt : L"", 18); + pad_print(line, SD_COUNTOF(line), &len, wf ? wf : L"", 0); + cli_print(line); + sd_free(wh); sd_free(ws); sd_free(wt); sd_free(wf); + } + if (l->truncated) + say(SD_MSG_CLI_HK_LIST_ALL_TRUNCATED, NULL, 0, 0); + return 0; +} + +static int hk_list_all_cmd(const cli_args *a) +{ + hk_all_list *l = (hk_all_list *)sd_calloc(1, sizeof(*l)); + int rc; + if (!l) + return fail_code(a, "io_error", "out of memory", NULL); + rc = hk_list_all_run(a, l); + sd_free(l); + return rc; +} + static int hk_import(const cli_args *a) { sd_kh_store stores[3]; @@ -1249,6 +1372,8 @@ int cli_run_hostkey(const cli_args *a) return cli_is_elevated() ? hk_accept(a, 1) : hk_machine_denied(a); return hk_accept(a, 0); } + if (wcscmp(a->positional, L"list") == 0 && a->arg2 == NULL) + return hk_list_all_cmd(a); /* hostkey list without a host (03 §6.1) */ if (!lookup_name_of(a->arg2, lookup, sizeof(lookup))) return fail_code(a, "usage", "invalid host", NULL); if (wcscmp(a->positional, L"remove") == 0) diff --git a/l10n/cli/de.po b/l10n/cli/de.po index 656fc89..e059e98 100644 --- a/l10n/cli/de.po +++ b/l10n/cli/de.po @@ -587,6 +587,21 @@ msgctxt "cli.hk.list_truncated" msgid "The list was cut short; refine the host name to see the rest." msgstr "Die Liste wurde gekürzt; grenze den Hostnamen weiter ein, um den Rest zu sehen." +#. Column heading of hostkey list without a host (keep it short). +msgctxt "cli.hk.col_host" +msgid "Host" +msgstr "Host" + +#. hostkey list without a host and no stored key. +msgctxt "cli.hk.list_all_empty" +msgid "No host keys are stored." +msgstr "Es sind keine Host-Schlüssel gespeichert." + +#. hostkey list without a host stopped early because there are very many keys. +msgctxt "cli.hk.list_all_truncated" +msgid "The list was cut short; there are more stored keys." +msgstr "Die Liste wurde gekürzt; es sind weitere Schlüssel gespeichert." + #. hostkey remove. msgctxt "cli.hk.removed" msgid "Removed the stored keys of {host}." diff --git a/l10n/cli/en.po b/l10n/cli/en.po index cdc7f30..e3f6dac 100644 --- a/l10n/cli/en.po +++ b/l10n/cli/en.po @@ -654,6 +654,21 @@ msgctxt "cli.hk.list_truncated" msgid "The list was cut short; refine the host name to see the rest." msgstr "" +#. Column heading of hostkey list without a host (keep it short). +msgctxt "cli.hk.col_host" +msgid "Host" +msgstr "" + +#. hostkey list without a host and no stored key. +msgctxt "cli.hk.list_all_empty" +msgid "No host keys are stored." +msgstr "" + +#. hostkey list without a host stopped early because there are very many keys. +msgctxt "cli.hk.list_all_truncated" +msgid "The list was cut short; there are more stored keys." +msgstr "" + #. hostkey remove. msgctxt "cli.hk.removed" msgid "Removed the stored keys of {host}." diff --git a/l10n/cli/es.po b/l10n/cli/es.po index e4599f7..dbcba46 100644 --- a/l10n/cli/es.po +++ b/l10n/cli/es.po @@ -591,6 +591,21 @@ msgctxt "cli.hk.list_truncated" msgid "The list was cut short; refine the host name to see the rest." msgstr "La lista se cortó; acotá el nombre del host para ver el resto." +#. Column heading of hostkey list without a host (keep it short). +msgctxt "cli.hk.col_host" +msgid "Host" +msgstr "Host" + +#. hostkey list without a host and no stored key. +msgctxt "cli.hk.list_all_empty" +msgid "No host keys are stored." +msgstr "No hay claves de host guardadas." + +#. hostkey list without a host stopped early because there are very many keys. +msgctxt "cli.hk.list_all_truncated" +msgid "The list was cut short; there are more stored keys." +msgstr "La lista se cortó; hay más claves guardadas." + #. hostkey remove. msgctxt "cli.hk.removed" msgid "Removed the stored keys of {host}." diff --git a/l10n/cli/fr.po b/l10n/cli/fr.po index 4495116..286dade 100644 --- a/l10n/cli/fr.po +++ b/l10n/cli/fr.po @@ -591,6 +591,21 @@ msgctxt "cli.hk.list_truncated" msgid "The list was cut short; refine the host name to see the rest." msgstr "La liste a été raccourcie ; précisez le nom d'hôte pour voir le reste." +#. Column heading of hostkey list without a host (keep it short). +msgctxt "cli.hk.col_host" +msgid "Host" +msgstr "Hôte" + +#. hostkey list without a host and no stored key. +msgctxt "cli.hk.list_all_empty" +msgid "No host keys are stored." +msgstr "Aucune clé d'hôte n'est enregistrée." + +#. hostkey list without a host stopped early because there are very many keys. +msgctxt "cli.hk.list_all_truncated" +msgid "The list was cut short; there are more stored keys." +msgstr "La liste a été raccourcie ; d'autres clés sont enregistrées." + #. hostkey remove. msgctxt "cli.hk.removed" msgid "Removed the stored keys of {host}." diff --git a/l10n/cli/it.po b/l10n/cli/it.po index 32cfe3d..f814f3e 100644 --- a/l10n/cli/it.po +++ b/l10n/cli/it.po @@ -591,6 +591,21 @@ msgctxt "cli.hk.list_truncated" msgid "The list was cut short; refine the host name to see the rest." msgstr "L'elenco è stato troncato; affina il nome host per vedere il resto." +#. Column heading of hostkey list without a host (keep it short). +msgctxt "cli.hk.col_host" +msgid "Host" +msgstr "Host" + +#. hostkey list without a host and no stored key. +msgctxt "cli.hk.list_all_empty" +msgid "No host keys are stored." +msgstr "Non è salvata nessuna chiave host." + +#. hostkey list without a host stopped early because there are very many keys. +msgctxt "cli.hk.list_all_truncated" +msgid "The list was cut short; there are more stored keys." +msgstr "L'elenco è stato troncato; ci sono altre chiavi salvate." + #. hostkey remove. msgctxt "cli.hk.removed" msgid "Removed the stored keys of {host}." diff --git a/l10n/cli/pt-BR.po b/l10n/cli/pt-BR.po index f5b7dd7..7f22a50 100644 --- a/l10n/cli/pt-BR.po +++ b/l10n/cli/pt-BR.po @@ -591,6 +591,21 @@ msgctxt "cli.hk.list_truncated" msgid "The list was cut short; refine the host name to see the rest." msgstr "A lista foi encurtada; refine o nome do host para ver o restante." +#. Column heading of hostkey list without a host (keep it short). +msgctxt "cli.hk.col_host" +msgid "Host" +msgstr "Host" + +#. hostkey list without a host and no stored key. +msgctxt "cli.hk.list_all_empty" +msgid "No host keys are stored." +msgstr "Não há chaves de host salvas." + +#. hostkey list without a host stopped early because there are very many keys. +msgctxt "cli.hk.list_all_truncated" +msgid "The list was cut short; there are more stored keys." +msgstr "A lista foi encurtada; há mais chaves salvas." + #. hostkey remove. msgctxt "cli.hk.removed" msgid "Removed the stored keys of {host}." diff --git a/tests/apps/test_cli_args.c b/tests/apps/test_cli_args.c index 14ddac9..a0be8ed 100644 --- a/tests/apps/test_cli_args.c +++ b/tests/apps/test_cli_args.c @@ -159,7 +159,8 @@ SD_TEST(cli_parse_profile_hostkey_status) SD_CHECK(parse_line(L"sd hostkey list good.test", &a) == CLI_OK && a.cmd == CLI_CMD_HOSTKEY && wcscmp(a.subverb, L"list") == 0 && wcscmp(a.arg2, L"good.test") == 0); SD_CHECK(parse_line(L"sd hostkey list good.test --announced", &a) == CLI_OK && a.announced); - SD_CHECK(parse_line(L"sd hostkey list", &a) == CLI_E_MISSING_ARGUMENT); + SD_CHECK(parse_line(L"sd hostkey list", &a) == CLI_OK); /* 03 §6.1: the host is optional */ + SD_CHECK(parse_line(L"sd hostkey list --json", &a) == CLI_OK && a.json && a.arg2 == NULL); SD_CHECK(parse_line(L"sd hostkey list --announced", &a) == CLI_OK && a.announced && a.arg2 == NULL); SD_CHECK(parse_line(L"sd hostkey list h --type ed", &a) == CLI_E_UNKNOWN_OPTION && wcscmp(a.bad, L"--type") == 0); SD_CHECK(parse_line(L"sd hostkey import", &a) == CLI_E_MISSING_ARGUMENT); @@ -204,7 +205,8 @@ SD_TEST(cli_parse_hostkey_add_machine) SD_CHECK(parse_line(L"sd hostkey list h --machine", &a) == CLI_E_UNKNOWN_OPTION && wcscmp(a.bad, L"--machine") == 0); /* the service's pending list (M5): list --announced --machine, without a host */ SD_CHECK(parse_line(L"sd hostkey list --announced --machine", &a) == CLI_OK && a.announced && a.machine); - SD_CHECK(parse_line(L"sd hostkey list --machine", &a) == CLI_E_MISSING_ARGUMENT); + SD_CHECK(parse_line(L"sd hostkey list --machine", &a) == CLI_E_UNKNOWN_OPTION && + wcscmp(a.bad, L"--machine") == 0); /* a plain list has no machine form, with or without a host */ SD_CHECK(parse_line(L"sd hostkey remove h --expect SHA256:abcdefgh", &a) == CLI_E_UNKNOWN_OPTION); SD_CHECK(parse_line(L"sd hostkey accept-announced a-1 --machine", &a) == CLI_OK && a.machine); SD_CHECK(parse_line(L"sd hostkey accept-announced a-1 --expect SHA256:abcdefgh", &a) == CLI_E_UNKNOWN_OPTION); diff --git a/tests/apps/test_smoke.c b/tests/apps/test_smoke.c index 178977e..5ac3466 100644 --- a/tests/apps/test_smoke.c +++ b/tests/apps/test_smoke.c @@ -513,7 +513,11 @@ SD_TEST(smoke_hostkey_commands) EXPECT(RUN(L"hostkey list smokehk.test", NULL) == 0 && strstr(r.out, "ssh-ed25519") != NULL, "hostkey list text"); EXPECT(RUN(L"hostkey list nokey.test", NULL) == 0 && strstr(r.out, "No host key is stored") != NULL, "hostkey list empty"); - EXPECT(RUN(L"hostkey list", NULL) == 2, "hostkey list needs a host"); + /* hostkey list without a host (03 §6.1): every stored key of the user and machine stores */ + EXPECT(RUN(L"hostkey list --json", NULL) == 0 && strstr(r.out, "\"host\":\"smokehk.test\"") != NULL && + strstr(r.out, "\"store\":\"user\"") != NULL, "hostkey list all json"); + EXPECT(RUN(L"hostkey list", NULL) == 0 && strstr(r.out, "smokehk.test") != NULL && + strstr(r.out, "ssh-ed25519") != NULL, "hostkey list all text"); wcscpy_s(p, MAX_PATH, g_root); wcscat_s(p, MAX_PATH, L"\\hkimp\\known_hosts"); @@ -524,6 +528,8 @@ SD_TEST(smoke_hostkey_commands) EXPECT(RUN(L"hostkey list imp.test --json", NULL) == 0 && strstr(r.out, "\"store\":\"user\"") != NULL, "imported key is listed"); EXPECT(RUN(args, NULL) == 0 && strstr(r.out, "\"already_present\":1") != NULL, "import again keeps"); + EXPECT(RUN(L"hostkey list --json", NULL) == 0 && strstr(r.out, "\"host\":\"imp.test\"") != NULL && + strstr(r.out, "\"host\":\"smokehk.test\"") != NULL, "hostkey list all covers every host"); EXPECT(RUN(L"hostkey remove smokehk.test --json", NULL) == 0, "hostkey remove"); EXPECT(RUN(L"hostkey remove smokehk.test", NULL) == 3 && strstr(r.err, "No host key of") != NULL,