Skip to content

Commit 1587b41

Browse files
TheJoeIautclaude
andcommitted
Add a clipboard option to Generate Password
The Output setting chooses where the generated value goes: typed at the cursor as before, copied to the clipboard only, or both. A key configured before this existed carries no setting and keeps typing. Copying is done through the platform's own helper - clip.exe, xclip or wl-copy, pbcopy - reached by the same Shell helper the typing path uses, so the value travels on stdin and never appears in a process list. That helper is now shared rather than duplicated inside KeyboardTyper. The clipboard is written before the value is typed. Typing is the half that can fail on a missing helper, and having the value on the clipboard is what makes that recoverable instead of a lost password. Fixes an encoding bug found by testing the real clipboard rather than a substitute: CliWrap's PipeSource.FromString defaults to Console.InputEncoding, the ANSI code page on Windows, so anything outside it arrived mangled - "pässwörd-üñî" came back as "p?ssw?rd-???". The encoding is now stated as UTF-8. This was not only a clipboard problem: the Linux typing path pipes through the same helper into 'xdotool type --file -', which expects UTF-8, so a non-ASCII password would have been typed wrongly there too. Covered by unit tests over the three modes, the ordering, the pre-existing settings case and a failing clipboard, plus integration tests that round trip real values - symbols and non-ASCII included - through the actual clipboard and put its previous contents back. Linux typing re-verified in a container. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b7f0897 commit 1587b41

8 files changed

Lines changed: 374 additions & 32 deletions

File tree

‎BitwardenCLI.Tests/GenerateActionTests.cs‎

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,27 @@ public void The_generator_type_is_matched_case_insensitively()
196196
/// </summary>
197197
private static (Generate action, IBwCli cli, IKeyboardTyper typer, ISDConnection connection) Build(
198198
Action<Generate.PluginSettings>? configure = null, string generated = "gener4ted-p4ssword")
199+
{
200+
var built = BuildWithClipboard(configure, generated);
201+
return (built.action, built.cli, built.typer, built.connection);
202+
}
203+
204+
private static (Generate action, IBwCli cli, IKeyboardTyper typer, IClipboardWriter clipboard,
205+
ISDConnection connection) BuildWithClipboard(
206+
Action<Generate.PluginSettings>? configure = null, string generated = "gener4ted-p4ssword")
199207
{
200208
ISDConnection connection = Substitute.For<ISDConnection>();
201209
IBwCli cli = Substitute.For<IBwCli>();
202210
IKeyboardTyper typer = Substitute.For<IKeyboardTyper>();
211+
IClipboardWriter clipboard = Substitute.For<IClipboardWriter>();
203212

204213
cli.Run(Arg.Any<string[]>()).Returns(generated);
205214

206215
Generate.PluginSettings settings = Defaults();
207216
configure?.Invoke(settings);
208217

209-
return (new Generate(connection, TestPayloads.Initial(settings), cli, typer), cli, typer, connection);
218+
var action = new Generate(connection, TestPayloads.Initial(settings), cli, typer, clipboard);
219+
return (action, cli, typer, clipboard, connection);
210220
}
211221

212222
[Fact]
@@ -238,7 +248,8 @@ public async Task A_failing_cli_alerts_and_types_nothing()
238248
IKeyboardTyper typer = Substitute.For<IKeyboardTyper>();
239249
cli.Run(Arg.Any<string[]>()).Returns<string>(_ => throw new BwCliException("boom", 1));
240250

241-
var action = new Generate(connection, TestPayloads.Initial(Defaults()), cli, typer);
251+
var action = new Generate(connection, TestPayloads.Initial(Defaults()), cli, typer,
252+
Substitute.For<IClipboardWriter>());
242253

243254
await action.TypeGeneratedSecret();
244255

@@ -281,8 +292,101 @@ public void An_action_dropped_with_no_settings_persists_its_defaults()
281292
ISDConnection connection = Substitute.For<ISDConnection>();
282293

283294
_ = new Generate(connection, TestPayloads.Empty(), Substitute.For<IBwCli>(),
284-
Substitute.For<IKeyboardTyper>());
295+
Substitute.For<IKeyboardTyper>(), Substitute.For<IClipboardWriter>());
285296

