Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion analytics-events.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,25 @@
"engine_log_tail": { "type": "string", "note": "Deferred reports only: last engine diagnostic lines captured with the crash" }
},
"pii": false
},
"ui_action": {
"description": "RFC 0001 2026-10-28 amendment: the user activated a control in the main UI (anything outside the settings windows). Opt-in gated like every event.",
"properties": {
"surface": { "type": "string", "enum": ["main_toolbar", "status_bar", "ime_toolbar"], "description": "ime_toolbar = the keyboard (direct-entry) mode mini-bar. status_bar also covers the bottom message-pane header (speak / copy / new / paste / enter-direct-mode controls). The RFC's ime_topbar surface has no Windows equivalent and is therefore never emitted." },
"action": { "type": "string", "description": "Fixed vocabulary; new identifiers require a schema update", "enum": ["new", "open", "save", "settings", "copy", "copy_selection", "cut", "paste", "select_all", "speak", "stop_speak", "speed_up", "speed_down", "layout", "direct_mode", "game_mode", "control_mode", "reanchor", "hide"] },
"state": { "type": "string", "enum": ["on", "off", "right", "left", "bottom", "top"], "description": "Toggles (direct_mode, game_mode, control_mode, hide) carry on/off; the layout action carries the pane position" }
},
"pii": false
},
"setting_changed": {
"description": "RFC 0001 2026-10-28 amendment: the user committed a settings change at the widget site. Programmatic changes (engine init, defaults, migrations) never emit this.",
"properties": {
"setting_id": { "type": "string", "description": "Canonical identifier: the DasherCore parameter display name (e.g. Max Speed (bits/sec)) or the frontend setting key (e.g. output_font_size, access_method). Locale caveat: engine parameter display names follow the UI locale (RFC 0003), so the same setting may report under its translated name — frontend keys are locale-stable. Speech engine credentials are deliberately excluded (secrets).", "example": "Max Speed (bits/sec)" },
"value_type": { "type": "string", "enum": ["bool", "number", "enum", "string"] },
"value": { "type": ["boolean", "number", "string"], "description": "Present only for bool/number/enum values from a fixed vocabulary. OMITTED for string settings - free text is never collected" },
"surface": { "type": "string", "enum": ["settings", "main_ui", "ime"], "description": "Where the change was made. Windows currently emits settings and main_ui only" }
},
"pii": false
}
},
"explicitly_not_collected": [
Expand All @@ -70,6 +89,8 @@
"User name, email, or account info",
"IP address (scrubbed at ingest)",
"Training text contents",
"Game mode target text"
"Game mode target text",
"Values of free-text/string settings (2026-10-28 amendment: only the fact that such a setting changed is collected)",
"File paths of any kind (2026-10-28 amendment)"
]
}
208 changes: 186 additions & 22 deletions src/Dasher.Windows/Controls/SettingsPanel.cs

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/Dasher.Windows/Services/AnalyticsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,45 @@ public static void Capture(string eventName, Dictionary<string, object>? propert
catch { }
}

// ── RFC 0001, 2026-10-28 amendment: UI-interaction telemetry ─────────────
//
// Both events answer the UX team's question (which settings are the
// priority, where should main-UI focus go) and fire only after the
// existing opt-in. See analytics-events.json for the published schema —
// if it's not in the schema, it's not collected.

/// <summary>
/// The user activated a control in the main UI (outside settings).
/// Surfaces: main_toolbar, status_bar, ime_toolbar (the keyboard-mode
/// mini-bar). Fixed action vocabulary — declared in analytics-events.json.
/// </summary>
public static void CaptureUiAction(string surface, string action, string? state = null)
{
var props = new Dictionary<string, object> { ["surface"] = surface, ["action"] = action };
if (state != null) props["state"] = state;
Capture("ui_action", props);
}

