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-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; } 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_editor.c b/apps/shelldrive-ui/ui_editor.c index e3896cd..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,9 +31,11 @@ #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" +#include "ui_win.h" #define ED_WIDTH 300 /* dialog units; every page of the sheet has this width */ #define ED_PAGES 5 @@ -42,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 }; @@ -67,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 { @@ -540,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) @@ -553,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: { @@ -560,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; @@ -587,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; } @@ -702,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; @@ -740,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]); @@ -823,7 +979,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_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_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/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/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/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/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/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: 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/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" 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) { 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, 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 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);