286297
connection.Received(1).SetSettingsAsync(Arg.Any<Newtonsoft.Json.Linq.JObject>());
287298
}
299+
300+
// --- where the generated value goes ---
301+
302+
[Fact]
303+
public async Task By_default_the_value_is_typed_and_not_copied()
304+
{
305+
var (action, _, typer, clipboard, _) = BuildWithClipboard();
306+
307+
await action.TypeGeneratedSecret();
308+
309+
await typer.Received(1).TypeText("gener4ted-p4ssword");
310+
await clipboard.DidNotReceive().SetText(Arg.Any<string>());
311+
}
312+
313+
[Fact]
314+
public async Task Copy_to_clipboard_copies_without_typing()
315+
{
316+
// Typing a password into whichever window happens to be focused is exactly what
317+
// this mode exists to avoid.
318+
var (action, _, typer, clipboard, connection) = BuildWithClipboard(s => s.Output = "clipboard");
319+
320+
await action.TypeGeneratedSecret();
321+
322+
await clipboard.Received(1).SetText("gener4ted-p4ssword");
323+
await typer.DidNotReceive().TypeText(Arg.Any<string>());
324+
await connection.Received(1).ShowOk();
325+
}
326+
327+
[Fact]
328+
public async Task Type_and_copy_does_both()
329+
{
330+
var (action, _, typer, clipboard, _) = BuildWithClipboard(s => s.Output = "both");
331+
332+
await action.TypeGeneratedSecret();
333+
334+
await clipboard.Received(1).SetText("gener4ted-p4ssword");
335+
await typer.Received(1).TypeText("gener4ted-p4ssword");
336+
}
337+
338+
[Fact]
339+
public async Task The_value_reaches_the_clipboard_before_it_is_typed()
340+
{
341+
// Typing is the half that can fail on a missing helper; having the value on the
342+
// clipboard first is what makes that recoverable.
343+
var (action, _, typer, clipboard, _) = BuildWithClipboard(s => s.Output = "both");
344+
345+
await action.TypeGeneratedSecret();
346+
347+
Received.InOrder(() =>
348+
{
349+
clipboard.SetText("gener4ted-p4ssword");
350+
typer.TypeText("gener4ted-p4ssword");
351+
});
352+
}
353+
354+
[Fact]
355+
public async Task A_key_configured_before_this_option_existed_still_types()
356+
{
357+
// Those settings carry no output field at all.
358+
var (action, _, typer, clipboard, _) = BuildWithClipboard(s => s.Output = null!);
359+
360+
await action.TypeGeneratedSecret();
361+
362+
await typer.Received(1).TypeText("gener4ted-p4ssword");
363+
await clipboard.DidNotReceive().SetText(Arg.Any<string>());
364+
}
365+
366+
[Fact]
367+
public async Task A_failing_clipboard_alerts_and_does_not_type_the_value_anyway()
368+
{
369+
var (action, _, typer, clipboard, connection) = BuildWithClipboard(s => s.Output = "both");
370+
clipboard.SetText(Arg.Any<string>())
371+
.Returns(_ => throw new InvalidOperationException("'clip.exe' failed."));
372+
373+
await action.TypeGeneratedSecret();
374+
375+
await connection.Received(1).ShowAlert();
376+
await connection.DidNotReceive().ShowOk();
377+
await typer.DidNotReceive().TypeText(Arg.Any<string>());
378+
}
379+
380+
[Theory]
381+
[InlineData("ClipBoard", false, true)]
382+
[InlineData("BOTH", true, true)]
383+
[InlineData("Type", true, false)]
384+
public void The_output_mode_is_matched_case_insensitively(string output, bool types, bool copies)
385+
{
386+
Generate.PluginSettings settings = Defaults();
387+
settings.Output = output;
388+
389+
Assert.Equal(types, settings.TypesResult);
390+
Assert.Equal(copies, settings.CopiesResult);
391+
}
288392
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using System.Runtime.InteropServices;
2+
using CliWrap;
3+
using CliWrap.Buffered;
4+
using Xunit;
5+
6+
namespace BitwardenStreamdeckPlugin.Tests.Integration;
7+
8+
/// <summary>
9+
/// Exercises the real clipboard. Whether a generated password survives the trip through
10+
/// the platform helper cannot be answered by a substitute, and the characters a generator
11+
/// produces are exactly the ones a naive implementation mangles.
12+
///
13+
/// The clipboard belongs to whoever is at the machine, so its contents are put back
14+
/// afterwards. Marked Integration and excluded from the default run for that reason.
15+
/// </summary>
16+
[Trait("Category", "Integration")]
17+
public class ClipboardTests
18+
{
19+
private static bool OnWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
20+
21+
private static async Task<string> ReadClipboard()
22+
{
23+
BufferedCommandResult result = await Cli.Wrap("powershell")
24+
.WithArguments(new[] { "-NoProfile", "-Command", "Get-Clipboard -Raw" })
25+
.WithValidation(CommandResultValidation.None)
26+
.ExecuteBufferedAsync();
27+
28+
return result.StandardOutput.TrimEnd('\r', '\n');
29+
}
30+
31+
private static async Task WriteClipboard(string text)
32+
{
33+
await new ClipboardWriter().SetText(text);
34+
}
35+
36+
[SkippableTheory]
37+
[InlineData("gener4ted-p4ssword")]
38+
[InlineData("p@ssw0rd!#$%^&*()_+-=[]{}|;':\",./<>?")]
39+
[InlineData("correct-horse-battery-staple")]
40+
[InlineData("pässwörd-üñî")]
41+
public async Task A_generated_value_survives_the_clipboard_intact(string secret)
42+
{
43+
Skip.IfNot(OnWindows, "Windows only; the Linux and macOS helpers need a session to talk to");
44+
45+
string original = await ReadClipboard();
46+
47+
try
48+
{
49+
await WriteClipboard(secret);
50+
51+
Assert.Equal(secret, await ReadClipboard());
52+
}
53+
finally
54+
{
55+
await RestoreClipboard(original);
56+
}
57+
}
58+
59+
[SkippableFact]
60+
public async Task An_empty_value_leaves_the_clipboard_alone()
61+
{
62+
Skip.IfNot(OnWindows, "Windows only");
63+
64+
string marker = "clipboard-should-not-change-" + Guid.NewGuid().ToString("N");
65+
string original = await ReadClipboard();
66+
67+
try
68+
{
69+
await WriteClipboard(marker);
70+
await new ClipboardWriter().SetText("");
71+
72+
Assert.Equal(marker, await ReadClipboard());
73+
}
74+
finally
75+
{
76+
await RestoreClipboard(original);
77+
}
78+
}
79+
80+
/// <summary>
81+
/// Puts the clipboard back as it was found - including leaving it empty, which an
82+
/// earlier version of this got wrong and left a test value sitting there.
83+
/// </summary>
84+
private static async Task RestoreClipboard(string original)
85+
{
86+
if (!string.IsNullOrEmpty(original))
87+
{
88+
await WriteClipboard(original);
89+
return;
90+
}
91+
92+
await Cli.Wrap("powershell")
93+
.WithArguments(new[] { "-NoProfile", "-Command", "Set-Clipboard -Value $null" })
94+
.WithValidation(CommandResultValidation.None)
95+
.ExecuteAsync();
96+
}
97+
}