/// <summary>
/// The user committed a settings change at the widget site. NEVER emit
/// for programmatic changes (engine init, defaults, migrations) — the
/// amendment explicitly scopes this to user commits. Values are included
/// only for bool/number/enum; string settings report the fact of the
/// change only (privacy: no free text, no file paths).
/// </summary>
public static void CaptureSettingChanged(string settingId, string valueType, object? value, string surface = "settings")
{
var props = new Dictionary<string, object>
{
["setting_id"] = settingId,
["value_type"] = valueType,
["surface"] = surface,
};
if (valueType != "string" && value != null)
props["value"] = value;
Capture("setting_changed", props);
}

// ── Engine log ring buffer (RFC 0009) ──────────────────────────────────────

/// <summary>
Expand Down
122 changes: 122 additions & 0 deletions src/Dasher.Windows/Services/PickerSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System;
using System.Runtime.InteropServices;

namespace Dasher.Windows.Services;

/// <summary>
/// One place for "a native modal dialog is about to run" coordination:
/// normalizes the owner window (WindowDialogStyles) and suspends the
/// keyboard-mode target tracking while the dialog is up.
///
/// The tracker suspension matters: with the hang fixed, the dialog CAN
/// take foreground for the first time in keyboard mode — and the
/// foreground-change machinery (WinEvent hooks, the window's Deactivated
/// handler) would otherwise record the DIALOG as the typing target, then
/// re-seed predictions from its controls. Worse, after the dialog closes
/// the recorded target is a dead HWND and the first injected characters
/// go nowhere until the user re-clicks their document (review loop 1 on
/// the picker-hang fix).
///
/// MainWindow registers the one suspend/resume pair for the app's single
/// window; resume runs BEFORE the window styles come back so the previous
/// typing target can be given foreground while Dasher can still activate.
/// Handlers are invoked isolated: one throwing handler cannot abort the
/// session and resurrect the hang (or the focus regression).
/// </summary>
public static class PickerSession
{
private static event Action? SuspendHandlers;
private static event Action? ResumeHandlers;

/// <summary>Session depth: nested Begins (defensively impossible while
/// the modal disables the owner, but the invariant is now stated rather
/// than lucky) suspend once and resume when the LAST session ends — an
/// inner dispose must not re-arm tracking under a still-live dialog
/// (review loop 3).</summary>
private static int _depth;

/// <summary>Diagnostics sink. MainWindow wires this to the keyboard
/// debug log: a swallowed handler exception must be visible in field
/// logs, not only in debug builds (review loop 3).</summary>
internal static Action<string>? Log { private get; set; }

/// <summary>Register the app-side suspend/resume actions (idempotent per delegate pair).</summary>
public static void Register(Action suspend, Action resume)
{
SuspendHandlers -= suspend;
ResumeHandlers -= resume;
SuspendHandlers += suspend;
ResumeHandlers += resume;
}

public static void Unregister(Action suspend, Action resume)
{
SuspendHandlers -= suspend;
ResumeHandlers -= resume;
}

/// <summary>Wrap a native picker invocation: suspend tracking, normalize
/// the owner; dispose resumes tracking (previous target back to
/// foreground) and then restores the window styles.</summary>
public static IDisposable Begin(IntPtr ownerHwnd)
{
if (_depth == 0)
InvokeIsolated(SuspendHandlers);
_depth++;

var styleRestore = (IDisposable)NoopDisposable.Instance;
try
{
styleRestore = WindowDialogStyles.PickDialogFriendly(ownerHwnd);
}
catch
{
// Never leave tracking suspended with no dialog to wait for.
if (--_depth == 0) InvokeIsolated(ResumeHandlers);
throw;
}

return new Session(() =>
{
try
{
if (--_depth == 0) InvokeIsolated(ResumeHandlers);
}
finally
{
// Always: a throwing resume handler must not leave the
// window activatable in keyboard mode (focus stealing).
styleRestore.Dispose();
}
});
}

private static void InvokeIsolated(Action? handlers)
{
if (handlers is null) return;
foreach (var d in handlers.GetInvocationList())
{
try
{
((Action)d)();
}
catch (Exception ex)
{
// One bad handler must not defeat the session mechanism.
Log?.Invoke($"PickerSession handler threw: {ex.Message} ({ex.GetType()})");
System.Diagnostics.Debug.WriteLine($"PickerSession handler threw: {ex}");
}
}
}

private sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}

