RemSound's password boxes are deliberately NOT PasswordChar-masked (a screen-reader user can't see a mask), so the key-click hook's "is this a password field?" check (UseSystemPasswordChar / PasswordChar) was always false and the distinct passkey.wav never played. Password fields now mark themselves with Tag = KeyClickService.PasswordFieldTag, and the hook checks that (keeping the masking-flag check as a fallback). Tagged both password fields - ProfilePasswordDialog (which all password entry routes through, including the send/receive streaming gate) and ProfilePasswordManagerDialog. So a key click + passkey now layer together on every password keystroke. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.9 KiB
C#
113 lines
4.9 KiB
C#
namespace RemSound.App;
|
||
|
||
/// <summary>
|
||
/// Small dialog for viewing and changing the active profile's encryption password. Shows the
|
||
/// current password in a PLAIN (readable) edit box, not a masked one, on purpose: this audience
|
||
/// uses a screen reader, and a masked password field reads as a run of bullets, which is
|
||
/// useless. Letting NVDA read the actual characters is far more usable, and the security model
|
||
/// already accepts that the password is recoverable from the profile file anyway.
|
||
///
|
||
/// Returns the new password on OK (which may be empty — that clears the password), or null on
|
||
/// Cancel. The result is trimmed so an invisible trailing space can't cause a maddening
|
||
/// "we typed the same password but it won't connect" mismatch between two peers. 2026-05-31.
|
||
/// </summary>
|
||
internal static class ProfilePasswordDialog
|
||
{
|
||
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
|
||
{
|
||
using var dialog = new Form
|
||
{
|
||
Text = "Change profile password",
|
||
StartPosition = FormStartPosition.CenterParent,
|
||
FormBorderStyle = FormBorderStyle.FixedDialog,
|
||
MinimizeBox = false,
|
||
MaximizeBox = false,
|
||
ShowInTaskbar = false,
|
||
ClientSize = new Size(460, 150),
|
||
AccessibleName = "Change profile password",
|
||
};
|
||
|
||
var label = new Label
|
||
{
|
||
Text = $"Password for profile “{profileTitle}”:",
|
||
AutoSize = true,
|
||
};
|
||
var textBox = new TextBox
|
||
{
|
||
Text = currentPassword,
|
||
Dock = DockStyle.Top,
|
||
Width = 400,
|
||
AccessibleName = $"Password for profile {profileTitle}",
|
||
// Mark as a password field so the key-click hook layers in the passkey sound.
|
||
Tag = KeyClickService.PasswordFieldTag,
|
||
};
|
||
var hint = new Label
|
||
{
|
||
Text = "Both you and the person you're connecting to must use the same password.",
|
||
AutoSize = true,
|
||
};
|
||
|
||
var okButton = new Button { Text = "OK", AutoSize = true };
|
||
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||
|
||
// OK is validated by hand (no auto-close DialogResult) so we can block an empty entry when
|
||
// a password is being REQUIRED. The trigger is the OK/Enter action, not typing — and
|
||
// deliberately clearing an already-set password to blank is still allowed, because that
|
||
// path passes requireNonEmpty: false. So this only catches "was asked for a password,
|
||
// entered nothing, pressed OK", which used to silently leave audio dead.
|
||
void TryAccept()
|
||
{
|
||
if (requireNonEmpty && textBox.Text.Trim().Length == 0)
|
||
{
|
||
var page = new TaskDialogPage
|
||
{
|
||
Caption = "Password required",
|
||
Heading = "Enter a password",
|
||
Text = "A password is required before audio can flow. You and the person you're connecting to must use the same one.",
|
||
Icon = TaskDialogIcon.Warning,
|
||
Buttons = { TaskDialogButton.OK },
|
||
DefaultButton = TaskDialogButton.OK,
|
||
AllowCancel = true,
|
||
};
|
||
TaskDialog.ShowDialog(dialog, page);
|
||
textBox.Focus();
|
||
textBox.SelectAll();
|
||
return;
|
||
}
|
||
dialog.DialogResult = DialogResult.OK;
|
||
dialog.Close();
|
||
}
|
||
|
||
okButton.Click += (_, _) => TryAccept();
|
||
textBox.KeyDown += (_, args) =>
|
||
{
|
||
if (args.KeyCode == Keys.Enter)
|
||
{
|
||
args.Handled = true;
|
||
args.SuppressKeyPress = true;
|
||
TryAccept();
|
||
}
|
||
};
|
||
|
||
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), RowCount = 4, ColumnCount = 1 };
|
||
panel.Controls.Add(label, 0, 0);
|
||
panel.Controls.Add(textBox, 0, 1);
|
||
panel.Controls.Add(hint, 0, 2);
|
||
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
|
||
buttons.Controls.Add(okButton);
|
||
buttons.Controls.Add(cancelButton);
|
||
panel.Controls.Add(buttons, 0, 3);
|
||
dialog.Controls.Add(panel);
|
||
dialog.AcceptButton = okButton;
|
||
dialog.CancelButton = cancelButton;
|
||
|
||
// Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
|
||
// sitting minimised in the tray — e.g. a quick profile switch to a passwordless-but-
|
||
// streaming profile trips the password gate mid-switch, and the user must be able to read
|
||
// and answer it there and then. Centres on screen, forces focus, then closes.
|
||
return ForegroundDialog.Show(owner => dialog.ShowDialog(owner)) == DialogResult.OK
|
||
? textBox.Text.Trim()
|
||
: null;
|
||
}
|
||
}
|