‎BitwardenCLI/ClipboardWriter.cs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using System.Threading.Tasks;
4+
5+
namespace BitwardenStreamdeckPlugin
6+
{
7+
/// <summary>
8+
/// Copies text to the clipboard through whichever helper the platform provides.
9+
/// Like the keyboard layer, the value goes over stdin so it never appears in the
10+
/// process list.
11+
/// </summary>
12+
internal sealed class ClipboardWriter : IClipboardWriter
13+
{
14+
internal static IClipboardWriter Shared { get; set; } = new ClipboardWriter();
15+
16+
public async Task SetText(string text)
17+
{
18+
if (string.IsNullOrEmpty(text))
19+
{
20+
return;
21+
}
22+
23+
if (OperatingSystem.IsWindows())
24+
{
25+
// Ships with Windows and handles symbols and non-ASCII correctly.
26+
await Shell.Run("clip.exe", Array.Empty<string>(), text);
27+
return;
28+
}
29+
30+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
31+
{
32+
await Shell.Run(LinuxClipboardTool(), LinuxClipboardArguments(), text);
33+
return;
34+
}
35+
36+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
37+
{
38+
await Shell.Run("pbcopy", Array.Empty<string>(), text);
39+
return;
40+
}
41+
42+
throw new PlatformNotSupportedException("Copying to the clipboard is not supported on this platform");
43+
}
44+
45+
/// <summary>
46+
/// wl-copy under Wayland, xclip under X11 - the same split the typing helpers use.
47+
/// </summary>
48+
internal static string LinuxClipboardTool()
49+
{
50+
return string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))
51+
? "xclip"
52+
: "wl-copy";
53+
}
54+
55+
internal static string[] LinuxClipboardArguments()
56+
{
57+
// xclip writes to the primary selection unless told otherwise; wl-copy already
58+
// targets the clipboard.
59+
return string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY"))
60+
? new[] { "-selection", "clipboard" }
61+
: Array.Empty<string>();
62+
}
63+
}
64+
}