private sealed class Session(Action dispose) : IDisposable
{
public void Dispose() => dispose();
}
}
131 changes: 131 additions & 0 deletions src/Dasher.Windows/Services/WindowDialogStyles.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System;
using System.Runtime.InteropServices;

namespace Dasher.Windows.Services;

/// <summary>
/// Makes the main window a viable owner for native modal file dialogs.
///
/// In keyboard (direct-entry) mode the window carries WS_EX_NOACTIVATE,
/// WS_EX_LAYERED and TOPMOST so it floats over the target application
/// without stealing keystrokes. A Win32 common dialog owned by such a
/// window never surfaces: it cannot take activation from a NOACTIVATE
/// owner and is born behind the topmost one — IFileDialog::Show then
/// pumps invisibly on the UI thread forever. To the user the app is a
/// frozen white screen ("export crashes the app", v0.1.33 tester report)
/// that only Task Manager can end — reproduced locally with the picker
/// invoked from keyboard mode.
///
/// PickDialogFriendly temporarily clears WS_EX_NOACTIVATE and drops the
/// window out of the topmost band (restored by the returned dispose);
/// WS_EX_LAYERED is deliberately left alone — it does not block the
/// dialog and clearing it would break Window.Opacity. Pure Win32: the
/// Avalonia Topmost/Opacity properties are left alone too — they are
/// re-applied by the mode logic and must not fight the dialog.
/// The restore is surgical (only the NOACTIVATE bit and the topmost band
/// are touched), so a style another actor changed while the dialog was up
/// survives.
/// </summary>
public static class WindowDialogStyles
{
internal const int GWL_EXSTYLE = -20;
internal const long WS_EX_TOPMOST = 0x00000008;
internal const long WS_EX_NOACTIVATE = 0x08000000;
internal const long WS_EX_LAYERED = 0x00080000;

// 64-bit entry points; user32.dll has no *Ptr variants on 32-bit, so
// the accessors switch on IntPtr.Size (the pattern MainWindow used to
// duplicate — this is now the single source of truth for both).
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
private static extern IntPtr GetWindowLongPtr64(IntPtr hWnd, int nIndex);

[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong);

[DllImport("user32.dll", EntryPoint = "GetWindowLong")]
private static extern int GetWindowLong32(IntPtr hWnd, int nIndex);

[DllImport("user32.dll", EntryPoint = "SetWindowLong")]
private static extern int SetWindowLong32(IntPtr hWnd, int nIndex, int dwNewLong);

[DllImport("user32.dll")]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);

[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);

private static readonly IntPtr HWND_NOTOPMOST = new(-2);
private static readonly IntPtr HWND_TOPMOST = new(-1);
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOSIZE = 0x0001;
private const uint SWP_NOACTIVATE = 0x0010;

internal static long GetExStyle(IntPtr hwnd) =>
IntPtr.Size == 8 ? GetWindowLongPtr64(hwnd, GWL_EXSTYLE).ToInt64() : GetWindowLong32(hwnd, GWL_EXSTYLE);

internal static long SetExStyle(IntPtr hwnd, long style) =>
IntPtr.Size == 8
? SetWindowLongPtr64(hwnd, GWL_EXSTYLE, (IntPtr)style).ToInt64()
: SetWindowLong32(hwnd, GWL_EXSTYLE, (int)style);

internal static bool TrySetForeground(IntPtr hwnd) => hwnd != IntPtr.Zero && SetForegroundWindow(hwnd);

