-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathServerViewModel.cs
More file actions
517 lines (449 loc) · 17.6 KB
/
Copy pathServerViewModel.cs
File metadata and controls
517 lines (449 loc) · 17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
using System.ComponentModel;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using Sample.Maui.Pages;
using Sample.Maui.Server;
using Shiny.Net.HttpServer;
using Shiny.Net.HttpServer.Ssh;
namespace Sample.Maui.ViewModels;
/// <summary>
/// The Server tab: what is running, where it can be reached, and the buttons that change that.
/// <para>
/// The only interesting part is the threading. <see cref="QuickTunnel"/> raises its changes from the
/// background — a reconnect happens whenever the network does — and MAUI will not marshal that for
/// you. <see cref="IMainThread"/> is Shiny's abstraction over that hop; it exists because MAUI's own
/// <c>MainThread.InvokeOnMainThreadAsync</c> misbehaves on some desktop targets.
/// </para>
/// </summary>
[ShellMap<ServerPage>("Server", registerRoute: false)]
public partial class ServerViewModel(
HttpServer server,
QuickTunnel tunnel,
RequestLog log,
CredentialStore credentials,
IDialogs dialogs,
IMainThread mainThread
) : ObservableObject, IPageLifecycleAware
{
/// <summary>Live while a share is being opened, so the user can call it off.</summary>
CancellationTokenSource? sharing;
[ObservableProperty]
string status = "Stopped";
/// <summary>
/// The address to hand a customer. Null until the tunnel is up, and it changes on every
/// reconnect — which is exactly why the view binds to it instead of reading it once.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasPublicUrl))]
[NotifyPropertyChangedFor(nameof(CanStopSharing))]
[NotifyPropertyChangedFor(nameof(McpUrl))]
[NotifyPropertyChangedFor(nameof(FilesUrl))]
[NotifyPropertyChangedFor(nameof(PublicWebDavUrl))]
string? publicUrl;
/// <summary>The address on this Wi-Fi network. Works with no internet at all.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(McpUrl))]
[NotifyPropertyChangedFor(nameof(FilesUrl))]
[NotifyPropertyChangedFor(nameof(LocalWebDavUrl))]
string? localUrl;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
string? lastError;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(NotBusy))]
bool busy;
[ObservableProperty]
int requestCount;
/// <summary>
/// The account a visitor is asked for, editable in place. It sits on this screen because the
/// prompt is one tap away from it — every address above opens a browser that asks for this, and
/// a password kept on another tab is a password typed wrong.
/// <para>
/// An edit takes effect on the very next request. Nothing restarts: the validator is a service
/// that reads the current value rather than a list of accounts handed over at startup.
/// </para>
/// </summary>
[ObservableProperty]
string username = string.Empty;
/// <summary>
/// Deliberately not masked. On a device you are handing a link to, someone has to read this out;
/// a product with real accounts would not show it at all.
/// </summary>
[ObservableProperty]
string password = string.Empty;
/// <summary>Where the password ended up, which is not always where it was meant to.</summary>
[ObservableProperty]
string storageStatus = string.Empty;
public bool HasPublicUrl => this.PublicUrl is { Length: > 0 };
public bool HasError => this.LastError is { Length: > 0 };
public bool NotBusy => !this.Busy;
/// <summary>
/// What to paste into an MCP client. The public address when the tunnel is up, because that is
/// the one that works from wherever the client happens to be running; otherwise the LAN address.
/// </summary>
public string? McpUrl => (this.PublicUrl ?? this.LocalUrl) is { Length: > 0 } url
? $"{url.TrimEnd('/')}/mcp"
: null;
/// <summary>
/// The file browser over the app's own storage — a JSON listing a script or a browser can walk,
/// which is the same directory the WebDAV mount below exposes as a drive.
/// <para>
/// The address on this network comes first, the same way round as the mount and for the same
/// reason: this hands out the contents of the device's storage, and the tunnel is cleartext HTTP
/// through a shared host.
/// </para>
/// </summary>
public string? FilesUrl => (this.LocalUrl ?? this.PublicUrl) is { Length: > 0 } url
? $"{url.TrimEnd('/')}/files"
: null;
/// <summary>
/// The mount on this network — what to type into Finder's "Connect to Server" or Explorer's
/// "Map network drive", and what to tap to browse it from the phone itself.
/// <para>
/// Offered before <see cref="PublicWebDavUrl"/>, the opposite way round from <see cref="McpUrl"/>.
/// A mounted drive is a long-lived connection that sends the password on every request, so the
/// one that stays on the Wi-Fi is the one to prefer — the tunnel is cleartext HTTP to a shared
/// host.
/// </para>
/// </summary>
public string? LocalWebDavUrl => DavUrl(this.LocalUrl);
/// <summary>
/// The same mount through the tunnel, for the desktop that is not on this Wi-Fi — or for
/// continuity, where the link opens on whichever machine picks the handoff up.
/// </summary>
public string? PublicWebDavUrl => DavUrl(this.PublicUrl);
/// <summary>
/// The mount's collection URL. The trailing slash is deliberate: it is the address a browser
/// should sit at while it walks the listing, and the one clients resolve member links against.
/// </summary>
static string? DavUrl(string? url) => url is { Length: > 0 }
? $"{url.TrimEnd('/')}/dav/"
: null;
/// <summary>
/// Whether there is anything to stop — which includes a share still being opened.
/// <para>
/// Not the same as <see cref="HasPublicUrl"/>, and the difference is the whole point: opening a
/// tunnel takes seconds and can fail, and while it was in flight every button on the screen was
/// disabled — the action ones by <see cref="Busy"/>, this one by there being no URL yet. The
/// screen had no way out of its own waiting state.
/// </para>
/// </summary>
public bool CanStopSharing => this.sharing is not null || this.HasPublicUrl;
/// <summary>
/// Subscriptions live for as long as the tab is on screen. The view model outlives one visit —
/// Shell keeps a tab's page around — so subscribing in the constructor would work, and then
/// leak the day someone reuses this on a page that gets popped.
/// </summary>
public void OnAppearing()
{
tunnel.PropertyChanged += this.OnTunnelChanged;
log.Added += this.OnRequest;
// The screen shows what the store settled on rather than what was typed at it — the store
// trims a username, and it is the value the validator will actually compare against.
credentials.Changed += this.OnCredentialsChanged;
this.Sync();
// Serving on the local network starts with the app. That is what this sample is for, and it
// exposes nothing beyond the Wi-Fi it is already on — publishing to the internet stays
// behind a deliberate tap.
_ = this.EnsureStartedAsync();
}
public void OnDisappearing()
{
tunnel.PropertyChanged -= this.OnTunnelChanged;
log.Added -= this.OnRequest;
credentials.Changed -= this.OnCredentialsChanged;
}
/// <summary>Starts the server on this network. No internet involved.</summary>
[RelayCommand]
async Task StartLocal()
{
this.Busy = true;
try
{
await this.StartServerAsync();
}
finally
{
this.Busy = false;
}
}
/// <summary>
/// Opens the public tunnel, so the link works from anywhere.
/// <para>
/// Cancellable, because it is the one action here that talks to a machine on the other side of
/// the internet and can sit there for seconds before it says anything.
/// </para>
/// </summary>
[RelayCommand]
async Task Share()
{
if (this.sharing is not null)
return;
using var cts = new CancellationTokenSource();
this.sharing = cts;
this.Busy = true;
this.LastError = null;
this.OnPropertyChanged(nameof(this.CanStopSharing));
try
{
await this.StartServerAsync();
// Null is a failure the tunnel already described — it connected but never learned an
// address to hand anyone. Reporting its reason beats a blank screen labelled "Failed".
if (await tunnel.StartAsync(cts.Token) is null)
this.LastError = tunnel.LastError ?? "The tunnel did not return a public address.";
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
this.LastError = ex.Message;
}
finally
{
this.sharing = null;
this.Busy = false;
this.OnPropertyChanged(nameof(this.CanStopSharing));
}
}
/// <summary>
/// Closes the tunnel, or calls off one that is still opening. The server keeps serving on this
/// network.
/// </summary>
[RelayCommand]
async Task StopSharing()
{
// Cancel first: this is what a person taps when the app has been saying "Connecting…" for
// longer than they are willing to wait.
if (this.sharing is { } cts)
await cts.CancelAsync();
this.Busy = true;
try
{
await tunnel.StopAsync();
}
catch (Exception ex)
{
this.LastError = ex.Message;
}
finally
{
this.Busy = false;
}
}
[RelayCommand]
async Task CopyLink()
{
if (this.PublicUrl is { Length: > 0 } url)
{
await Clipboard.Default.SetTextAsync(url);
await dialogs.Alert("Copied", "The link is on your clipboard.");
}
}
[RelayCommand]
async Task OpenLink()
{
if (this.PublicUrl is { Length: > 0 } url)
await Browser.Default.OpenAsync(url, BrowserLaunchMode.SystemPreferred);
}
/// <summary>
/// Opens any of the addresses on screen in the phone's own browser.
/// <para>
/// <see cref="BrowserLaunchMode.SystemPreferred"/> rather than the in-app browser, because the
/// system one is what Handoff advertises — the page the phone is looking at is then a tap away
/// on the Mac, which is the short way to get a WebDAV listing onto the machine that can mount it.
/// The browser prompts for the same Basic credentials as everything else here.
/// </para>
/// </summary>
[RelayCommand]
async Task OpenUrl(string? url)
{
if (url is not { Length: > 0 })
return;
try
{
await Browser.Default.OpenAsync(url, BrowserLaunchMode.SystemPreferred);
}
catch (Exception ex)
{
this.LastError = ex.Message;
}
}
/// <summary>Copies a username, a password or a link, for the prompt the browser is about to show.</summary>
[RelayCommand]
async Task CopyValue(string? value)
{
if (value is not { Length: > 0 })
return;
await Clipboard.Default.SetTextAsync(value);
await dialogs.Alert("Copied", "It is on your clipboard.");
}
/// <summary>Saves the account. It applies to the next request; nothing restarts.</summary>
[RelayCommand]
async Task SaveAccount()
{
if (string.IsNullOrWhiteSpace(this.Username) || string.IsNullOrWhiteSpace(this.Password))
{
this.LastError = "A username and password are both required.";
return;
}
this.Busy = true;
try
{
await credentials.SetAsync(this.Username, this.Password);
this.LastError = null;
this.StorageStatus = credentials.IsStoredSecurely
? "Saved to the device keychain."
: "Saved, but the keychain was unavailable so this is stored unencrypted.";
}
catch (Exception ex)
{
this.LastError = ex.Message;
}
finally
{
this.Busy = false;
}
}
/// <summary>Replaces the password with a fresh random one, which also revokes the old link.</summary>
[RelayCommand]
async Task RegeneratePassword()
{
this.Busy = true;
try
{
await credentials.RegenerateAsync();
this.Password = credentials.Password;
this.StorageStatus = "New password generated. Anyone using the old one is now locked out.";
}
catch (Exception ex)
{
this.LastError = ex.Message;
}
finally
{
this.Busy = false;
}
}
async Task EnsureStartedAsync()
{
// Credentials first: the server refuses everything without them, so starting before they
// are loaded would answer a real request with a password nobody has been told.
await credentials.LoadAsync();
this.ShowCredentials();
this.StorageStatus = this.DescribeStorage();
await this.StartLocalCommand.ExecuteAsync(null);
}
/// <summary>
/// The server half of both actions, without the <see cref="Busy"/> bookkeeping.
/// <para>
/// Sharing starts the server too, and when that ran through the command its <c>finally</c>
/// cleared <see cref="Busy"/> while the tunnel was still opening — re-enabling every button
/// mid-flight.
/// </para>
/// </summary>
async Task StartServerAsync()
{
if (server.IsRunning)
{
this.LocalUrl ??= this.BuildLocalUrl();
return;
}
try
{
await server.StartAsync();
this.LocalUrl = this.BuildLocalUrl();
}
catch (Exception ex)
{
this.LastError = ex.Message;
}
}
/// <summary>Reads the current state of things the tab does not own, on every appearance.</summary>
void Sync()
{
this.PublicUrl = tunnel.PublicUrl;
this.Status = Describe(tunnel.State);
this.LastError = tunnel.LastError;
this.RequestCount = log.Total;
this.ShowCredentials();
this.StorageStatus = this.DescribeStorage();
if (server.IsRunning)
this.LocalUrl = this.BuildLocalUrl();
}
/// <summary>
/// Reads the account out of the store, which is the one that decides who gets in — so this wins
/// over anything typed and not saved. Empty until <see cref="CredentialStore.LoadAsync"/> has
/// run, which is why the first appearance sets it again once that finishes.
/// </summary>
void ShowCredentials()
{
this.Username = credentials.Username;
this.Password = credentials.Password;
}
/// <summary>
/// Where the password is being kept. Not folded into <see cref="ShowCredentials"/>, which also
/// runs from the store's change event — and that fires from inside a save, so it would replace
/// what the save just reported with this generic line a moment after the user read it.
/// </summary>
string DescribeStorage() => credentials.IsStoredSecurely
? "Stored in the device keychain."
: "The keychain was unavailable, so this is stored unencrypted.";
void OnCredentialsChanged(object? sender, EventArgs e)
=> mainThread.BeginInvokeOnMainThread(this.ShowCredentials);
void OnTunnelChanged(object? sender, PropertyChangedEventArgs e) => mainThread.BeginInvokeOnMainThread(() =>
{
this.PublicUrl = tunnel.PublicUrl;
this.Status = Describe(tunnel.State);
this.LastError = tunnel.LastError;
});
void OnRequest(object? sender, RequestLogEntry entry)
=> mainThread.BeginInvokeOnMainThread(() => this.RequestCount = log.Total);
static string Describe(QuickTunnelState state) => state switch
{
QuickTunnelState.Stopped => "Not shared",
QuickTunnelState.Connecting => "Connecting…",
QuickTunnelState.Connected => "Shared",
QuickTunnelState.Reconnecting => "Reconnecting…",
QuickTunnelState.Failed => "Failed",
_ => state.ToString()
};
/// <summary>
/// The address another device on this network would use.
/// <para>
/// <c>ListenUrl</c> reports <c>0.0.0.0</c>, which is correct and useless to read out loud, so
/// the first real IPv4 address on an up, non-loopback interface is substituted.
/// </para>
/// </summary>
string? BuildLocalUrl()
{
if (server.ListenUrl is not { } listen)
return null;
var port = new Uri(listen).Port;
var address = LocalAddress();
return address is null ? listen : $"http://{address}:{port}";
}
static IPAddress? LocalAddress()
{
try
{
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up
|| nic.NetworkInterfaceType == NetworkInterfaceType.Loopback)
continue;
foreach (var ip in nic.GetIPProperties().UnicastAddresses)
{
if (ip.Address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(ip.Address))
return ip.Address;
}
}
}
catch (NetworkInformationException)
{
// Some platforms restrict interface enumeration; the tunnel URL still works.
}
return null;
}
}