tauri-plugin-moonbase 0.1.0

Moonbase license activation for Tauri 2 apps, with a finished activation UI: browser and offline activation, trials, add-ons, upgrades, updates and a license-state event for your own UI.
Documentation
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
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
'use strict'

// The activation UI, shared by the plugin's own window (window.js) and the in-app
// overlay (overlay.js). Every screen renders from the license state the plugin
// pushes in, and every action is a plugin command. Server-provided text only ever
// goes into the page as text.
//
// mode is 'window' or 'overlay'. In a window the page is the panel and reports its
// height so the window fits each screen; as an overlay the panel floats over the
// app, which it locks while there is no license.
// eslint-disable-next-line no-unused-vars
function createActivationUI({ root, config, invoke, mode, onHeight }) {
  const product = config.productName
  const account = config.manufacturerName || 'Moonbase'
  const overlay = mode === 'overlay'
  const MOONBASE_SITE = 'https://moonbase.sh'

  // -- Building blocks ------------------------------------------------------------

  function h(tag, props = {}, ...children) {
    const el = document.createElement(tag)
    for (const [key, value] of Object.entries(props)) {
      if (value === undefined || value === null || value === false) continue
      if (key.startsWith('on')) el.addEventListener(key.slice(2).toLowerCase(), value)
      else if (key === 'class') el.className = value
      else if (key === 'style') el.style.cssText = value
      else el.setAttribute(key, value === true ? '' : value)
    }
    for (const child of children.flat(Infinity)) {
      if (child === null || child === undefined || child === false) continue
      el.append(child instanceof Node ? child : document.createTextNode(String(child)))
    }
    return el
  }

  // Static markup only: icons defined in this file.
  function svg(markup) {
    const template = document.createElement('template')
    template.innerHTML = markup.trim()
    return template.content.firstChild
  }

  const icon = (path, { size = 16, stroke = 'currentColor', width = 1.8 } = {}) =>
    svg(`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"><path d="${path}"></path></svg>`)

  const paths = {
    check: 'M4.5 12.75l6 6 9-13.5',
    cross: 'M6 18 18 6M6 6l12 12',
    lock: 'M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25z',
    back: 'M15.75 19.5 8.25 12l7.5-7.5',
    chevron: 'm8.25 4.5 7.5 7.5-7.5 7.5',
    signal:
      'M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12z',
    computer:
      'M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25m18 0A2.25 2.25 0 0 0 18.75 3H5.25A2.25 2.25 0 0 0 3 5.25m18 0V12a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 12V5.25',
    fileDown:
      'M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9z',
    upload:
      'M7.5 7.5h-.75A2.25 2.25 0 0 0 4.5 9.75v7.5a2.25 2.25 0 0 0 2.25 2.25h7.5a2.25 2.25 0 0 0 2.25-2.25v-7.5a2.25 2.25 0 0 0-2.25-2.25h-.75m-6 3.75 3-3m0 0 3 3m-3-3v11.25m6-2.25h.75a2.25 2.25 0 0 0 2.25-2.25v-7.5a2.25 2.25 0 0 0-2.25-2.25h-.75',
    okCircle: 'M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z',
    warning:
      'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z',
    blocked:
      'M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636',
    copy: 'M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375',
    download:
      'M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3',
    puzzle:
      'M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.4 48.4 0 0 1-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 0 1-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 0 0-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.04 48.04 0 0 1-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 0 0 .657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 0 1-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 0 0 5.427-.63 48.05 48.05 0 0 0 .582-4.717.532.532 0 0 0-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0 0 0 .658-.663 48.4 48.4 0 0 0-.37-5.36c-1.886.342-3.81.574-5.766.689a.578.578 0 0 1-.61-.58v0z',
    external:
      'M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25',
    sparkles:
      'M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09Z',
  }

  const spinner = (size = 17) =>
    svg(`<svg class="spin" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="rgba(255,255,255,0.3)" stroke-width="3"></circle><path d="M21 12a9 9 0 0 0-9-9" stroke="#fff" stroke-width="3" stroke-linecap="round"></path></svg>`)

  const bigSpinner = (small = false) =>
    h(
      'div',
      { class: small ? 'spinner small' : 'spinner' },
      svg(
        `<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9.5" stroke="rgba(255,255,255,0.1)" stroke-width="1.6"></circle><path d="M21.5 12a9.5 9.5 0 0 0-9.5-9.5" stroke="#82cef1" stroke-width="1.6" stroke-linecap="round"></path></svg>`,
      ),
    )

  // The product mark: the app's own logo, or the design's sun in the accent.
  function mark() {
    if (config.logoSvg) {
      // The app's own SVG, inlined: an overlay cannot load images from the plugin.
      const template = document.createElement('template')
      template.innerHTML = config.logoSvg
      const logo = template.content.querySelector('svg')
      if (logo) {
        logo.classList.add('mark')
        return logo
      }
    }
    if (config.logo) return h('img', { class: 'mark', src: config.logo, alt: '' })
    return svg(
      `<svg class="mark" viewBox="0 0 38 38" fill="none"><circle cx="19" cy="19" r="13" stroke="var(--accent)" stroke-width="2" opacity="0.9"></circle><circle cx="19" cy="19" r="4.5" fill="var(--accent)"></circle><g stroke="var(--accent)" stroke-width="2" stroke-linecap="round"><line x1="19" y1="1.5" x2="19" y2="6"></line><line x1="19" y1="32" x2="19" y2="36.5"></line><line x1="1.5" y1="19" x2="6" y2="19"></line><line x1="32" y1="19" x2="36.5" y2="19"></line><line x1="6.6" y1="6.6" x2="9.8" y2="9.8"></line><line x1="28.2" y1="28.2" x2="31.4" y2="31.4"></line><line x1="31.4" y1="6.6" x2="28.2" y2="9.8"></line><line x1="9.8" y1="28.2" x2="6.6" y2="31.4"></line></g></svg>`,
    )
  }

  const lockup = ({ large = false, dim = false } = {}) =>
    h(
      'div',
      { class: `lockup${large ? ' large' : ''}${dim ? ' dim' : ''}` },
      mark(),
      h('div', {}, h('div', { class: 'name' }, product), config.manufacturerName && h('div', { class: 'maker' }, config.manufacturerName)),
    )

  const header = (pill, options) => h('div', { class: 'header' }, lockup(options), pill)

  const button = (label, onClick, { kind = 'primary', iconPath, disabled, short } = {}) =>
    h(
      'button',
      { class: `btn ${kind}${short ? ' short' : ''}`, type: 'button', disabled, onClick },
      iconPath && icon(iconPath, { width: 1.8 }),
      label,
    )

  const textButton = (label, onClick, { link = false, iconPath, disabled } = {}) =>
    h('button', { class: `text-button${link ? ' link' : ''}`, type: 'button', disabled, onClick }, iconPath && icon(iconPath, { size: 14, width: 1.7 }), label)

  const backButton = (label, onClick) =>
    h('div', { class: 'back' }, textButton(label, onClick, { iconPath: paths.back }))

  const errorLine = (message) =>
    message && h('div', { class: 'error', role: 'alert' }, icon(paths.warning, { size: 15 }), h('span', {}, message))

  const formatDate = (iso) => new Date(iso).toLocaleDateString(undefined, { dateStyle: 'long' })
  const dayMs = 86400000
  const daysUntil = (iso) => Math.max(0, Math.ceil((new Date(iso) - Date.now()) / dayMs))
  const join = (a, b) => (!a ? b : !b ? a : `${a} · ${b}`)
  const osLabel = { macos: 'macOS', windows: 'Windows', linux: 'Linux', ios: 'iOS', android: 'Android' }[config.os] || config.os

  function trialLength(license) {
    const days = license?.expiresAt && license?.issuedAt ? Math.round((new Date(license.expiresAt) - new Date(license.issuedAt)) / dayMs) : 0
    return days >= 1 ? days : config.trialLengthDays
  }

  async function copyText(text) {
    try {
      await navigator.clipboard.writeText(text)
      return
    } catch {
      // Custom schemes are not always secure contexts; fall back to a selection.
    }
    const area = h('textarea', { style: 'position:fixed;opacity:0' })
    area.value = text
    root.append(area)
    area.select()
    document.execCommand('copy')
    area.remove()
  }

  // -- Copy -------------------------------------------------------------------------

  const strings = config.strings || {}
  const pick = (custom, fallback) => (custom ? custom : fallback)

  // What a license that covers only some versions covers, in a sentence or two:
  // "Your updates ended on 1 June 2026. Your license covers releases up to 2.3.0."
  function releaseAccessText(access) {
    if (!access) return ''
    const sentences = []
    if (access.updatesUntil && new Date(access.updatesUntil) < new Date()) sentences.push(`Your updates ended on ${formatDate(access.updatesUntil)}.`)
    const covers = []
    if (access.upToMajor != null) covers.push(`versions up to ${access.upToMajor}.x`)
    if (access.upToVersion) covers.push(`releases up to ${access.upToVersion}`)
    if (covers.length) sentences.push(`Your license covers ${covers.join(' and ')}.`)
    return sentences.join(' ')
  }

  // "Renew updates" for a license whose updates ran out, "Upgrade license" for one
  // limited to some majors, as the customer portal words it.
  const upgradeLabel = (access) => (access?.updatesUntil ? 'Renew updates' : 'Upgrade license')

  function welcomeCopy(reason) {
    switch (reason) {
      case 'deactivated':
        return [pick(strings.deactivatedTitle, 'Deactivated on this computer'), pick(strings.deactivatedBody, `${product} no longer has an active license here. Activate it again to keep using it.`)]
      case 'expired':
        return [pick(strings.licenseExpiredTitle, 'Your license has expired'), pick(strings.licenseExpiredBody, `Renew your ${product} license in your ${account} account, then activate it again.`)]
      case 'invalid':
        return [
          pick(strings.licenseInvalidTitle, 'Your license is no longer valid'),
          pick(strings.licenseInvalidBody, `${account} no longer accepts this license on this computer. Activate again, or contact ${account} if this seems wrong.`),
        ]
      case 'unverified':
        return [
          pick(strings.licenseUnverifiedTitle, "Couldn't verify your license"),
          pick(strings.licenseUnverifiedBody, `${product} has gone too long without checking its license online. Connect to the internet and activate again.`),
        ]
      default:
        return [pick(strings.welcomeTitle, `Activate ${product}`), pick(strings.welcomeBody, `Unlock ${product} through your ${account} account, or take it for a spin with a free trial.`)]
    }
  }

  // -- State --------------------------------------------------------------------------

  let state = null
  let device = null
  let view = null // a screen the user navigated to, over the one the status implies
  let cameFromDetails = false
  let update = null // release notes, fetched when the update screen opens
  let download = { phase: 'idle' }
  let idCopied = false
  let copiedLink = null // the activation link just copied, while "Link copied" shows
  let copyToken = 0
  const offline = { saved: null, file: null, busy: false, error: '' }

  function statusScreen() {
    switch (state?.status) {
      case 'unlicensed':
        return 'welcome'
      case 'activating':
        return 'browser'
      case 'licensed':
        return 'details'
      case 'trial':
        return 'trial'
      case 'trialExpired':
        return 'expired'
      case 'notCovered':
        return 'notCovered'
      default:
        return 'loading'
    }
  }

  const screenName = () => view || statusScreen()

  function go(name) {
    view = name
    if (name === 'update') loadUpdate()
    if (name === 'addons') loadAddOns()
    render()
  }

  const loadAddOns = () => run(() => invoke('load_add_ons'))

  async function run(action) {
    try {
      await action()
    } catch (error) {
      // The plugin puts the customer-facing reason in state.error and pushes it.
      if (!state?.error) console.warn('moonbase:', error)
    }
  }

  async function loadUpdate() {
    update = null
    try {
      update = await invoke('check_update')
    } catch (error) {
      update = { available: false, error: error?.message ?? String(error) }
    }
    render()
  }

  // -- Screens ------------------------------------------------------------------------

  const screens = {
    loading: () => h('div', { class: 'center' }, bigSpinner(true), h('p', {}, 'Checking your license…')),

    welcome: () => {
      const [title, body] = welcomeCopy(state.lockReason)
      return h(
        'div',
        {},
        h('div', { style: 'margin-bottom:30px' }, lockup({ large: true })),
        h('h1', { class: 'large' }, title),
        h('p', { style: 'margin-bottom:28px; max-width:430px' }, body),
        h(
          'div',
          { class: 'stack' },
          button(pick(strings.activateOnline, 'Activate online'), () => run(() => invoke('activate', { offline: false })), { short: true }),
        ),
        errorLine(state.error?.message),
        h(
          'div',
          { style: 'margin-top:24px' },
          textButton(pick(strings.activateOffline, 'No internet? Activate offline'), () => go('offline'), { iconPath: paths.signal }),
        ),
      )
    },

    browser: () => {
      const url = state.activation?.browserUrl
      const host = url ? new URL(url).host : null
      // The way through when the browser didn't open: a quiet link while it
      // probably did, the main action when it couldn't be opened at all (no
      // default browser, say). Either copies the link for any browser, on this
      // computer or another.
      const failed = url && state.activation.browserOpenFailed
      const copied = url && copiedLink === url
      const copyLink = async () => {
        await copyText(url)
        copiedLink = url
        render()
        const token = ++copyToken
        setTimeout(() => {
          if (copyToken !== token) return
          copiedLink = null
          render()
        }, 2000)
      }
      return h(
        'div',
        { class: 'center' },
        bigSpinner(),
        h('h1', {}, url ? 'Waiting for activation' : 'Starting activation…'),
        h(
          'p',
          { style: 'margin:0 auto 22px; max-width:420px' },
          !url
            ? 'Creating a secure activation request with your store.'
            : failed
              ? "We couldn't open your browser. Copy the activation link, open it in any browser on this computer or another, then sign in and confirm this device."
              : ['We opened ', h('span', { class: 'em' }, host), " in your browser. Sign in and confirm this device; we'll pick it up automatically."],
        ),
        failed &&
          h(
            'div',
            { class: 'narrow', style: 'max-width:300px; margin-bottom:18px' },
            button(copied ? 'Link copied' : 'Copy activation link', copyLink, { iconPath: copied ? paths.check : paths.copy, short: true }),
          ),
        h('div', { class: 'chip' }, icon(paths.computer, { size: 15, stroke: '#768aa4', width: 1.6 }), h('span', {}, `${device?.name || 'This device'} · ${osLabel}`)),
        h('div', { class: 'narrow', style: 'max-width:300px' }, button('Cancel activation', () => run(() => invoke('cancel_activation')), { kind: 'ghost', short: true })),
        url &&
          !failed &&
          h(
            'div',
            { class: 'links' },
            textButton('Open the page again', () => run(() => invoke('open_link', { url })), {}),
            textButton(copied ? 'Link copied' : "Browser didn't open? Copy the link", copyLink, { link: true, iconPath: copied ? paths.check : paths.copy }),
          ),
        errorLine(state.error?.message),
      )
    },

    success: () => {
      const license = state.license
      if (!license) return screens[statusScreen()]()
      return h(
        'div',
        { class: 'center' },
        h('div', { class: 'check' }, icon(paths.check, { size: 34, stroke: '#34d27b', width: 2.4 })),
        h('h1', {}, license.trial ? 'Your trial has started' : "You're all set"),
        h(
          'p',
          { style: 'margin:0 auto 24px; max-width:380px' },
          license.trial
            ? `Enjoy ${product}${license.daysRemaining != null ? ` for the next ${license.daysRemaining} days` : ''}. Unlock the full version any time.`
            : `${product} is activated on this machine. Your license is linked to your account.`,
        ),
        h(
          'div',
          { class: 'card mini' },
          h('div', { class: 'row' }, h('span', { class: 'label' }, 'Plan'), h('span', { class: 'value' }, planName(license))),
          h('div', { class: 'row' }, h('span', { class: 'label' }, 'Licensed to'), h('span', { class: 'value' }, license.issuedTo.email || license.issuedTo.name)),
        ),
        h('div', { class: 'narrow' }, button(`Open ${product}`, () => invoke('close_ui'))),
        h('div', { class: 'links' }, textButton('View license details', () => go('details'), { link: true })),
      )
    },

    offline: () => {
      const host = config.activationHost
      const saveSub = offline.saved ? `Saved to ${offline.saved}` : 'Choose where to save it'
      const input = h('input', { type: 'file', accept: '.mb,.jwt,.txt,.lic', onChange: (event) => pickFile(event.target.files?.[0]) })
      const drop = h(
        'label',
        {
          class: 'drop',
          onDragover: (event) => {
            event.preventDefault()
            drop.classList.add('over')
          },
          onDragleave: () => drop.classList.remove('over'),
          onDrop: (event) => {
            event.preventDefault()
            drop.classList.remove('over')
            pickFile(event.dataTransfer?.files?.[0])
          },
        },
        input,
        offline.file
          ? [icon(paths.okCircle, { size: 24, stroke: '#34d27b', width: 1.7 }), h('span', { class: 'title mono' }, offline.file.name), h('span', { class: 'sub' }, 'Click to choose a different file')]
          : [
              icon(paths.upload, { size: 24, stroke: '#768aa4', width: 1.6 }),
              // An app window usually takes dropped files itself, so the overlay only offers a picker.
              h('span', { class: 'title' }, overlay ? 'Choose your license file' : 'Drop your license file here'),
              h('span', { class: 'sub' }, overlay ? 'Click to browse · .mb' : 'or click to browse · .mb'),
            ],
      )
      return h(
        'div',
        {},
        backButton('Back', () => go(null)),
        h('h1', {}, 'Offline activation'),
        h(
          'p',
          { style: 'margin-bottom:24px' },
          'For computers without internet. Save your request file, take it to ',
          h('span', { class: 'em' }, `${host}/activate`),
          ' on a connected device, and bring back the license file.',
        ),
        h('div', { class: 'step' }, h('span', { class: 'n' }, '1'), h('span', { class: 'label' }, 'Save your request file')),
        h(
          'button',
          { class: 'file', type: 'button', onClick: saveRequest },
          icon(paths.fileDown, { size: 22, stroke: '#82cef1', width: 1.6 }),
          h('span', { class: 'grow' }, h('span', { class: 'title' }, config.requestFileName), h('span', { class: 'sub' }, saveSub)),
          offline.saved ? icon(paths.check, { size: 20, stroke: '#34d27b', width: 2 }) : h('span', { class: 'action' }, 'Save…'),
        ),
        h('div', { class: 'step' }, h('span', { class: 'n' }, '2'), h('span', { class: 'label' }, 'Add the license file')),
        drop,
        errorLine(offline.error),
        h(
          'div',
          { class: 'offline-submit' },
          button(offline.busy ? [spinner(), 'Verifying…'] : 'Activate offline', activateOffline, { disabled: offline.busy }),
        ),
      )
    },

    trial: () => {
      const license = state.license
      const left = license.daysRemaining
      const total = trialLength(license)
      const endless = left == null
      return h(
        'div',
        {},
        header(h('span', { class: 'pill trial' }, endless ? 'Trial' : `Trial · ${left} ${left === 1 ? 'day' : 'days'} left`)),
        h('h1', {}, "You're on a free trial"),
        h(
          'p',
          { style: 'margin-bottom:8px' },
          endless ? 'Unlock the full version any time to keep everything.' : `${left} of ${total} days remaining. Unlock the full version any time to keep everything.`,
        ),
        endless ? h('div', { style: 'height:18px' }) : h('div', { class: 'bar' }, h('div', { style: `width:${Math.min(100, Math.max(0, (left / total) * 100))}%` })),
        features(),
        button('Unlock full version', () => run(() => invoke('activate', { offline: false })), { iconPath: paths.lock }),
        errorLine(state.error?.message),
        h('div', { class: 'links' }, textButton('Continue trial', () => invoke('close_ui')), textButton('License details', () => go('details'))),
      )
    },

    expired: () => {
      const trial = state.expiredTrial
      const days = trialLength(trial)
      return h(
        'div',
        {},
        header(h('span', { class: 'pill expired' }, h('span', { class: 'dot' }), 'Trial expired'), { dim: true }),
        h('h1', {}, 'Your trial has ended'),
        h(
          'p',
          { style: 'margin-bottom:8px' },
          trial?.expiresAt
            ? [`Your ${days}-day trial of ${product} ended on `, h('span', { class: 'em' }, formatDate(trial.expiresAt)), '. Unlock the full version to keep using it.']
            : `Your trial of ${product} has ended. Unlock the full version to keep using it.`,
        ),
        h('div', { class: 'bar expired', style: 'margin-bottom:22px' }, h('div', { style: 'width:100%' })),
        h(
          'div',
          { class: 'notice' },
          icon(paths.blocked, { size: 18, stroke: '#f08a8a' }),
          h('span', {}, pick(strings.expiredNotice, `${product}'s full features are locked until it is activated. Your work is safe and opens as usual.`)),
        ),
        button('Unlock full version', () => run(() => invoke('activate', { offline: false })), { iconPath: paths.lock }),
        errorLine(state.error?.message),
        h('div', { class: 'links' }, textButton('Activate offline instead', () => go('offline'))),
      )
    },

    // A valid license that doesn't cover this version of the app, such as one whose
    // updates ended before it came out. Locked like an ended trial, but the license
    // stays: an upgrade or renewal makes it cover this one.
    notCovered: () => {
      const info = state.notCovered
      const access = info?.releaseAccess
      const renewal = !!access?.updatesUntil
      const offlineLicense = info?.license?.activationMethod === 'Offline'
      const title = pick(strings.notCoveredTitle, info?.appVersion ? `Version ${info.appVersion} needs an upgrade` : 'This version needs an upgrade')
      const body = pick(strings.notCoveredBody, releaseAccessText(access) || `Your ${product} license doesn't cover this version.`)
      return h(
        'div',
        {},
        header(h('span', { class: 'pill upgrade' }, h('span', { class: 'dot' }), 'Upgrade needed'), { dim: true }),
        h('h1', {}, title),
        h('p', { style: 'margin-bottom:22px' }, body),
        h(
          'div',
          { class: 'notice upgrade' },
          icon(paths.warning, { size: 18, stroke: 'var(--trial)' }),
          h('span', {}, `${product} is locked. Your license still works with the versions it covers, so you can go back to one of those, or upgrade to use this one.`),
        ),
        // Opens the browser either way: the upgrade page, or the activation page
        // that offers it.
        button(upgradeLabel(access), () => run(() => invoke('upgrade_license')), { iconPath: paths.external }),
        errorLine(state.error?.message),
        h(
          'div',
          { class: 'links' },
          // An offline license is never checked online: the upgraded one comes as a
          // new license file.
          offlineLicense
            ? textButton('Load an upgraded license file', () => go('offline'))
            : textButton(state.refreshing ? 'Checking your license…' : renewal ? 'Already renewed? Check again' : 'Already upgraded? Check again', () => run(() => invoke('refresh', { force: true })), {
                disabled: state.refreshing,
              }),
        ),
      )
    },

    details: () => {
      const license = state.license
      if (!license) return screens[statusScreen()]()
      const pills = h(
        'div',
        { class: 'pills' },
        state.update && h('button', { class: 'pill update', type: 'button', onClick: () => openUpdate(true) }, icon(paths.download, { size: 13, stroke: 'var(--accent)', width: 1.9 }), 'Update'),
        h('span', { class: 'pill active' }, h('span', { class: 'dot' }), license.trial ? 'Trial' : 'Active'),
      )
      const seatCount = license.seatCount
      const used = license.seatsUsed
      const pipCount = seatCount ? Math.min(seatCount, 8) : 0
      return h(
        'div',
        {},
        header(pills),
        h(
          'div',
          { class: 'card rows' },
          h('div', { class: 'row' }, h('span', { class: 'label' }, 'Licensed to'), h('span', { class: 'value' }, license.issuedTo.name || license.issuedTo.email)),
          h('div', { class: 'row' }, h('span', { class: 'label' }, 'Email'), h('span', { class: 'value soft' }, license.issuedTo.email)),
          h('div', { class: 'row' }, h('span', { class: 'label' }, 'Plan'), h('span', { class: 'value' }, `${planName(license)} · ${license.expiresAt ? `until ${formatDate(license.expiresAt)}` : 'perpetual'}`)),
          h(
            'div',
            { class: 'row id' },
            h('span', { class: 'label' }, 'License ID'),
            h(
              'span',
              { class: 'copy-group' },
              h('span', { class: 'license-id' }, license.id),
              h(
                'button',
                {
                  class: 'small-button',
                  type: 'button',
                  title: 'Copy license ID for support',
                  onClick: async () => {
                    await copyText(license.id)
                    idCopied = true
                    render()
                    setTimeout(() => {
                      idCopied = false
                      render()
                    }, 1800)
                  },
                },
                idCopied ? [icon(paths.check, { size: 14, stroke: '#34d27b', width: 2 }), 'Copied'] : [icon(paths.copy, { size: 14, width: 1.6 }), 'Copy'],
              ),
            ),
          ),
        ),
        h(
          'div',
          { class: 'card seats' },
          h(
            'div',
            {},
            h('div', { class: 'title' }, seatCount ? `Activations · ${used ?? '?'} of ${seatCount} ${seatCount === 1 ? 'device' : 'devices'}` : license.activationMethod === 'Offline' ? 'Activated offline on this device' : 'Activated on this device'),
            pipCount > 0 && h('div', { class: 'pips' }, Array.from({ length: pipCount }, (_, index) => h('span', { class: `pip${index < (used ?? 0) ? ' used' : ''}` }))),
          ),
          h(
            'button',
            { class: 'danger-button', type: 'button', disabled: state.busy, onClick: () => run(() => invoke('deactivate')) },
            state.busy ? [spinner(13), 'Deactivating…'] : license.trial || license.activationMethod === 'Offline' ? 'Remove from this device' : 'Deactivate this device',
          ),
        ),
        errorLine(state.error?.message),
        // Add-ons are activated and tried from a full license.
        config.showAddOns &&
          !license.trial &&
          h(
            'button',
            { class: 'card nav-card', type: 'button', onClick: () => go('addons') },
            icon(paths.puzzle, { size: 20, stroke: 'var(--accent)', width: 1.7 }),
            h(
              'span',
              { class: 'grow' },
              h('span', { class: 'title' }, 'Add-ons'),
              h('span', { class: 'sub' }, state.activeSubProducts.length ? `${state.activeSubProducts.length} active on this computer` : 'See what you can add'),
            ),
            icon(paths.chevron, { size: 18, stroke: '#768aa4', width: 2 }),
          ),
      )
    },

    // The product's add-ons, each with where it stands on this computer and what
    // the customer can do about it: try it, buy it, or activate it here.
    addons: () => {
      const license = state.license
      if (!license) return screens[statusScreen()]()
      const info = state.addOns
      const loading = info.phase === 'loading'
      const failed = !!info.error && info.addOns.length === 0
      const rows = info.addOns.map((entry) => describeAddOn(entry, license))
      let list
      if (rows.length) list = rows.map(addOnRow)
      else if (info.phase !== 'ready') list = [0, 1, 2].map(() => h('div', { class: 'card addon skeleton' }, h('div', { class: 'icon' }), h('div', { class: 'grow' }, h('span'), h('span'))))
      else if (failed) list = errorLine(info.error)
      else list = h('p', { class: 'empty' }, `There are no add-ons for ${product} yet.`)
      // Under the list: why an activation or trial failed, or why a reload did while
      // the earlier list stays up.
      const message = info.actionError || (info.addOns.length ? info.error : null)
      // Not while an add-on is being set up: its license is on its way.
      const waiting = !!info.busyId
      return h(
        'div',
        {},
        backButton('License details', () => go('details')),
        h('h1', {}, 'Add-ons'),
        h('p', { style: 'margin-bottom:20px' }, `Everything you can add to ${product}.`),
        h('div', { class: 'addon-list' }, list),
        errorLine(message),
        h(
          'div',
          { class: 'links start' },
          textButton(loading ? 'Checking…' : failed ? 'Try again' : 'Bought an add-on? Refresh', () => run(() => invoke(failed ? 'load_add_ons' : 'refresh_add_ons')), {
            link: true,
            disabled: loading || waiting,
          }),
        ),
      )
    },

    update: () => {
      const back = () => (cameFromDetails ? go(null) : invoke('close_ui'))
      if (!update) return h('div', { class: 'center' }, bigSpinner(true), h('p', {}, 'Fetching the release notes…'))
      if (update.error || !update.available) {
        return h(
          'div',
          {},
          header(null),
          h('h1', {}, `${product} is up to date`),
          h('p', {}, update.error ? `Couldn't check for updates: ${update.error}` : `You're on the latest version.`),
          h('div', { class: 'links' }, textButton('Back', back)),
        )
      }
      const notes = update.releaseNotes
        .split('\n')
        .map((line) => line.replace(/^\s*[-*•]\s*/, '').trim())
        .filter(Boolean)
      const downloading = download.phase === 'downloading'
      const percent = download.progress != null ? Math.round(download.progress * 100) : null
      // Moonbase says the customer's licenses don't cover the new version, when the
      // notes load or when the download is refused: offer the upgrade instead.
      const notCovered = update.covered === false || download.phase === 'notCovered'
      const access = download.releaseAccess || update.releaseAccess
      const upgradeUrl = download.upgradeUrl || update.upgradeUrl
      return h(
        'div',
        {},
        h('div', { style: 'margin-bottom:24px' }, lockup()),
        h('div', { style: 'margin-bottom:14px' }, h('span', { class: 'pill update' }, icon(paths.download, { size: 14, stroke: 'var(--accent)', width: 1.9 }), 'Update available')),
        h('h1', {}, `${product} ${update.latestVersion} is ready`),
        h(
          'p',
          { style: 'margin-bottom:20px' },
          "You're on ",
          h('span', { class: 'mono em' }, update.currentVersion || 'an earlier version'),
          '. The latest is ',
          h('span', { class: 'mono', style: 'color:#82cef1' }, update.latestVersion),
          update.canDownload && !notCovered ? ', included in your license.' : notCovered ? ", which your license doesn't cover." : ", which your license doesn't include yet.",
        ),
        notes.length > 0 &&
          h(
            'div',
            { class: 'card notes' },
            h('div', { class: 'heading' }, "What's new"),
            h('div', { class: 'items' }, notes.map((line) => h('div', { class: 'note' }, icon(paths.check, { stroke: 'var(--accent)', width: 2 }), h('span', {}, line)))),
          ),
        downloading && h('div', { class: 'bar accent', style: 'margin:0 0 14px' }, h('div', { style: `width:${percent ?? 8}%` })),
        download.phase === 'done'
          ? button('Show in folder', () => run(() => invoke('reveal_update')), { iconPath: paths.download })
          : notCovered
            ? upgradeUrl && button(upgradeLabel(access), () => run(() => invoke('upgrade_license')), { iconPath: paths.external })
            : update.canDownload
            ? button(downloading ? [spinner(), percent != null ? `Downloading… ${percent}%` : 'Downloading…'] : `Download ${update.latestVersion}`, () => run(() => invoke('download_update')), {
                iconPath: downloading ? null : paths.download,
                disabled: downloading,
              })
            : button('Unlock full version', () => run(() => invoke('activate', { offline: false })), { iconPath: paths.lock }),
        download.phase === 'done' && h('p', { style: 'margin-top:12px; text-align:center; font-size:12.5px' }, `Saved to ${download.path}`),
        notCovered && h('p', { class: 'fine-print' }, [`Version ${update.latestVersion} needs an upgrade.`, releaseAccessText(access)].filter(Boolean).join(' ')),
        errorLine(download.phase === 'failed' ? download.error : null),
        h(
          'div',
          { class: 'links' },
          textButton('Remind me later', back),
          textButton('Skip this version', async () => {
            await run(() => invoke('skip_update', { version: update.latestVersion }))
            back()
          }),
        ),
      )
    },
  }

  function planName(license) {
    if (license.trial) return `${license.product.name} trial`
    if (license.activationMethod === 'Offline') return `${license.product.name} (offline)`
    return license.product.name
  }

  function features() {
    if (!config.trialFeatures.length) return null
    return h(
      'div',
      { class: 'features' },
      config.trialFeatures.map((feature) =>
        h(
          'div',
          { class: `feature${feature.included ? '' : ' excluded'}` },
          icon(feature.included ? paths.check : paths.cross, { size: 17, stroke: feature.included ? '#34d27b' : '#5a6b82', width: 2 }),
          h('span', {}, feature.label),
        ),
      ),
    )
  }

  // -- Add-ons --------------------------------------------------------------------

  const cycleSuffix = { Daily: ' / day', Weekly: ' / week', Monthly: ' / month', Quarterly: ' / quarter', Yearly: ' / year' }

  function formatAmount(amount, currency) {
    const whole = Math.abs(amount - Math.round(amount)) < 0.005
    try {
      return new Intl.NumberFormat(undefined, { style: 'currency', currency, minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 }).format(amount)
    } catch {
      return `${whole ? Math.round(amount) : amount.toFixed(2)} ${currency}`
    }
  }

  // What the add-on costs in the list's currency: its default price, or its first.
  // Empty when it isn't for sale in that currency.
  function priceText(entry, currency) {
    const pricing = entry.product.defaultVariation || entry.product.variations?.[0]
    const amount = currency && pricing?.price?.[currency]
    if (amount == null) return ''
    const recurring = pricing.recurrence?.type === 'Recurring'
    return formatAmount(amount, currency) + (recurring ? cycleSuffix[pricing.recurrence.cycleLength] || '' : '')
  }

  // The add-on as the license lists it, by its own part of its ID or its whole ID.
  function findSubProduct(license, id) {
    const prefix = `${license.product.id}.`
    const own = id.startsWith(prefix) && id.length > prefix.length ? id.slice(prefix.length) : id
    return (license.subProducts || []).find((entry) => entry.id === own) || null
  }

  // One add-on as its row shows it: where it stands here, and what to do about it.
  function describeAddOn(entry, license) {
    const price = priceText(entry, state.addOns.currency)
    const owns = entry.product.owned || entry.product.subscribed
    const forSale = !!entry.buyUrl
    const row = { id: entry.id, name: entry.product.name, status: '', tone: 'muted', primary: null, secondary: null }

    // The list says where each add-on stood when it was loaded. For one the
    // license lists, the license says where it stands on this computer now (on a
    // seat or a trial, revoked here, until when), and keeps saying it as time
    // passes and the license is refreshed: a trial that runs out with the screen
    // open shows as ended, and a seat revoked here since shows as revoked.
    const held = findSubProduct(license, entry.id)
    const active = entry.includedInLicense ? state.activeSubProducts.includes(entry.id) : entry.active
    const wasActive = held ? held.active : entry.active
    const revoked = held ? held.revoked : entry.revoked
    const ends = held ? held.expiresAt : entry.expiresAt || entry.trial?.expiresAt || null
    const lapsed = wasActive && !active && ends && daysUntil(ends) === 0
    // Whether a trial rather than a seat unlocks it here. A customer who buys an
    // add-on during its trial keeps the trial until a seat replaces it.
    const onTrial = held ? held.trial : entry.trial?.status === 'active'
    let trial = entry.trial?.status || 'unavailable'
    if (held?.trial) trial = active ? 'active' : 'expired'
    else if (trial === 'active' && lapsed) trial = 'expired'

    if (active && owns && !onTrial) {
      row.status = entry.includedInLicense ? 'Active on this computer' : entry.product.subscribed ? 'Subscribed' : 'Owned'
      row.tone = 'success'
    } else if (active) {
      const days = ends ? daysUntil(ends) : null
      row.status = join('Trial', days == null ? '' : days === 1 ? '1 day left' : `${days} days left`)
      row.tone = 'trial'
      // Bought during the trial: a seat of it is theirs to take.
      if (owns && entry.includedInLicense) row.primary = 'activate'
      else if (forSale) row.primary = 'buy'
    } else if (lapsed && owns && !onTrial) {
      // Owned until now: a subscription to it that ended.
      row.status = join('Expired', price)
      if (forSale) row.primary = 'buy'
    } else if (entry.includedInLicense && owns) {
      // Owned, but this computer holds no seat of it. Its seats are in use only when
      // neither the list nor the license knows of one free: either can be the older.
      const listKnows = !!entry.seats
      const licenseKnows = !!held?.seats
      const seatsInUse = (listKnows || licenseKnows) && (!listKnows || entry.seats.remaining <= 0) && (!licenseKnows || held.seats.remaining <= 0)
      if (revoked) {
        row.status = 'Deactivated on this computer'
        row.primary = 'activate'
      } else if (seatsInUse) {
        const total = listKnows ? entry.seats.total : held.seats.total
        row.status = total === 1 ? 'Its seat is in use on another computer' : `All ${total} seats are in use`
      } else {
        row.status = 'Not activated on this computer'
        row.primary = 'activate'
      }
    } else if (trial === 'available') {
      const days = entry.trial.numberOfDays
      row.status = join(days ? `${days}-day free trial` : 'Free trial', price)
      row.primary = 'tryIt'
      if (forSale) row.secondary = 'buy'
    } else if (trial === 'expired') {
      row.status = join('Trial ended', price)
      if (forSale) row.primary = 'buy'
    } else if (trial === 'unavailable' && entry.trial?.requiresNewsletterOptIn) {
      // Its trial is for newsletter subscribers, which the customer isn't yet.
      row.status = join('Join the newsletter to try it free', price)
      if (forSale) row.primary = 'buy'
    } else {
      row.status = join(price, entry.product.tagline || '')
      if (forSale) row.primary = 'buy'
    }
    return row
  }

  const addOnActions = {
    tryIt: { label: 'Try free', command: 'request_sub_product_trial' },
    activate: { label: 'Activate', command: 'activate_sub_product' },
    buy: { label: 'Buy', command: 'buy_sub_product' },
  }

  function addOnRow(row) {
    // While an add-on is being activated or tried its button spins, and the rest
    // wait their turn, as they do while the list is reloading.
    const busyId = state.addOns.busyId
    const anyBusy = !!busyId || state.addOns.phase === 'loading'
    const action = (kind, primary) => {
      const spins = primary && busyId === row.id
      return h(
        'button',
        { class: `btn small ${primary ? 'primary' : 'ghost'}`, type: 'button', disabled: anyBusy, onClick: () => run(() => invoke(addOnActions[kind].command, { id: row.id })) },
        spins ? spinner(14) : addOnActions[kind].label,
      )
    }
    return h(
      'div',
      { class: `card addon ${row.tone}` },
      h('div', { class: 'icon' }, icon(row.tone === 'success' ? paths.check : paths.sparkles, { size: 20, width: 1.7 })),
      h('div', { class: 'grow' }, h('div', { class: 'name' }, row.name), row.status && h('div', { class: 'status' }, row.status)),
      (row.primary || row.secondary) && h('div', { class: 'actions' }, row.secondary && action(row.secondary, false), row.primary && action(row.primary, true)),
    )
  }

  function openUpdate(fromDetails) {
    cameFromDetails = fromDetails
    go('update')
  }

  async function saveRequest() {
    offline.error = ''
    try {
      const saved = await invoke('save_machine_file')
      if (saved) offline.saved = saved
    } catch (error) {
      offline.error = error?.message ?? String(error)
    }
    render()
  }

  function pickFile(file) {
    if (!file) return
    const reader = new FileReader()
    reader.onload = () => {
      offline.file = { name: file.name, text: String(reader.result) }
      offline.error = ''
      render()
    }
    reader.onerror = () => {
      offline.error = "Couldn't read that file."
      render()
    }
    reader.readAsText(file)
  }

  async function activateOffline() {
    if (offline.busy) return
    if (!offline.file) {
      offline.error = `Add the license file from ${config.activationHost} to continue.`
      render()
      return
    }
    offline.busy = true
    offline.error = ''
    render()
    try {
      await invoke('activate_offline', { licenseFile: offline.file.text })
      offline.file = null
      offline.saved = null
      view = 'success'
    } catch {
      offline.error = state?.error?.message || "That license file isn't valid for this device."
    }
    offline.busy = false
    render()
  }

  // -- Structure, rendering and sizing ---------------------------------------------

  const screenEl = h('div')
  const closeButton = overlay ? h('button', { class: 'mb-close', type: 'button', 'aria-label': 'Close', onClick: () => invoke('close_ui') }, icon(paths.cross, { size: 18, width: 1.8 })) : null
  // The Moonbase brand mark, verbatim from the marketing site's
  // images/logos/moonbase.svg (as in moonbase-cpp's JUCE module), in one colour.
  const moonbaseMark = svg(
    '<svg width="14" height="14" viewBox="0 0 512 512" fill="currentColor" aria-hidden="true"><path d="m493.17795,174.6135c6.257,75.3 -30.577,150.948 -100.579,190.758c-66.292,37.697 -145.001,33.405 -205.402,-4.415c0.409,-1.248 0.638,-2.577 0.638,-3.96c0,-7.026 -5.717,-12.743 -12.743,-12.743c-2.876,0 -5.524,0.969 -7.659,2.582c-18.813,-15.259 -35.159,-34.207 -47.856,-56.534c-54.663,-96.122 -21.054,-218.358 75.069,-273.021a201.218,201.218 0 0 1 33.302,-15.124c-32.428,3.667 -64.663,13.809 -94.827,30.962c-104.526,59.443 -149.511,183.241 -113.473,293.249c-3.376,3.057 -5.513,7.459 -5.513,12.362c0,9.207 7.491,16.698 16.698,16.698c0.136,0 0.267,-0.017 0.401,-0.02a252.205,252.205 0 0 0 8.683,16.691c67.869,119.344 219.635,161.073 338.98,93.204a250.416,250.416 0 0 0 32.893,-22.333c1.045,0.331 2.157,0.513 3.311,0.513c6.047,0 10.968,-4.921 10.968,-10.968c0,-0.605 -0.063,-1.196 -0.157,-1.775c69.545,-64.988 96.008,-165.054 67.266,-256.126m-446.526,25.112a5.071,5.071 0 0 1 -5.066,-5.066a5.072,5.072 0 0 1 5.066,-5.067a5.073,5.073 0 0 1 5.066,5.067a5.072,5.072 0 0 1 -5.066,5.066m-15.819,153.944c-8.238,0 -14.94,-6.702 -14.94,-14.94c0,-8.239 6.702,-14.941 14.94,-14.941c8.239,0 14.941,6.702 14.941,14.941c-0.001,8.238 -6.703,14.94 -14.941,14.94m144.258,-7.659c6.057,0 10.985,4.928 10.985,10.986c0,6.058 -4.928,10.985 -10.985,10.985c-6.058,0 -10.986,-4.928 -10.986,-10.985c0.001,-6.059 4.928,-10.986 10.986,-10.986m70.829,133.004c-5.079,0 -9.21,-4.132 -9.21,-9.21c0,-5.079 4.131,-9.21 9.21,-9.21c5.078,0 9.209,4.131 9.209,9.21c0,5.078 -4.13,9.21 -9.209,9.21m169.18,-37.289c-5.078,0 -9.209,-4.132 -9.209,-9.21s4.131,-9.209 9.209,-9.209s9.21,4.131 9.21,9.209s-4.132,9.21 -9.21,9.21"></path><path d="m144.78195,275.9675c46.746,82.201 151.278,110.943 233.48,64.197c59.864,-34.044 91.363,-98.736 86.013,-163.13a170.094,170.094 0 0 0 -5.515,-31.052c-23.045,0.567 -63.864,10.137 -104.194,61.671c-52.045,66.5 -186.285,49.153 -228.855,17.372c3.503,17.443 9.796,34.632 19.071,50.942m233.513,-65.677a8.663,8.663 0 1 1 -8.663,8.663a8.662,8.662 0 0 1 8.663,-8.663m-37.006,62.618c7.02,0 12.712,5.691 12.712,12.712s-5.692,12.712 -12.712,12.712c-7.021,0 -12.712,-5.691 -12.712,-12.712s5.691,-12.712 12.712,-12.712m-102.544,12.712a6.78,6.78 0 1 1 0,13.56a6.78,6.78 0 0 1 0,-13.56"></path><ellipse cx="251.33138" cy="152.05851" rx="7.3075" ry="7.3075"></ellipse><ellipse cx="204.89499" cy="210.31151" rx="5.35076" ry="5.35076"></ellipse><ellipse cx="302.28597" cy="200.15625" rx="12.09273" ry="12.09273"></ellipse></svg>',
  )
  const badge =
    config.showBadge &&
    h(
      'footer',
      { class: 'badge' },
      icon(paths.lock, { size: 14, width: 1.5 }),
      h('span', {}, 'Licensing secured by'),
      h('button', { class: 'moonbase-link', type: 'button', onClick: () => invoke('open_link', { url: MOONBASE_SITE }).catch(() => {}) }, h('span', { class: 'moon' }, moonbaseMark), h('span', { class: 'wordmark' }, 'moonbase')),
    )
  const panel = h('main', { class: 'panel' }, closeButton, screenEl, badge)

  root.classList.add('mb-root')
  if (overlay) root.classList.add('mb-overlay-root')
  if (!overlay && config.os === 'macos') root.classList.add('mac')
  if (config.reduceMotion) root.classList.add('reduce-motion')
  root.style.setProperty('--accent', config.accent)
  root.replaceChildren(panel)

  let lastScreen = null
  let reportedHeight = 0

  // Closing is always possible in a window, which never locks the app. An overlay
  // stays up while there is no license: that is the lock.
  function canClose() {
    if (!state || state.status === 'activating' || state.status === 'loading') return false
    return !overlay || state.licensed
  }

  function render() {
    if (!state) return
    const name = screenName()
    const node = (screens[name] || screens.loading)()
    // Animate only real screen changes, not every state push.
    const wrapper = h('div', { class: name === lastScreen ? '' : 'screen' }, node)
    screenEl.replaceChildren(wrapper)
    lastScreen = name
    if (closeButton) closeButton.hidden = !canClose()
    // Measured straight away rather than on the next frame: WebKit pauses frames
    // for a window that is covered, and the window should fit its screen anyway.
    reportHeight()
  }

  function reportHeight() {
    if (!onHeight) return
    const height = Math.ceil(panel.getBoundingClientRect().height)
    if (height === reportedHeight) return
    reportedHeight = height
    onHeight(height)
  }

  function show(screen) {
    if (screen === 'update') return openUpdate(false)
    if (screen === 'license') screen = 'details'
    view = screen === 'welcome' || !screen ? null : screen
    if ((view === 'details' || view === 'addons') && !state?.license) view = null
    if (view === 'addons') loadAddOns()
    render()
  }

  function onState(next) {
    const previous = state
    state = next
    // A completed activation gets its own moment. Cancelling one while a trial runs
    // also leaves a license, but the same one, so compare activations.
    const activated = previous?.status === 'activating' && !next.activation && next.license && next.license.activationId !== previous.license?.activationId
    if (activated) view = 'success'
    // Screens about a license make no sense without one.
    if (['details', 'addons', 'update'].includes(view) && !next.license) view = null
    // Activating supersedes whatever the user was looking at, and so does a license
    // turning out not to cover this version.
    if (next.status === 'activating') view = null
    if (next.status === 'notCovered' && previous?.status !== 'notCovered') view = null
    if (view === 'success' && !next.license) view = null
    // The add-ons were listed for a license that has since been replaced.
    if (view === 'addons' && next.license && next.addOns.phase === 'idle' && previous?.addOns?.phase !== 'idle') loadAddOns()
    render()
  }

  // Start with the state handed over, or ask for it.
  async function start(initial, screen) {
    try {
      device = await invoke('device')
    } catch {
      device = null
    }
    onState(initial || (await invoke('state')))
    if (screen) show(screen)
  }

  return {
    update: onState,
    show,
    start,
    canClose,
    focus() {
      panel.querySelector('button, [href], input, label')?.focus({ preventScroll: true })
    },
    download(next) {
      download = next
      render()
    },
  }
}