/// <summary>
/// Clear NOACTIVATE, leave the topmost band and bring the window to the
/// foreground so the dialog it owns can activate and show above it.
/// </summary>
/// <returns>An IDisposable that restores the previous state. Never null;
/// a zero/missing handle — or a window that already dialog-friendly
/// (editor mode) — is a no-op.</returns>
public static IDisposable PickDialogFriendly(IntPtr hwnd)
{
if (hwnd == IntPtr.Zero) return Noop;

var exStyle = GetExStyle(hwnd);
var wasTopmost = (exStyle & WS_EX_TOPMOST) != 0;
var hadNoActivate = (exStyle & WS_EX_NOACTIVATE) != 0;
if (!hadNoActivate && !wasTopmost)
{
// Editor-mode window: nothing to normalize, and no foreground
// push either — the user just clicked a button in this window.
return Noop;
}

if (hadNoActivate) SetExStyle(hwnd, exStyle & ~WS_EX_NOACTIVATE);
if (wasTopmost) SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
SetForegroundWindow(hwnd);
return new Restore(hwnd, hadNoActivate, wasTopmost);
}

private sealed class Restore(IntPtr hwnd, bool restoreNoActivate, bool restoreTopmost) : IDisposable
{
public void Dispose()
{
try
{
// Surgical: only the bits PickDialogFriendly cleared come
// back; anything another actor changed meanwhile survives.
if (restoreNoActivate)
{
var cur = GetExStyle(hwnd);
if ((cur & WS_EX_NOACTIVATE) == 0) SetExStyle(hwnd, cur | WS_EX_NOACTIVATE);
}
if (restoreTopmost && (GetExStyle(hwnd) & WS_EX_TOPMOST) == 0)
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
}
catch
{
// Never throw from a dispose on the UI path; worst case the
// window keeps the dialog-friendly styles until the next
// keyboard-mode change re-applies them.
}
}
}

private sealed class NoopDisposable : IDisposable
{
public void Dispose() { }
}

private static readonly NoopDisposable Noop = new();
}
6 changes: 3 additions & 3 deletions src/Dasher.Windows/Views/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -346,14 +346,14 @@

<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="ui-nav-label" Text="Auto"/>
<ToggleSwitch IsChecked="{Binding AutoSpeed}" OnContent="" OffContent="" MinWidth="0" Height="26" VerticalAlignment="Center"/>
<ToggleSwitch x:Name="AutoSpeedToggle" IsChecked="{Binding AutoSpeed}" OnContent="" OffContent="" MinWidth="0" Height="26" VerticalAlignment="Center"/>
</StackPanel>

<Rectangle Width="1" Height="24" Fill="{DynamicResource BorderLight}" Margin="8,0"/>

<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="ui-nav-label" Text="Learning"/>
<ToggleSwitch IsChecked="{Binding Learning}" OnContent="" OffContent="" MinWidth="0" Height="26" VerticalAlignment="Center"/>
<ToggleSwitch x:Name="LearningToggle" IsChecked="{Binding Learning}" OnContent="" OffContent="" MinWidth="0" Height="26" VerticalAlignment="Center"/>
</StackPanel>

<Rectangle Width="1" Height="24" Fill="{DynamicResource BorderLight}" Margin="8,0"/>
Expand Down Expand Up @@ -389,7 +389,7 @@
<LucideIcon Kind="{x:Static lucide:LucideIconKind.ChevronLeft}" Size="16" Foreground="{DynamicResource TextMuted}"/>
</Button>

<Button Classes="ui-circle-btn" Click="OnToggleMode" ToolTip.Tip="Toggle pane position">
<Button x:Name="PaneEyeModeBtn" Classes="ui-circle-btn" Click="OnToggleMode" ToolTip.Tip="Toggle pane position">
<LucideIcon Kind="{x:Static lucide:LucideIconKind.Eye}" Size="18" Foreground="{DynamicResource TextSecondary}"/>
</Button>

Expand Down
Loading
Loading