-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin.zig
More file actions
944 lines (863 loc) · 38.1 KB
/
Copy pathplugin.zig
File metadata and controls
944 lines (863 loc) · 38.1 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
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
//! The Google Drive plugin: sign in, and the drive is a folder in the explorer.
//!
//! Everything file-shaped is someone else's: `core.drive.Client` speaks Drive's REST,
//! `core.transport` moves bytes, and `Host.mount` puts the result in the tree beside the disk,
//! where the workbench, the text editor, search and the files service already work on it. What
//! is left for this plugin is exactly the part only it can own — the account: getting a token,
//! keeping it fresh, and naming the mount after whoever signed in.
//!
//! Two sign-ins, one shape. Native: Google's desktop flow — PKCE, the system browser, a
//! loopback listener for the code, a token exchange, and a refresh token kept in settings so
//! the next launch signs in silently. Web: Google Identity Services' token client (Google will
//! not exchange a code from a browser without a secret), which hands over an hour's access
//! token and is asked again, silently, before it runs out. Both end in the same place:
//! `mount("gdrive://<email>")`.
const std = @import("std");
const builtin = @import("builtin");
const sdk = @import("fizzy_sdk");
const dvui = @import("dvui");
const core = @import("core");
const Settings = @import("src/Settings.zig");
const oauth = @import("src/oauth.zig");
const drive = @import("src/drive.zig");
/// The app's OAuth clients, baked in at build time (see `credentials.zon.example`).
const credentials = @import("credentials.zon");
const icons = @import("icons");
const vfs = core.vfs;
const is_wasm = builtin.target.cpu.arch == .wasm32;
pub const plugin_options = @import("fizzy_plugin_options");
pub const plugin_id = plugin_options.id;
var plugin: sdk.Plugin = .{
.state = undefined,
.vtable = &vtable,
.id = plugin_id,
.display_name = plugin_options.name,
};
const vtable: sdk.Plugin.VTable = .{
.deinit = deinit,
.initPlugin = initPlugin,
.beginFrame = beginFrame,
.needsContinuousRepaint = needsContinuousRepaint,
.onFolderOpen = onFolderOpen,
};
const Schema = sdk.settings.Schema(Settings);
/// Google's own error pages (an account that is not a listed tester, a dismissed consent
/// prompt) never redirect back to the loopback, so without a limit that state is forever.
const sign_in_timeout_ms: i64 = 5 * 60 * 1000;
/// How often the mount asks Drive what changed. Drive's own change feed is what a folder
/// watcher is for the disk; a few seconds is fine for edits made elsewhere to show up.
const poll_interval_ms: i64 = 5000;
const State = struct {
settings: Settings = .{},
native_transport: if (is_wasm) void else core.transport.Native = if (is_wasm) {} else undefined,
transport: vfs.http.Transport = undefined,
/// Set by `initPlugin`. Nothing that needs `dvui.io` may run before: in a dylib, `register`
/// runs before the host injects it.
ready: bool = false,
phase: Phase = .signed_out,
/// The desktop flow's listener while a browser tab is open. Native only.
loopback: if (is_wasm) void else ?*oauth.Loopback = if (is_wasm) {} else null,
pkce: if (is_wasm) void else oauth.Pkce = if (is_wasm) {} else undefined,
/// Web: the `state` the implicit flow must echo. Owned.
web_state: []u8 = &.{},
/// The one request this plugin has in flight (exchange, refresh, or the account probe).
pending: ?vfs.http.Job = null,
/// Whether the sign-in in flight is one the user just asked for — so the drive it mounts
/// is one to open — rather than the silent refresh at startup.
interactive: bool = false,
/// Boot-clock ms when the browser was opened; a sign-in nobody finishes is abandoned
/// after `sign_in_timeout_ms` so the option comes back on its own.
awaiting_since_ms: i64 = 0,
access_token: []u8 = &.{},
/// Boot-clock ms after which `access_token` is no longer trusted.
expires_at_ms: i64 = 0,
account: []u8 = &.{},
/// `gdrive://<account>` while mounted. Owned.
prefix: []u8 = &.{},
client: ?*drive.Client = null,
/// Clients already unmounted but not yet freed, and how many frames each has waited. A
/// plugin reading the old mount (atlas's vault scan) holds its `Fs` until the host closes
/// or replaces the folder, which lands in the same frame (a re-root's `setProjectFolder`)
/// or at the top of the next (the close an unmount queues). Freeing at once had that
/// plugin cancel its jobs into freed memory.
retired: std.ArrayListUnmanaged(Retired) = .empty,
/// The `changes.list` poll: when it last ran, and whether one is in flight.
last_poll_ms: i64 = 0,
poll: ?vfs.Job = null,
/// Resolving an open folder the index has not reached, before prefetching it.
prefetch_stat: ?vfs.Job = null,
/// The account's profile picture, once fetched. Owned pixels (freed with the source).
avatar: ?dvui.ImageSource = null,
avatar_job: ?vfs.http.Job = null,
const Retired = struct { client: *drive.Client, frames: u8 = 0 };
const Phase = enum {
signed_out,
/// Native: the browser is open, the loopback is waiting for the code.
awaiting_code,
/// A token request is in flight (exchange or refresh).
token,
/// Token in hand; asking Drive who this is.
account,
mounted,
};
};
pub fn register(host: *sdk.Host) !void {
const gpa = host.allocator;
const st = try gpa.create(State);
errdefer gpa.destroy(st);
st.* = .{};
plugin.state = @ptrCast(st);
try host.registerPlugin(&plugin);
try Schema.register(host, &plugin, .{ .title = "Google Drive", .value = &st.settings });
try host.registerCommand(.{
.id = sdk.Plugin.commandId(plugin_id, "sign_in"),
.owner = &plugin,
.title = "Connect Google Drive…",
.run = cmdSignIn,
.isEnabled = cmdSignInEnabled,
.icon = icons.tvg.lucide.@"log-in",
});
try host.registerCommand(.{
.id = sdk.Plugin.commandId(plugin_id, "open_folder"),
.owner = &plugin,
.title = "Open Google Drive",
.run = cmdOpenDrive,
.isEnabled = cmdMounted,
.icon = icons.tvg.lucide.@"hard-drive",
});
try host.registerCommand(.{
.id = sdk.Plugin.commandId(plugin_id, "sign_out"),
.owner = &plugin,
.title = "Disconnect Google Drive",
.run = cmdSignOut,
.isEnabled = cmdSignOutEnabled,
.icon = icons.tvg.lucide.@"log-out",
});
try host.registerOpenAction(.{
.id = "drive.open_folder",
.owner = &plugin,
.title = "Open Google Drive",
.command = sdk.Plugin.commandId(plugin_id, "open_folder"),
.sf_symbol = "folder.badge.gearshape",
});
try host.registerAccountProvider(.{
.id = "drive.google",
.name = "Google Drive",
.owner = &plugin,
.ctx = st,
.vtable = &account_vtable,
});
try host.registerMenuSection(.{
.id = "drive.menu.file_section",
.parent_menu_id = "fizzy.menu.file",
.owner = &plugin,
.draw = drawFileMenuSection,
});
if (!is_wasm) {
try host.registerNativeMenuItem(.{
.id = "drive.native.open_folder",
.owner = &plugin,
.parent_menu_id = "fizzy.menu.file",
.title = "Open Google Drive",
.command = sdk.Plugin.commandId(plugin_id, "open_folder"),
.run = nativeOpenDrive,
});
try host.registerNativeMenuItem(.{
.id = "drive.native.sign_in",
.owner = &plugin,
.parent_menu_id = "fizzy.menu.file",
.title = "Connect Google Drive…",
.command = sdk.Plugin.commandId(plugin_id, "sign_in"),
.run = nativeSignIn,
});
}
}
/// After the host injected its dvui globals: the transport (which keeps `dvui.io`), and the
/// silent sign-in a saved refresh token allows.
fn initPlugin(ptr: *anyopaque) anyerror!void {
const st = stateOf(ptr);
if (st.ready) return;
const gpa = sdk.allocator();
if (!is_wasm) {
st.native_transport = core.transport.Native.init(gpa, dvui.io, wakeHost);
st.transport = st.native_transport.transport();
} else {
st.transport = core.transport.Web.transport(gpa);
}
st.ready = true;
// A saved refresh token (the host's secret store) means the user signed in before: pick
// up where they left off.
if (!is_wasm and refreshToken(st).len != 0) startRefresh(st);
}
pub fn pluginPtr() *sdk.Plugin {
return &plugin;
}
test {
_ = @import("src/drive.zig");
_ = @import("src/oauth.zig");
}
fn stateOf(ptr: *anyopaque) *State {
return @ptrCast(@alignCast(ptr));
}
fn deinit(ptr: *anyopaque) void {
const st = stateOf(ptr);
const gpa = sdk.allocator();
signOut(st, false);
freeRetired(st, true);
if (!is_wasm and st.ready) st.native_transport.deinit();
Schema.deinit(&st.settings);
gpa.destroy(st);
}
fn wakeHost() void {
sdk.refresh();
}
const secret_refresh_token = "drive.refresh_token";
/// The saved refresh token, from the host's secret store.
fn refreshToken(_: *State) []const u8 {
return sdk.host().getSecret(secret_refresh_token) orelse "";
}
fn storeRefreshToken(value: []const u8) void {
sdk.host().setSecret(secret_refresh_token, value) catch |err| {
dvui.log.warn("drive: could not store the refresh token: {t}", .{err});
};
}
/// The whole drive, and it has to be.
///
/// `drive.file` — access to what the user hands over, one item at a time — cannot support a
/// mount: a folder picked under it grants the *folder object* alone. Its children do not list
/// and each one 404s, so the tree opens empty. Nothing in the API makes that folder browsable,
/// so an editor that shows a drive as a folder needs `…/auth/drive`.
///
/// Google calls that restricted: until the Cloud project passes verification *and* an annual
/// third-party security assessment, users see an "unverified app" screen (they may continue
/// through it) and at most 100 accounts, over the project's lifetime, can grant it. That is the
/// price of the feature, and it is the publisher's to pay — see README › Publisher setup.
fn scopeOf(_: *State) []const u8 {
return oauth.scope_full;
}
fn nowMs() i64 {
return @intCast(@divTrunc(std.Io.Clock.boot.now(dvui.io).nanoseconds, std.time.ns_per_ms));
}
// ---- commands and menu ----------------------------------------------------------------------
fn cmdSignIn(ptr: *anyopaque) anyerror!void {
signIn(stateOf(ptr));
}
fn cmdSignInEnabled(ptr: *anyopaque) bool {
// Also while a browser tab is open: choosing Connect again abandons that attempt and
// starts over, which is what someone whose first try ended on a Google error page wants.
const phase = stateOf(ptr).phase;
return phase == .signed_out or phase == .awaiting_code;
}
fn cmdSignOut(ptr: *anyopaque) anyerror!void {
signOut(stateOf(ptr), true);
}
fn cmdSignOutEnabled(ptr: *anyopaque) bool {
return stateOf(ptr).phase != .signed_out;
}
fn cmdOpenDrive(ptr: *anyopaque) anyerror!void {
openAsRoot(stateOf(ptr));
}
fn cmdMounted(ptr: *anyopaque) bool {
return mounted(stateOf(ptr));
}
fn mounted(st: *State) bool {
return st.phase == .mounted;
}
/// Make the drive the open folder. The mount is already there — signing in put it beside the
/// disk — so this is fizzy opening a folder it can already see, no browser and no Google UI.
/// From there the explorer browses it like any other tree.
fn openAsRoot(st: *State) void {
if (st.phase != .mounted) return;
sdk.host().setProjectFolder(st.prefix) catch |err| {
dvui.log.err("drive: could not open {s}: {t}", .{ st.prefix, err });
complain("Could not open your Drive as the folder.");
};
}
fn nativeOpenDrive(_: ?*anyopaque) anyerror!void {
openAsRoot(stateOf(plugin.state));
}
fn nativeSignIn(_: ?*anyopaque) anyerror!void {
signIn(stateOf(plugin.state));
}
fn drawFileMenuSection(_: ?*anyopaque) anyerror!void {
const st = stateOf(plugin.state);
const host = sdk.host();
if (st.phase == .signed_out) {
if (host.drawMenuItem("Connect Google Drive…", sdk.Plugin.commandId(plugin_id, "sign_in"))) signIn(st);
} else if (st.phase == .awaiting_code) {
if (host.drawMenuItem("Connect Google Drive… (retry)", sdk.Plugin.commandId(plugin_id, "sign_in"))) signIn(st);
if (host.drawMenuItem("Cancel Google sign-in", sdk.Plugin.commandId(plugin_id, "sign_out"))) signOut(st, false);
} else {
const label = std.fmt.allocPrint(host.arena(), "Disconnect Google Drive ({s})", .{
if (st.account.len != 0) st.account else "signing in…",
}) catch "Disconnect Google Drive";
if (host.drawMenuItem(label, sdk.Plugin.commandId(plugin_id, "sign_out"))) signOut(st, true);
}
}
// ---- the per-frame tick ---------------------------------------------------------------------
fn beginFrame(ptr: *anyopaque) void {
const st = stateOf(ptr);
if (!st.ready) return;
freeRetired(st, false);
// Own requests land here; once mounted the table pumps the same transport too, which is
// harmless — a completion is delivered once.
st.transport.pump();
if (is_wasm) core.transport.WebOAuth.pump();
if (!is_wasm and st.phase == .awaiting_code) {
pollLoopback(st);
if (st.phase == .awaiting_code and nowMs() - st.awaiting_since_ms > sign_in_timeout_ms) {
signOut(st, false);
complain("Google sign-in timed out; choose Connect Google Drive to try again.");
}
}
// The watcher: what changed on Drive since last time, folded into the table's listings.
if (st.phase == .mounted and st.poll == null and nowMs() - st.last_poll_ms > poll_interval_ms) {
if (st.client) |client| {
st.last_poll_ms = nowMs();
st.poll = client.pollChanges(sdk.allocator(), onChanges, st) catch null;
}
}
// Refresh ahead of expiry while mounted, so a listing never has to fail first — and at
// once when one did anyway with a 401 (the token was revoked, or the clock was wrong),
// rather than leaving the tree dead until the timer says so.
var stale = st.expires_at_ms != 0 and nowMs() > st.expires_at_ms - 120_000;
if (st.client) |client| {
if (client.takeUnauthorized()) stale = true;
}
if (mounted(st) and st.pending == null and stale) {
if (is_wasm) requestWebToken(st, true) else startRefresh(st);
}
}
fn needsContinuousRepaint(ptr: *anyopaque) bool {
const st = stateOf(ptr);
// Polling the loopback is the one thing nothing else wakes us for.
return !is_wasm and st.phase == .awaiting_code;
}
// ---- sign in ----------------------------------------------------------------------------------
fn signIn(st: *State) void {
if (!st.ready) return complain("Google Drive is still starting; try again in a moment.");
// A retry while the previous browser tab is still open: drop that attempt first.
if (st.phase == .awaiting_code) signOut(st, false);
if (st.phase != .signed_out) return;
st.interactive = true;
if (is_wasm) {
if (credentials.web_client_id.len == 0) return complain("This build of the Drive plugin has no web OAuth client configured.");
st.phase = .token;
// Connect and choose in one trip, as on the desktop: `drive.file` gives an account
// with nothing picked no view of anything.
requestWebToken(st, false);
return;
}
if (credentials.client_id.len == 0) return complain("This build of the Drive plugin has no OAuth client configured.");
// Connecting and choosing a folder are one trip to the browser: under `drive.file` an
// account with nothing picked can see nothing, so there is no useful stop in between.
startDesktopFlow(st) catch |err| {
dvui.log.err("drive: could not start sign-in: {t}", .{err});
complain("Could not start Google sign-in; see the log.");
};
}
fn startDesktopFlow(st: *State) !void {
if (is_wasm) return error.Unsupported;
const gpa = sdk.allocator();
st.pkce = oauth.Pkce.generate(dvui.io);
const loopback = try oauth.Loopback.start(gpa, dvui.io, st.pkce.state, .{});
errdefer loopback.stop();
const url = try oauth.authUrl(gpa, credentials.client_id, scopeOf(st), loopback.port(), &st.pkce);
defer gpa.free(url);
if (!dvui.openURL(.{ .url = url })) return error.CouldNotOpenBrowser;
st.loopback = loopback;
st.phase = .awaiting_code;
st.awaiting_since_ms = nowMs();
dvui.toast(@src(), .{ .message = "Finish signing in to Google in your browser." });
}
fn pollLoopback(st: *State) void {
if (is_wasm) return;
const loopback = st.loopback orelse return;
switch (loopback.take()) {
.waiting => return,
.failed => {
loopback.stop();
st.loopback = null;
st.phase = .signed_out;
complain("Google sign-in did not complete.");
},
.query => |query| {
const gpa = sdk.allocator();
defer gpa.free(query);
const port = loopback.port();
loopback.stop();
st.loopback = null;
const raw = oauth.queryParam(query, "code") orelse {
st.phase = .signed_out;
return complain("Google sign-in did not complete.");
};
const code = oauth.decode(gpa, raw) catch return fail(st, "out of memory");
defer gpa.free(code);
startExchange(st, code, port);
},
}
}
/// Tear the mount down and bring it up again rooted at `folder_id` (`"root"` for My Drive),
/// which becomes the open folder. The account and token stay; only the mount changes — and
/// with it the prefix, so a folder's files are `gdrive://<account>/<folder>/…`.
fn remount(st: *State, folder_id: []const u8, folder_name: []const u8) void {
if (st.phase != .mounted) return;
const gpa = sdk.allocator();
const email = gpa.dupe(u8, st.account) catch return;
defer gpa.free(email);
setSetting(st, "root_folder_id", folder_id);
setSetting(st, "root_folder_name", folder_name);
if (st.poll) |job| {
if (st.client) |client| client.fs().cancel(job);
st.poll = null;
}
releaseClient(st);
if (st.prefix.len != 0) gpa.free(st.prefix);
st.prefix = &.{};
st.phase = .account;
mountDrive(st, email, true) catch |err| {
dvui.log.err("drive: mount failed: {t}", .{err});
fail(st, "could not mount the drive");
};
}
fn startExchange(st: *State, code: []const u8, port: u16) void {
if (is_wasm) return;
const gpa = sdk.allocator();
const body = oauth.exchangeBody(gpa, credentials.client_id, credentials.client_secret, code, port, &st.pkce) catch return fail(st, "out of memory");
st.phase = .token;
postForm(st, oauth.token_endpoint, body, onToken);
}
fn startRefresh(st: *State) void {
if (is_wasm) return;
const gpa = sdk.allocator();
const body = oauth.refreshBody(gpa, credentials.client_id, credentials.client_secret, refreshToken(st)) catch return fail(st, "out of memory");
if (st.phase == .signed_out) st.phase = .token;
postForm(st, oauth.token_endpoint, body, onToken);
}
/// A form POST whose body the transport copies; freed here once it has.
fn postForm(st: *State, url: []const u8, body: []u8, cb: vfs.http.DoneFn) void {
const gpa = sdk.allocator();
defer gpa.free(body);
st.pending = st.transport.request(gpa, .{
.method = .POST,
.url = url,
.headers = &.{.{ .name = "Content-Type", .value = "application/x-www-form-urlencoded" }},
.body = body,
}, cb, st) catch |err| {
dvui.log.err("drive: request failed to start: {t}", .{err});
return fail(st, "could not reach Google");
};
}
fn onToken(ctx: ?*anyopaque, result: vfs.Error!vfs.http.Response) void {
const st: *State = @ptrCast(@alignCast(ctx.?));
st.pending = null;
const gpa = sdk.allocator();
const resp = result catch |err| {
dvui.log.err("drive: token request failed: {t}", .{err});
return fail(st, "could not reach Google");
};
defer resp.deinit(gpa);
const parsed = oauth.parseToken(gpa, resp.body) catch return fail(st, "unexpected reply from Google");
defer parsed.deinit();
if (resp.status != 200 or parsed.value.access_token.len == 0) {
dvui.log.err("drive: token request: HTTP {d} {s}: {s}", .{ resp.status, parsed.value.@"error" orelse "", parsed.value.error_description orelse "" });
if (resp.status == 400 or resp.status == 401) {
// A refresh token Google no longer honours is not worth keeping, and a mount it
// can no longer refresh is not worth keeping up.
storeRefreshToken("");
signOut(st, false);
return complain("Google Drive: the saved sign-in is no longer valid; connect again.");
}
return fail(st, "Google refused the sign-in");
}
setToken(st, parsed.value.access_token, parsed.value.expires_in);
if (parsed.value.refresh_token) |rt| storeRefreshToken(rt);
afterToken(st);
}
fn setToken(st: *State, token: []const u8, expires_in: i64) void {
const gpa = sdk.allocator();
const copy = gpa.dupe(u8, token) catch return;
if (st.access_token.len != 0) gpa.free(st.access_token);
st.access_token = copy;
st.expires_at_ms = nowMs() + expires_in * 1000;
if (st.client) |c| c.access_token = st.access_token;
}
/// Token in hand. A folder just picked is resolved first (it decides what gets mounted, and
/// the token that can see it is this one). Mounted already (a refresh) → nothing more;
/// otherwise find out whose drive this is.
fn afterToken(st: *State) void {
if (st.phase == .mounted) return;
askWhoThisIs(st);
}
fn askWhoThisIs(st: *State) void {
st.phase = .account;
const gpa = sdk.allocator();
var auth_buf: [2100]u8 = undefined;
const auth = std.fmt.bufPrint(&auth_buf, "Bearer {s}", .{st.access_token}) catch return fail(st, "token too long");
st.pending = st.transport.request(gpa, .{
.method = .GET,
.url = oauth.about_endpoint,
.headers = &.{.{ .name = "Authorization", .value = auth }},
}, onAbout, st) catch return fail(st, "could not reach Google");
}
fn onAbout(ctx: ?*anyopaque, result: vfs.Error!vfs.http.Response) void {
const st: *State = @ptrCast(@alignCast(ctx.?));
st.pending = null;
const gpa = sdk.allocator();
const resp = result catch return fail(st, "could not reach Google Drive");
defer resp.deinit(gpa);
if (resp.status != 200) {
dvui.log.err("drive: about: HTTP {d}: {s}", .{ resp.status, resp.body });
return fail(st, "Google Drive refused the token");
}
const parsed = oauth.parseAbout(gpa, resp.body) catch return fail(st, "unexpected reply from Google Drive");
defer parsed.deinit();
const email = parsed.value.user.emailAddress;
if (email.len == 0) return fail(st, "Google did not say whose drive this is");
mountDrive(st, email, false) catch |err| {
dvui.log.err("drive: mount failed: {t}", .{err});
return fail(st, "could not mount the drive");
};
if (parsed.value.user.photoLink) |link| fetchAvatar(st, link);
}
/// The profile picture is decoration: fetched after the mount is up, dropped on any failure
/// (the web build's `fetch` cannot read it cross-origin, for one), and the disc shows a
/// glyph until it lands.
fn fetchAvatar(st: *State, link: []const u8) void {
if (st.avatar_job != null) return;
const gpa = sdk.allocator();
// A larger rendition than Google's default 64 px, sharp on a 2× rail.
const url = std.fmt.allocPrint(gpa, "{s}{s}", .{ link, if (std.mem.indexOf(u8, link, "=s") != null) "" else "=s128" }) catch return;
defer gpa.free(url);
st.avatar_job = st.transport.request(gpa, .{ .method = .GET, .url = url }, onAvatar, st) catch null;
}
fn onAvatar(ctx: ?*anyopaque, result: vfs.Error!vfs.http.Response) void {
const st: *State = @ptrCast(@alignCast(ctx.?));
st.avatar_job = null;
const gpa = sdk.allocator();
const resp = result catch return;
defer resp.deinit(gpa);
if (resp.status != 200) return;
dropAvatar(st);
st.avatar = core.image.fromImageFileBytesAlloc(gpa, "drive-avatar", resp.body, .ptr) catch null;
sdk.refresh();
}
fn dropAvatar(st: *State) void {
if (st.avatar) |src| {
if (src == .pixelsPMA) sdk.allocator().free(src.pixelsPMA.rgba);
}
st.avatar = null;
}
// ---- the account, as the host's rail disc shows it ----------------------------------------
const account_vtable: sdk.accounts.Provider.VTable = .{
.accounts = providerAccounts,
.signIn = providerSignIn,
.menu = providerMenu,
};
fn providerAccounts(ctx: ?*anyopaque, arena: std.mem.Allocator) []const sdk.accounts.Account {
const st: *State = @ptrCast(@alignCast(ctx.?));
// Connected counts: the rail disc is how the user reaches the folder picker, and with
// nothing picked yet that is the only thing to do.
if (!mounted(st)) return &.{};
const one = arena.alloc(sdk.accounts.Account, 1) catch return &.{};
one[0] = .{ .id = st.account, .label = st.account, .avatar = st.avatar };
return one;
}
fn providerSignIn(ctx: ?*anyopaque) void {
const st: *State = @ptrCast(@alignCast(ctx.?));
if (st.phase == .signed_out) signIn(st);
}
/// The account's submenu rows, drawn by the host (`Host.drawMenuItem`): a dylib's own dvui has
/// no open menu to put an item in. True when one was chosen.
fn providerMenu(ctx: ?*anyopaque, _: []const u8) bool {
const st: *State = @ptrCast(@alignCast(ctx.?));
const host = sdk.host();
if (host.drawMenuItem("Open Google Drive", sdk.Plugin.commandId(plugin_id, "open_folder"))) {
openAsRoot(st);
return true;
}
if (host.drawMenuItem("Sign out", sdk.Plugin.commandId(plugin_id, "sign_out"))) {
signOut(st, true);
return true;
}
return false;
}
fn mountDrive(st: *State, email: []const u8, open_it_arg: bool) !void {
const gpa = sdk.allocator();
// A sign-in the user just asked for opens the drive; the silent restore at startup only
// mounts it, so a recent `gdrive://` path resolves without the drive taking over the window.
const open_it = open_it_arg or st.interactive;
st.interactive = false;
// A re-root arrives while a mount is already up under the old one, and its prefix is a
// different string — so without this the old one would stay mounted beside the new.
if (st.client) |client| {
if (st.poll) |job| {
client.fs().cancel(job);
st.poll = null;
}
releaseClient(st);
if (st.prefix.len != 0) gpa.free(st.prefix);
st.prefix = &.{};
}
const account = try gpa.dupe(u8, email);
errdefer gpa.free(account);
// My Drive is `gdrive://<account>`; a drive re-rooted at one folder is named after it, so
// its paths read like paths.
const root_id = st.settings.root_folder_id.get();
const root_name = st.settings.root_folder_name.get();
const prefix = if (std.mem.eql(u8, root_id, "root") or root_name.len == 0)
try std.fmt.allocPrint(gpa, "gdrive://{s}", .{email})
else
try std.fmt.allocPrint(gpa, "gdrive://{s}/{s}", .{ email, root_name });
errdefer gpa.free(prefix);
const client = try gpa.create(drive.Client);
errdefer gpa.destroy(client);
client.* = try drive.Client.init(gpa, dvui.io, st.transport, st.access_token, root_id);
errdefer client.deinit();
client.look_ahead = true;
try sdk.host().mount(prefix, client.fs());
if (st.account.len != 0) gpa.free(st.account);
st.account = account;
st.prefix = prefix;
st.client = client;
st.phase = .mounted;
setSetting(st, "account", email);
// Signing in only mounts (the explorer shows nothing until a drive folder is opened;
// a recent gdrive path can now resolve). Picking a folder makes it the root, replacing
// whatever was; it closes like any other root.
if (open_it) sdk.host().setProjectFolder(prefix) catch |err| dvui.log.warn("drive: could not open {s} as the folder: {t}", .{ prefix, err });
// Already the open folder (a recent reopened before the mount was up): walk it now.
prefetchOpenFolder(st);
if (!open_it and std.mem.eql(u8, root_id, "root")) {
const msg = std.fmt.allocPrint(sdk.host().arena(), "Google Drive connected as {s}.", .{email}) catch "Google Drive connected.";
dvui.toast(@src(), .{ .message = msg });
}
sdk.refresh();
}
fn onChanges(ctx: ?*anyopaque, result: vfs.Error![]drive.Change) void {
const st: *State = @ptrCast(@alignCast(ctx.?));
st.poll = null;
const gpa = sdk.allocator();
const changes = result catch |err| {
// Silent at frame rate would be a request storm; once a minute is a log line.
dvui.log.warn("drive: changes poll failed: {t}", .{err});
st.last_poll_ms = nowMs() + 60_000;
return;
};
defer drive.Client.freeChanges(gpa, changes);
if (changes.len == 0) return;
const host = sdk.host();
const arena = host.arena();
// What the disk's folder watcher would have said, for the paths under the open folder:
// the host reconciles its file table from these and hands them to every plugin, so a vault
// indexed from this drive re-reads exactly what changed instead of re-walking the drive.
// Anything outside the open folder only needs the table's listings dropped.
const folder = host.folder();
var events: std.ArrayList(sdk.Plugin.PathEvent) = .empty;
for (changes) |c| {
const full = mountPath(arena, st.prefix, c.path) catch continue;
const old = if (c.old_path.len != 0) mountPath(arena, st.prefix, c.old_path) catch continue else "";
if (folder != null and isUnder(full, folder.?)) {
events.append(arena, .{
.path = full,
.old_path = old,
.kind = switch (c.kind) {
.created => .created,
.modified => .modified,
.deleted => .deleted,
.renamed => .renamed,
},
.object = if (c.is_dir) .dir else .file,
}) catch continue;
continue;
}
const files = host.files orelse continue;
if (std.fs.path.dirname(full)) |parent| files.invalidateListing(parent);
if (old.len != 0) {
if (std.fs.path.dirname(old)) |parent| files.invalidateListing(parent);
}
if (c.is_dir) files.invalidateListing(full);
}
if (events.items.len != 0) host.notifyFolderPathsChanged(.{ .events = events.items, .truncated = false });
if (host.files) |files| files.invalidateIndex();
sdk.refresh();
}
/// `gdrive://<account>` + `/a/b` — the mount's root is the prefix itself.
fn mountPath(arena: std.mem.Allocator, prefix: []const u8, rel: []const u8) ![]const u8 {
return std.mem.concat(arena, u8, &.{ prefix, if (vfs.path.isRoot(rel)) "" else rel });
}
fn isUnder(path: []const u8, dir: []const u8) bool {
return std.mem.startsWith(u8, path, dir) and (path.len == dir.len or path[dir.len] == '/');
}
/// The open folder is on this mount: fetch the level beneath it now, so the folders the tree
/// shows first open without a round trip. Everything deeper follows what is opened
/// (`Client.lookahead`) — not a walk of the whole drive, which spent the quota on folders nobody
/// looked at and made whatever was expanded wait behind it.
fn prefetchOpenFolder(st: *State) void {
const client = st.client orelse return;
const folder = sdk.host().folder() orelse return;
if (st.prefix.len == 0 or !isUnder(folder, st.prefix)) return;
const rel = if (folder.len == st.prefix.len) "/" else folder[st.prefix.len..];
if (client.tree.contains(rel)) {
client.lookahead(rel) catch |err| dvui.log.warn("drive: lookahead of {s} did not start: {t}", .{ folder, err });
return;
}
// A folder the index has not reached yet: resolve it first, then look ahead of it.
if (st.prefetch_stat) |job| client.fs().cancel(job);
st.prefetch_stat = client.fs().stat(rel, onPrefetchStat, st) catch null;
}
fn onPrefetchStat(ctx: ?*anyopaque, result: vfs.Error!vfs.Stat) void {
const st: *State = @ptrCast(@alignCast(ctx.?));
st.prefetch_stat = null;
const s = result catch return;
if (s.kind != .dir) return;
prefetchOpenFolder(st);
}
fn onFolderOpen(ptr: *anyopaque, _: std.mem.Allocator) void {
prefetchOpenFolder(stateOf(ptr));
}
/// Back to signed out. `forget` also drops the saved refresh token, which is what the user
/// means by "disconnect"; a failure mid-flow keeps it so the next launch can try again.
fn signOut(st: *State, forget: bool) void {
const gpa = sdk.allocator();
if (st.pending) |job| {
st.transport.cancel(job);
st.pending = null;
}
if (st.poll) |job| {
if (st.client) |client| client.fs().cancel(job);
st.poll = null;
}
if (!is_wasm) {
if (st.loopback) |l| {
l.stop();
st.loopback = null;
}
}
releaseClient(st);
if (st.prefix.len != 0) gpa.free(st.prefix);
st.prefix = &.{};
if (st.access_token.len != 0) gpa.free(st.access_token);
st.access_token = &.{};
st.expires_at_ms = 0;
if (st.account.len != 0) gpa.free(st.account);
st.account = &.{};
if (st.web_state.len != 0) gpa.free(st.web_state);
st.web_state = &.{};
if (st.avatar_job) |job| {
st.transport.cancel(job);
st.avatar_job = null;
}
dropAvatar(st);
if (is_wasm) core.transport.WebOAuth.cancel();
st.phase = .signed_out;
st.interactive = false;
if (forget) {
storeRefreshToken("");
setSetting(st, "account", "");
setSetting(st, "root_folder_id", "root");
setSetting(st, "root_folder_name", "");
}
sdk.refresh();
}
/// Unmount the client and retire it: freed by `freeRetired` once whoever read the mount has
/// let go of it.
fn releaseClient(st: *State) void {
const client = st.client orelse return;
if (st.prefetch_stat) |job| {
client.fs().cancel(job);
st.prefetch_stat = null;
}
st.client = null;
sdk.host().unmount(st.prefix);
st.retired.append(sdk.allocator(), .{ .client = client }) catch destroyClient(client);
}
fn destroyClient(client: *drive.Client) void {
client.deinit();
sdk.allocator().destroy(client);
}
/// Once a frame; `all` at teardown. A client waits out the frame it was retired in and the
/// next, whose start is where the host applies a queued folder close.
fn freeRetired(st: *State, all: bool) void {
var i: usize = 0;
while (i < st.retired.items.len) {
const r = &st.retired.items[i];
if (all or r.frames >= 1) {
destroyClient(r.client);
_ = st.retired.swapRemove(i);
continue;
}
r.frames += 1;
i += 1;
}
if (all) st.retired.deinit(sdk.allocator());
}
fn fail(st: *State, what: []const u8) void {
const msg = std.fmt.allocPrint(sdk.host().arena(), "Google Drive: {s}.", .{what}) catch "Google Drive: sign-in failed.";
complain(msg);
// A refresh that fails while mounted (the network, a 5xx) leaves the mount up with its old
// token and is retried in a minute — not next frame, which was one token POST per frame.
// Anything earlier in the flow starts over.
if (st.phase == .mounted) {
st.expires_at_ms = nowMs() + 120_000 + 60_000;
return;
}
signOut(st, false);
}
/// The browser has the front after a sign-in or a pick; ask fizzy for it back. The command is
/// fizzy's own and older builds do not have it, which is why the failure is ignored rather than
/// reported — there is nothing the user could do about it.
fn comeBack() void {
if (is_wasm) return;
sdk.host().runCommand("fizzy.focusWindow") catch {};
}
fn complain(msg: []const u8) void {
dvui.log.warn("drive: {s}", .{msg});
dvui.toast(@src(), .{ .message = msg });
}
/// Write a string setting the schema's way — its own allocator, the default never freed — and
/// persist the plugin's settings.
fn setSetting(st: *State, comptime field: []const u8, value: []const u8) void {
const gpa = sdk.allocator();
const cell = &@field(st.settings, field);
const copy = gpa.dupe(u8, value) catch return;
// Owned unless it is exactly the declared default (see `sdk.settings` on ownership).
const default_value = (Settings{});
if (cell.v.ptr != @field(default_value, field).v.ptr) gpa.free(cell.v);
cell.v = copy;
Schema.store(sdk.host(), plugin_id, st.settings);
sdk.host().markSettingsDirty();
}
// ---- web: the implicit flow through fizzy's generic OAuth popup --------------------------
//
// Google will not exchange a code from a browser without a client secret, so the web build
// uses the implicit grant: the popup lands on fizzy's `oauth-callback.html` with the access
// token in the fragment. Nothing Google-specific lives in fizzy for this; the popup helper is
// the same one any provider's plugin would use.
fn requestWebToken(st: *State, silent: bool) void {
if (!is_wasm) return;
const gpa = sdk.allocator();
const redirect = core.transport.WebOAuth.callbackUrl(gpa) catch return fail(st, "no callback page");
defer gpa.free(redirect);
if (st.web_state.len == 0) {
var nonce: [24]u8 = undefined;
dvui.io.random(&nonce);
const state_buf = gpa.alloc(u8, 32) catch return fail(st, "out of memory");
_ = std.base64.url_safe_no_pad.Encoder.encode(state_buf, &nonce);
st.web_state = state_buf;
}
const url = oauth.implicitAuthUrl(gpa, credentials.web_client_id, scopeOf(st), redirect, st.web_state, silent) catch return fail(st, "out of memory");
defer gpa.free(url);
core.transport.WebOAuth.begin(gpa, url, onWebOAuth, st) catch return fail(st, "a sign-in is already open");
}
fn onWebOAuth(ctx: ?*anyopaque, result: ?[]u8) void {
if (!is_wasm) return;
const st: *State = @ptrCast(@alignCast(ctx.?));
const gpa = sdk.allocator();
const text = result orelse return fail(st, "Google sign-in did not complete");
defer gpa.free(text);
const parsed = oauth.parseImplicit(text) orelse return fail(st, "Google refused the sign-in");
if (!std.mem.eql(u8, parsed.state, st.web_state)) return fail(st, "sign-in reply did not match the request");
setToken(st, parsed.access_token, parsed.expires_in);
afterToken(st);
}