‎BitwardenCLI/Generate.cs‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static PluginSettings CreateDefaultSettings()
3030
return new PluginSettings
3131
{
3232
GeneratorType = "password",
33+
Output = "type",
3334
Length = 14,
3435
Uppercase = true,
3536
Lowercase = true,
@@ -49,6 +50,21 @@ public static PluginSettings CreateDefaultSettings()
4950
[JsonProperty(PropertyName = "generatortype")]
5051
public string GeneratorType { get; set; }
5152

53+
/// <summary>
54+
/// Where the generated value goes: "type" at the cursor, "clipboard" only, or
55+
/// "both". Anything unrecognised - including the empty value a key configured
56+
/// before this option carries - types, which is what the action always did.
57+
/// </summary>
58+
[JsonProperty(PropertyName = "output")]
59+
public string Output { get; set; }
60+
61+
internal bool TypesResult =>
62+
!string.Equals(Output, "clipboard", StringComparison.OrdinalIgnoreCase);
63+
64+
internal bool CopiesResult =>
65+
string.Equals(Output, "clipboard", StringComparison.OrdinalIgnoreCase)
66+
|| string.Equals(Output, "both", StringComparison.OrdinalIgnoreCase);
67+
5268
[JsonProperty(PropertyName = "length")]
5369
public int Length { get; set; }
5470

@@ -94,19 +110,22 @@ public static PluginSettings CreateDefaultSettings()
94110
private readonly PluginSettings settings;
95111
private readonly IBwCli cli;
96112
private readonly IKeyboardTyper typer;
113+
private readonly IClipboardWriter clipboard;
97114

98115
#endregion
99116

100117
public Generate(ISDConnection connection, InitialPayload payload)
101-
: this(connection, payload, BwCli.Shared, KeyboardTyper.Shared)
118+
: this(connection, payload, BwCli.Shared, KeyboardTyper.Shared, ClipboardWriter.Shared)
102119
{
103120
}
104121

105-
internal Generate(ISDConnection connection, InitialPayload payload, IBwCli cli, IKeyboardTyper typer)
122+
internal Generate(ISDConnection connection, InitialPayload payload, IBwCli cli, IKeyboardTyper typer,
123+
IClipboardWriter clipboard)
106124
: base(connection, payload)
107125
{
108126
this.cli = cli;
109127
this.typer = typer;
128+
this.clipboard = clipboard;
110129

111130
if (payload.Settings == null || payload.Settings.Count == 0)
112131
{
@@ -137,7 +156,20 @@ internal async Task TypeGeneratedSecret()
137156
try
138157
{
139158
string secret = await GenerateSecret();
140-
await typer.TypeText(secret);
159+
160+
// Copied before it is typed: typing is the part that can fail on a missing
161+
// helper, and having the value on the clipboard is what makes that
162+
// recoverable rather than a lost password.
163+
if (settings.CopiesResult)
164+
{
165+
await clipboard.SetText(secret);
166+
}
167+
168+
if (settings.TypesResult)
169+
{
170+
await typer.TypeText(secret);
171+
}
172+
141173
await Connection.ShowOk();
142174
}
143175
catch (Exception ex)

‎BitwardenCLI/IClipboardWriter.cs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Threading.Tasks;
2+
3+
namespace BitwardenStreamdeckPlugin
4+
{
5+
/// <summary>
6+
/// Puts text on the system clipboard. Exists so the actions can be tested without
7+
/// touching the real clipboard.
8+
/// </summary>
9+
internal interface IClipboardWriter
10+
{
11+
Task SetText(string text);
12+
}
13+
}

0 commit comments

Comments
 (0)