summaryrefslogtreecommitdiffstats
path: root/browser/modules/PageActions.sys.mjs
blob: f5951142ddf7512b5119d8a20e96a34af861b9e3 (plain)
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
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
  ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
  BinarySearch: "resource://gre/modules/BinarySearch.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

const ACTION_ID_BOOKMARK = "bookmark";
const ACTION_ID_BUILT_IN_SEPARATOR = "builtInSeparator";
const ACTION_ID_TRANSIENT_SEPARATOR = "transientSeparator";

const PREF_PERSISTED_ACTIONS = "browser.pageActions.persistedActions";
const PERSISTED_ACTIONS_CURRENT_VERSION = 1;

// Escapes the given raw URL string, and returns an equivalent CSS url()
// value for it.
function escapeCSSURL(url) {
  return `url("${url.replace(/[\\\s"]/g, encodeURIComponent)}")`;
}

export var PageActions = {
  /**
   * Initializes PageActions.
   *
   * @param {boolean} addShutdownBlocker
   *   This param exists only for tests.  Normally the default value of true
   *   must be used.
   */
  init(addShutdownBlocker = true) {
    this._initBuiltInActions();

    let callbacks = this._deferredAddActionCalls;
    delete this._deferredAddActionCalls;

    this._loadPersistedActions();

    // Register the built-in actions, which are defined below in this file.
    for (let options of gBuiltInActions) {
      if (!this.actionForID(options.id)) {
        this._registerAction(new Action(options));
      }
    }

    // Now place them all in each window.  Instead of splitting the register and
    // place steps, we could simply call addAction, which does both, but doing
    // it this way means that all windows initially place their actions in the
    // urlbar the same way -- placeAllActions -- regardless of whether they're
    // open when this method is called or opened later.
    for (let bpa of allBrowserPageActions()) {
      bpa.placeAllActionsInUrlbar();
    }

    // These callbacks are deferred until init happens and all built-in actions
    // are added.
    while (callbacks && callbacks.length) {
      callbacks.shift()();
    }

    if (addShutdownBlocker) {
      // Purge removed actions from persisted state on shutdown.  The point is
      // not to do it on Action.remove().  That way actions that are removed and
      // re-added while the app is running will have their urlbar placement and
      // other state remembered and restored.  This happens for upgraded and
      // downgraded extensions, for example.
      lazy.AsyncShutdown.profileBeforeChange.addBlocker(
        "PageActions: purging unregistered actions from cache",
        () => this._purgeUnregisteredPersistedActions()
      );
    }
  },

  _deferredAddActionCalls: [],

  /**
   * A list of all Action objects, not in any particular order.  Not live.
   * (array of Action objects)
   */
  get actions() {
    let lists = [
      this._builtInActions,
      this._nonBuiltInActions,
      this._transientActions,
    ];
    return lists.reduce((memo, list) => memo.concat(list), []);
  },

  /**
   * The list of Action objects that should appear in the panel for a given
   * window, sorted in the order in which they appear.  If there are both
   * built-in and non-built-in actions, then the list will include the separator
   * between the two.  The list is not live.  (array of Action objects)
   *
   * @param  browserWindow (DOM window, required)
   *         This window's actions will be returned.
   * @return (array of PageAction.Action objects) The actions currently in the
   *         given window's panel.
   */
  actionsInPanel(browserWindow) {
    function filter(action) {
      return action.shouldShowInPanel(browserWindow);
    }
    let actions = this._builtInActions.filter(filter);
    let nonBuiltInActions = this._nonBuiltInActions.filter(filter);
    if (nonBuiltInActions.length) {
      if (actions.length) {
        actions.push(
          new Action({
            id: ACTION_ID_BUILT_IN_SEPARATOR,
            _isSeparator: true,
          })
        );
      }
      actions.push(...nonBuiltInActions);
    }
    let transientActions = this._transientActions.filter(filter);
    if (transientActions.length) {
      if (actions.length) {
        actions.push(
          new Action({
            id: ACTION_ID_TRANSIENT_SEPARATOR,
            _isSeparator: true,
          })
        );
      }
      actions.push(...transientActions);
    }
    return actions;
  },

  /**
   * The list of actions currently in the urlbar, sorted in the order in which
   * they appear.  Not live.
   *
   * @param  browserWindow (DOM window, required)
   *         This window's actions will be returned.
   * @return (array of PageAction.Action objects) The actions currently in the
   *         given window's urlbar.
   */
  actionsInUrlbar(browserWindow) {
    // Remember that IDs in idsInUrlbar may belong to actions that aren't
    // currently registered.
    return this._persistedActions.idsInUrlbar.reduce((actions, id) => {
      let action = this.actionForID(id);
      if (action && action.shouldShowInUrlbar(browserWindow)) {
        actions.push(action);
      }
      return actions;
    }, []);
  },

  /**
   * Gets an action.
   *
   * @param  id (string, required)
   *         The ID of the action to get.
   * @return The Action object, or null if none.
   */
  actionForID(id) {
    return this._actionsByID.get(id);
  },

  /**
   * Registers an action.
   *
   * Actions are registered by their IDs.  An error is thrown if an action with
   * the given ID has already been added.  Use actionForID() before calling this
   * method if necessary.
   *
   * Be sure to call remove() on the action if the lifetime of the code that
   * owns it is shorter than the browser's -- if it lives in an extension, for
   * example.
   *
   * @param  action (Action, required)
   *         The Action object to register.
   * @return The given Action.
   */
  addAction(action) {
    if (this._deferredAddActionCalls) {
      // init() hasn't been called yet.  Defer all additions until it's called,
      // at which time _deferredAddActionCalls will be deleted.
      this._deferredAddActionCalls.push(() => this.addAction(action));
      return action;
    }
    this._registerAction(action);
    for (let bpa of allBrowserPageActions()) {
      bpa.placeAction(action);
    }
    return action;
  },

  _registerAction(action) {
    if (this.actionForID(action.id)) {
      throw new Error(`Action with ID '${action.id}' already added`);
    }
    this._actionsByID.set(action.id, action);

    // Insert the action into the appropriate list, either _builtInActions or
    // _nonBuiltInActions.

    // Keep in mind that _insertBeforeActionID may be present but null, which
    // means the action should be appended to the built-ins.
    if ("__insertBeforeActionID" in action) {
      // A "semi-built-in" action, probably an action from an extension
      // bundled with the browser.  Right now we simply assume that no other
      // consumers will use _insertBeforeActionID.
      let index = !action.__insertBeforeActionID
        ? -1
        : this._builtInActions.findIndex(a => {
            return a.id == action.__insertBeforeActionID;
          });
      if (index < 0) {
        // Append the action (excluding transient actions).
        index = this._builtInActions.filter(a => !a.__transient).length;
      }
      this._builtInActions.splice(index, 0, action);
    } else if (action.__transient) {
      // A transient action.
      this._transientActions.push(action);
    } else if (action._isBuiltIn) {
      // A built-in action. These are mostly added on init before all other
      // actions, one after the other. Extension actions load later and should
      // be at the end, so just push onto the array.
      this._builtInActions.push(action);
    } else {
      // A non-built-in action, like a non-bundled extension potentially.
      // Keep this list sorted by title.
      let index = lazy.BinarySearch.insertionIndexOf(
        (a1, a2) => {
          return a1.getTitle().localeCompare(a2.getTitle());
        },
        this._nonBuiltInActions,
        action
      );
      this._nonBuiltInActions.splice(index, 0, action);
    }

    let isNew = !this._persistedActions.ids.includes(action.id);
    if (isNew) {
      // The action is new.  Store it in the persisted actions.
      this._persistedActions.ids.push(action.id);
    }

    // Actions are always pinned to the urlbar, except for panel separators.
    action._pinnedToUrlbar = !action.__isSeparator;
    this._updateIDsPinnedToUrlbarForAction(action);
  },

  _updateIDsPinnedToUrlbarForAction(action) {
    let index = this._persistedActions.idsInUrlbar.indexOf(action.id);
    if (action.pinnedToUrlbar) {
      if (index < 0) {
        index =
          action.id == ACTION_ID_BOOKMARK
            ? -1
            : this._persistedActions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK);
        if (index < 0) {
          index = this._persistedActions.idsInUrlbar.length;
        }
        this._persistedActions.idsInUrlbar.splice(index, 0, action.id);
      }
    } else if (index >= 0) {
      this._persistedActions.idsInUrlbar.splice(index, 1);
    }
    this._storePersistedActions();
  },

  // These keep track of currently registered actions.
  _builtInActions: [],
  _nonBuiltInActions: [],
  _transientActions: [],
  _actionsByID: new Map(),

  /**
   * Call this when an action is removed.
   *
   * @param  action (Action object, required)
   *         The action that was removed.
   */
  onActionRemoved(action) {
    if (!this.actionForID(action.id)) {
      // The action isn't registered (yet).  Not an error.
      return;
    }

    this._actionsByID.delete(action.id);
    let lists = [
      this._builtInActions,
      this._nonBuiltInActions,
      this._transientActions,
    ];
    for (let list of lists) {
      let index = list.findIndex(a => a.id == action.id);
      if (index >= 0) {
        list.splice(index, 1);
        break;
      }
    }

    for (let bpa of allBrowserPageActions()) {
      bpa.removeAction(action);
    }
  },

  /**
   * Call this when an action's pinnedToUrlbar property changes.
   *
   * @param  action (Action object, required)
   *         The action whose pinnedToUrlbar property changed.
   */
  onActionToggledPinnedToUrlbar(action) {
    if (!this.actionForID(action.id)) {
      // This may be called before the action has been added.
      return;
    }
    this._updateIDsPinnedToUrlbarForAction(action);
    for (let bpa of allBrowserPageActions()) {
      bpa.placeActionInUrlbar(action);
    }
  },

  // For tests.  See Bug 1413692.
  _reset() {
    PageActions._purgeUnregisteredPersistedActions();
    PageActions._builtInActions = [];
    PageActions._nonBuiltInActions = [];
    PageActions._transientActions = [];
    PageActions._actionsByID = new Map();
  },

  _storePersistedActions() {
    let json = JSON.stringify(this._persistedActions);
    Services.prefs.setStringPref(PREF_PERSISTED_ACTIONS, json);
  },

  _loadPersistedActions() {
    let actions;
    try {
      let json = Services.prefs.getStringPref(PREF_PERSISTED_ACTIONS);
      actions = this._migratePersistedActions(JSON.parse(json));
    } catch (ex) {}

    // Handle migrating to and from Proton.  We want to gracefully handle
    // downgrades from Proton, and since Proton is controlled by a pref, we also
    // don't want to assume that a downgrade is possible only by downgrading the
    // app.  That makes it hard to use the normal migration approach of creating
    // a new persisted actions version, so we handle Proton migration specially.
    // We try-catch it separately from the earlier _migratePersistedActions call
    // because it should not be short-circuited when the pref load or usual
    // migration fails.
    try {
      actions = this._migratePersistedActionsProton(actions);
    } catch (ex) {}

    // If `actions` is still not defined, then this._persistedActions will
    // remain its default value.
    if (actions) {
      this._persistedActions = actions;
    }
  },

  _purgeUnregisteredPersistedActions() {
    // Remove all action IDs from persisted state that do not correspond to
    // currently registered actions.
    for (let name of ["ids", "idsInUrlbar"]) {
      this._persistedActions[name] = this._persistedActions[name].filter(id => {
        return this.actionForID(id);
      });
    }
    this._storePersistedActions();
  },

  _migratePersistedActions(actions) {
    // Start with actions.version and migrate one version at a time, all the way
    // up to the current version.
    for (
      let version = actions.version || 0;
      version < PERSISTED_ACTIONS_CURRENT_VERSION;
      version++
    ) {
      let methodName = `_migratePersistedActionsTo${version + 1}`;
      actions = this[methodName](actions);
      actions.version = version + 1;
    }
    return actions;
  },

  _migratePersistedActionsTo1(actions) {
    // The `ids` object is a mapping: action ID => true.  Convert it to an array
    // to save space in the prefs.
    let ids = [];
    for (let id in actions.ids) {
      ids.push(id);
    }
    // Move the bookmark ID to the end of idsInUrlbar.  The bookmark action
    // should always remain at the end of the urlbar, if present.
    let bookmarkIndex = actions.idsInUrlbar.indexOf(ACTION_ID_BOOKMARK);
    if (bookmarkIndex >= 0) {
      actions.idsInUrlbar.splice(bookmarkIndex, 1);
      actions.idsInUrlbar.push(ACTION_ID_BOOKMARK);
    }
    return {
      ids,
      idsInUrlbar: actions.idsInUrlbar,
    };
  },

  _migratePersistedActionsProton(actions) {
    if (actions?.idsInUrlbarPreProton) {
      // continue with Proton
    } else if (actions) {
      // upgrade to Proton
      actions.idsInUrlbarPreProton = [...(actions.idsInUrlbar || [])];
    } else {
      // new profile with Proton
      actions = {
        ids: [],
        idsInUrlbar: [],
        idsInUrlbarPreProton: [],
        version: PERSISTED_ACTIONS_CURRENT_VERSION,
      };
    }
    return actions;
  },

  /**
   * Send an ASRouter trigger to possibly show messaging related to the page
   * action that was placed in the urlbar.
   *
   * @param {Element} buttonNode The page action button node.
   */
  sendPlacedInUrlbarTrigger(buttonNode) {
    lazy.setTimeout(async () => {
      await lazy.ASRouter.initialized;
      let win = buttonNode?.ownerGlobal;
      if (!win || buttonNode.hidden) {
        return;
      }
      await lazy.ASRouter.sendTriggerMessage({
        browser: win.gBrowser.selectedBrowser,
        id: "pageActionInUrlbar",
        context: { pageAction: buttonNode.id },
      });
    }, 500);
  },

  // This keeps track of all actions, even those that are not currently
  // registered because they have been removed, so long as
  // _purgeUnregisteredPersistedActions has not been called.
  _persistedActions: {
    version: PERSISTED_ACTIONS_CURRENT_VERSION,
    // action IDs that have ever been seen and not removed, order not important
    ids: [],
    // action IDs ordered by position in urlbar
    idsInUrlbar: [],
  },
};

/**
 * A single page action.
 *
 * Each action can have both per-browser-window state and global state.
 * Per-window state takes precedence over global state.  This is reflected in
 * the title, tooltip, disabled, and icon properties.  Each of these properties
 * has a getter method and setter method that takes a browser window.  Pass null
 * to get the action's global state.  Pass a browser window to get the per-
 * window state.  However, if you pass a window and the action has no state for
 * that window, then the global state will be returned.
 *
 * `options` is a required object with the following properties.  Regarding the
 * properties discussed in the previous paragraph, the values in `options` set
 * global state.
 *
 * @param id (string, required)
 *        The action's ID.  Treat this like the ID of a DOM node.
 * @param title (string, optional)
 *        The action's title. It is optional for built in actions.
 * @param anchorIDOverride (string, optional)
 *        Pass a string to override the node to which the action's activated-
 *        action panel is anchored.
 * @param disabled (bool, optional)
 *        Pass true to cause the action to be disabled initially in all browser
 *        windows.  False by default.
 * @param extensionID (string, optional)
 *        If the action lives in an extension, pass its ID.
 * @param iconURL (string or object, optional)
 *        The URL string of the action's icon.  Usually you want to specify an
 *        icon in CSS, but this option is useful if that would be a pain for
 *        some reason.  You can also pass an object that maps pixel sizes to
 *        URLs, like { 16: url16, 32: url32 }.  The best size for the user's
 *        screen will be used.
 * @param isBadged (bool, optional)
 *        If true, the toolbarbutton for this action will get a
 *        "badged" attribute.
 * @param onBeforePlacedInWindow (function, optional)
 *        Called before the action is placed in the window:
 *        onBeforePlacedInWindow(window)
 *        * window: The window that the action will be placed in.
 * @param onCommand (function, optional)
 *        Called when the action is clicked, but only if it has neither a
 *        subview nor an iframe:
 *        onCommand(event, buttonNode)
 *        * event: The triggering event.
 *        * buttonNode: The button node that was clicked.
 * @param onIframeHiding (function, optional)
 *        Called when the action's iframe is hiding:
 *        onIframeHiding(iframeNode, parentPanelNode)
 *        * iframeNode: The iframe.
 *        * parentPanelNode: The panel node in which the iframe is shown.
 * @param onIframeHidden (function, optional)
 *        Called when the action's iframe is hidden:
 *        onIframeHidden(iframeNode, parentPanelNode)
 *        * iframeNode: The iframe.
 *        * parentPanelNode: The panel node in which the iframe is shown.
 * @param onIframeShowing (function, optional)
 *        Called when the action's iframe is showing to the user:
 *        onIframeShowing(iframeNode, parentPanelNode)
 *        * iframeNode: The iframe.
 *        * parentPanelNode: The panel node in which the iframe is shown.
 * @param onLocationChange (function, optional)
 *        Called after tab switch or when the current <browser>'s location
 *        changes:
 *        onLocationChange(browserWindow)
 *        * browserWindow: The browser window containing the tab switch or
 *          changed <browser>.
 * @param onPlacedInPanel (function, optional)
 *        Called when the action is added to the page action panel in a browser
 *        window:
 *        onPlacedInPanel(buttonNode)
 *        * buttonNode: The action's node in the page action panel.
 * @param onPlacedInUrlbar (function, optional)
 *        Called when the action is added to the urlbar in a browser window:
 *        onPlacedInUrlbar(buttonNode)
 *        * buttonNode: The action's node in the urlbar.
 * @param onRemovedFromWindow (function, optional)
 *        Called after the action is removed from a browser window:
 *        onRemovedFromWindow(browserWindow)
 *        * browserWindow: The browser window that the action was removed from.
 * @param onShowingInPanel (function, optional)
 *        Called when a browser window's page action panel is showing:
 *        onShowingInPanel(buttonNode)
 *        * buttonNode: The action's node in the page action panel.
 * @param onSubviewPlaced (function, optional)
 *        Called when the action's subview is added to its parent panel in a
 *        browser window:
 *        onSubviewPlaced(panelViewNode)
 *        * panelViewNode: The subview's panelview node.
 * @param onSubviewShowing (function, optional)
 *        Called when the action's subview is showing in a browser window:
 *        onSubviewShowing(panelViewNode)
 *        * panelViewNode: The subview's panelview node.
 * @param pinnedToUrlbar (bool, optional)
 *        Pass true to pin the action to the urlbar.  An action is shown in the
 *        urlbar if it's pinned and not disabled.  False by default.
 * @param tooltip (string, optional)
 *        The action's button tooltip text.
 * @param urlbarIDOverride (string, optional)
 *        Usually the ID of the action's button in the urlbar will be generated
 *        automatically.  Pass a string for this property to override that with
 *        your own ID.
 * @param wantsIframe (bool, optional)
 *        Pass true to make an action that shows an iframe in a panel when
 *        clicked.
 * @param wantsSubview (bool, optional)
 *        Pass true to make an action that shows a panel subview when clicked.
 * @param disablePrivateBrowsing (bool, optional)
 *        Pass true to prevent the action from showing in a private browsing window.
 */
function Action(options) {
  setProperties(this, options, {
    id: true,
    title: false,
    anchorIDOverride: false,
    disabled: false,
    extensionID: false,
    iconURL: false,
    isBadged: false,
    labelForHistogram: false,
    onBeforePlacedInWindow: false,
    onCommand: false,
    onIframeHiding: false,
    onIframeHidden: false,
    onIframeShowing: false,
    onLocationChange: false,
    onPlacedInPanel: false,
    onPlacedInUrlbar: false,
    onRemovedFromWindow: false,
    onShowingInPanel: false,
    onSubviewPlaced: false,
    onSubviewShowing: false,
    onPinToUrlbarToggled: false,
    pinnedToUrlbar: false,
    tooltip: false,
    urlbarIDOverride: false,
    wantsIframe: false,
    wantsSubview: false,
    disablePrivateBrowsing: false,

    // private

    // (string, optional)
    // The ID of another action before which to insert this new action in the
    // panel.
    _insertBeforeActionID: false,

    // (bool, optional)
    // True if this isn't really an action but a separator to be shown in the
    // page action panel.
    _isSeparator: false,

    // (bool, optional)
    // Transient actions have a couple of special properties: (1) They stick to
    // the bottom of the panel, and (2) they're hidden in the panel when they're
    // disabled.  Other than that they behave like other actions.
    _transient: false,

    // (bool, optional)
    // True if the action's urlbar button is defined in markup.  In that case, a
    // node with the action's urlbar node ID should already exist in the DOM
    // (either the auto-generated ID or urlbarIDOverride).  That node will be
    // shown when the action is added to the urlbar and hidden when the action
    // is removed from the urlbar.
    _urlbarNodeInMarkup: false,
  });

  /**
   * A cache of the pre-computed CSS variable values for a given icon
   * URLs object, as passed to _createIconProperties.
   */
  this._iconProperties = new WeakMap();

  /**
   * The global values for the action properties.
   */
  this._globalProps = {
    disabled: this._disabled,
    iconURL: this._iconURL,
    iconProps: this._createIconProperties(this._iconURL),
    title: this._title,
    tooltip: this._tooltip,
    wantsSubview: this._wantsSubview,
  };

  /**
   * A mapping of window-specific action property objects, each of which
   * derives from the _globalProps object.
   */
  this._windowProps = new WeakMap();
}

Action.prototype = {
  /**
   * The ID of the action's parent extension (string)
   */
  get extensionID() {
    return this._extensionID;
  },

  /**
   * The action's ID (string)
   */
  get id() {
    return this._id;
  },

  get disablePrivateBrowsing() {
    return !!this._disablePrivateBrowsing;
  },

  /**
   * Verifies that the action can be shown in a private window.  For
   * extensions, verifies the extension has access to the window.
   */
  canShowInWindow(browserWindow) {
    if (this._extensionID) {
      let policy = WebExtensionPolicy.getByID(this._extensionID);
      if (!policy.canAccessWindow(browserWindow)) {
        return false;
      }
    }
    return !(
      this.disablePrivateBrowsing &&
      lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow)
    );
  },

  /**
   * True if the action is pinned to the urlbar.  The action is shown in the
   * urlbar if it's pinned and not disabled.  (bool)
   */
  get pinnedToUrlbar() {
    return this._pinnedToUrlbar || false;
  },
  set pinnedToUrlbar(shown) {
    if (this.pinnedToUrlbar != shown) {
      this._pinnedToUrlbar = shown;
      PageActions.onActionToggledPinnedToUrlbar(this);
      this.onPinToUrlbarToggled();
    }
  },

  /**
   * The action's disabled state (bool)
   */
  getDisabled(browserWindow = null) {
    return !!this._getProperties(browserWindow).disabled;
  },
  setDisabled(value, browserWindow = null) {
    return this._setProperty("disabled", !!value, browserWindow);
  },

  /**
   * The action's icon URL string, or an object mapping sizes to URL strings
   * (string or object)
   */
  getIconURL(browserWindow = null) {
    return this._getProperties(browserWindow).iconURL;
  },
  setIconURL(value, browserWindow = null) {
    let props = this._getProperties(browserWindow, !!browserWindow);
    props.iconURL = value;
    props.iconProps = this._createIconProperties(value);

    this._updateProperty("iconURL", props.iconProps, browserWindow);
    return value;
  },

  /**
   * The set of CSS variables which define the action's icons in various
   * sizes. This is generated automatically from the iconURL property.
   */
  getIconProperties(browserWindow = null) {
    return this._getProperties(browserWindow).iconProps;
  },

  _createIconProperties(urls) {
    if (urls && typeof urls == "object") {
      let props = this._iconProperties.get(urls);
      if (!props) {
        props = Object.freeze({
          "--pageAction-image": `image-set(
            ${escapeCSSURL(this._iconURLForSize(urls, 16))},
            ${escapeCSSURL(this._iconURLForSize(urls, 32))} 2x
          )`,
        });
        this._iconProperties.set(urls, props);
      }
      return props;
    }

    let cssURL = urls ? escapeCSSURL(urls) : null;
    return Object.freeze({
      "--pageAction-image": cssURL,
    });
  },

  /**
   * The action's title (string). Note, built in actions will
   * not have a title property.
   */
  getTitle(browserWindow = null) {
    return this._getProperties(browserWindow).title;
  },
  setTitle(value, browserWindow = null) {
    return this._setProperty("title", value, browserWindow);
  },

  /**
   * The action's tooltip (string)
   */
  getTooltip(browserWindow = null) {
    return this._getProperties(browserWindow).tooltip;
  },
  setTooltip(value, browserWindow = null) {
    return this._setProperty("tooltip", value, browserWindow);
  },

  /**
   * Whether the action wants a subview (bool)
   */
  getWantsSubview(browserWindow = null) {
    return !!this._getProperties(browserWindow).wantsSubview;
  },
  setWantsSubview(value, browserWindow = null) {
    return this._setProperty("wantsSubview", !!value, browserWindow);
  },

  /**
   * Sets a property, optionally for a particular browser window.
   *
   * @param  name (string, required)
   *         The (non-underscored) name of the property.
   * @param  value
   *         The value.
   * @param  browserWindow (DOM window, optional)
   *         If given, then the property will be set in this window's state, not
   *         globally.
   */
  _setProperty(name, value, browserWindow) {
    let props = this._getProperties(browserWindow, !!browserWindow);
    props[name] = value;

    this._updateProperty(name, value, browserWindow);
    return value;
  },

  _updateProperty(name, value, browserWindow) {
    // This may be called before the action has been added.
    if (PageActions.actionForID(this.id)) {
      for (let bpa of allBrowserPageActions(browserWindow)) {
        bpa.updateAction(this, name, { value });
      }
    }
  },

  /**
   * Returns the properties object for the given window, if it exists,
   * or the global properties object if no window-specific properties
   * exist.
   *
   * @param {Window?} window
   *        The window for which to return the properties object, or
   *        null to return the global properties object.
   * @param {bool} [forceWindowSpecific = false]
   *        If true, always returns a window-specific properties object.
   *        If a properties object does not exist for the given window,
   *        one is created and cached.
   * @returns {object}
   */
  _getProperties(window, forceWindowSpecific = false) {
    let props = window && this._windowProps.get(window);

    if (!props && forceWindowSpecific) {
      props = Object.create(this._globalProps);
      this._windowProps.set(window, props);
    }

    return props || this._globalProps;
  },

  /**
   * Override for the ID of the action's activated-action panel anchor (string)
   */
  get anchorIDOverride() {
    return this._anchorIDOverride;
  },

  /**
   * Override for the ID of the action's urlbar node (string)
   */
  get urlbarIDOverride() {
    return this._urlbarIDOverride;
  },

  /**
   * True if the action is shown in an iframe (bool)
   */
  get wantsIframe() {
    return this._wantsIframe || false;
  },

  get isBadged() {
    return this._isBadged || false;
  },

  get labelForHistogram() {
    // The histogram label value has a length limit of 20 and restricted to a
    // pattern. See MAX_LABEL_LENGTH and CPP_IDENTIFIER_PATTERN in
    // toolkit/components/telemetry/parse_histograms.py
    return (
      this._labelForHistogram ||
      this._id.replace(/_\w{1}/g, match => match[1].toUpperCase()).substr(0, 20)
    );
  },

  /**
   * Selects the best matching icon from the given URLs object for the
   * given preferred size.
   *
   * @param {object} urls
   *        An object containing square icons of various sizes. The name
   *        of each property is its width, and the value is its image URL.
   * @param {integer} peferredSize
   *        The preferred icon width. The most appropriate icon in the
   *        urls object will be chosen to match that size. An exact
   *        match will be preferred, followed by an icon exactly double
   *        the size, followed by the smallest icon larger than the
   *        preferred size, followed by the largest available icon.
   * @returns {string}
   *        The chosen icon URL.
   */
  _iconURLForSize(urls, preferredSize) {
    // This case is copied from ExtensionParent.sys.mjs so that our image logic is
    // the same, so that WebExtensions page action tests that deal with icons
    // pass.
    let bestSize = null;
    if (urls[preferredSize]) {
      bestSize = preferredSize;
    } else if (urls[2 * preferredSize]) {
      bestSize = 2 * preferredSize;
    } else {
      let sizes = Object.keys(urls)
        .map(key => parseInt(key, 10))
        .sort((a, b) => a - b);
      bestSize =
        sizes.find(candidate => candidate > preferredSize) || sizes.pop();
    }
    return urls[bestSize];
  },

  /**
   * Performs the command for an action.  If the action has an onCommand
   * handler, then it's called.  If the action has a subview or iframe, then a
   * panel is opened, displaying the subview or iframe.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window in which to perform the action.
   */
  doCommand(browserWindow) {
    browserPageActions(browserWindow).doCommandForAction(this);
  },

  /**
   * Call this when before placing the action in the window.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window the action will be placed in.
   */
  onBeforePlacedInWindow(browserWindow) {
    if (this._onBeforePlacedInWindow) {
      this._onBeforePlacedInWindow(browserWindow);
    }
  },

  /**
   * Call this when the user activates the action.
   *
   * @param  event (DOM event, required)
   *         The triggering event.
   * @param  buttonNode (DOM node, required)
   *         The action's panel or urlbar button node that was clicked.
   */
  onCommand(event, buttonNode) {
    if (this._onCommand) {
      this._onCommand(event, buttonNode);
    }
  },

  /**
   * Call this when the action's iframe is hiding.
   *
   * @param  iframeNode (DOM node, required)
   *         The iframe that's hiding.
   * @param  parentPanelNode (DOM node, required)
   *         The panel in which the iframe is hiding.
   */
  onIframeHiding(iframeNode, parentPanelNode) {
    if (this._onIframeHiding) {
      this._onIframeHiding(iframeNode, parentPanelNode);
    }
  },

  /**
   * Call this when the action's iframe is hidden.
   *
   * @param  iframeNode (DOM node, required)
   *         The iframe that's being hidden.
   * @param  parentPanelNode (DOM node, required)
   *         The panel in which the iframe is hidden.
   */
  onIframeHidden(iframeNode, parentPanelNode) {
    if (this._onIframeHidden) {
      this._onIframeHidden(iframeNode, parentPanelNode);
    }
  },

  /**
   * Call this when the action's iframe is showing.
   *
   * @param  iframeNode (DOM node, required)
   *         The iframe that's being shown.
   * @param  parentPanelNode (DOM node, required)
   *         The panel in which the iframe is shown.
   */
  onIframeShowing(iframeNode, parentPanelNode) {
    if (this._onIframeShowing) {
      this._onIframeShowing(iframeNode, parentPanelNode);
    }
  },

  /**
   * Call this on tab switch or when the current <browser>'s location changes.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window containing the tab switch or changed <browser>.
   */
  onLocationChange(browserWindow) {
    if (this._onLocationChange) {
      this._onLocationChange(browserWindow);
    }
  },

  /**
   * Call this when a DOM node for the action is added to the page action panel.
   *
   * @param  buttonNode (DOM node, required)
   *         The action's panel button node.
   */
  onPlacedInPanel(buttonNode) {
    if (this._onPlacedInPanel) {
      this._onPlacedInPanel(buttonNode);
    }
  },

  /**
   * Call this when a DOM node for the action is added to the urlbar.
   *
   * @param  buttonNode (DOM node, required)
   *         The action's urlbar button node.
   */
  onPlacedInUrlbar(buttonNode) {
    if (this._onPlacedInUrlbar) {
      this._onPlacedInUrlbar(buttonNode);
    }
  },

  /**
   * Call this when the DOM nodes for the action are removed from a browser
   * window.
   *
   * @param  browserWindow (DOM window, required)
   *         The browser window the action was removed from.
   */
  onRemovedFromWindow(browserWindow) {
    if (this._onRemovedFromWindow) {
      this._onRemovedFromWindow(browserWindow);
    }
  },

  /**
   * Call this when the action's button is shown in the page action panel.
   *
   * @param  buttonNode (DOM node, required)
   *         The action's panel button node.
   */
  onShowingInPanel(buttonNode) {
    if (this._onShowingInPanel) {
      this._onShowingInPanel(buttonNode);
    }
  },

  /**
   * Call this when a panelview node for the action's subview is added to the
   * DOM.
   *
   * @param  panelViewNode (DOM node, required)
   *         The subview's panelview node.
   */
  onSubviewPlaced(panelViewNode) {
    if (this._onSubviewPlaced) {
      this._onSubviewPlaced(panelViewNode);
    }
  },

  /**
   * Call this when a panelview node for the action's subview is showing.
   *
   * @param  panelViewNode (DOM node, required)
   *         The subview's panelview node.
   */
  onSubviewShowing(panelViewNode) {
    if (this._onSubviewShowing) {
      this._onSubviewShowing(panelViewNode);
    }
  },
  /**
   * Call this when an icon in the url is pinned or unpinned.
   */
  onPinToUrlbarToggled() {
    if (this._onPinToUrlbarToggled) {
      this._onPinToUrlbarToggled();
    }
  },

  /**
   * Removes the action's DOM nodes from all browser windows.
   *
   * PageActions will remember the action's urlbar placement, if any, after this
   * method is called until app shutdown.  If the action is not added again
   * before shutdown, then PageActions will discard the placement, and the next
   * time the action is added, its placement will be reset.
   */
  remove() {
    PageActions.onActionRemoved(this);
  },

  /**
   * Returns whether the action should be shown in a given window's panel.
   *
   * @param  browserWindow (DOM window, required)
   *         The window.
   * @return True if the action should be shown and false otherwise.  Actions
   *         are always shown in the panel unless they're both transient and
   *         disabled.
   */
  shouldShowInPanel(browserWindow) {
    // When Proton is enabled, the extension page actions should behave similarly
    // to a transient action, and be hidden from the urlbar overflow menu if they
    // are disabled (as in the urlbar when the overflow menu isn't available)
    //
    // TODO(Bug 1704139): as a follow up we may look into just set on all
    // extensions pageActions `_transient: true`, at least once we sunset
    // the proton preference and we don't need the pre-Proton behavior anymore,
    // and remove this special case.
    const isProtonExtensionAction = this.extensionID;

    return (
      (!(this.__transient || isProtonExtensionAction) ||
        !this.getDisabled(browserWindow)) &&
      this.canShowInWindow(browserWindow)
    );
  },

  /**
   * Returns whether the action should be shown in a given window's urlbar.
   *
   * @param  browserWindow (DOM window, required)
   *         The window.
   * @return True if the action should be shown and false otherwise.  The action
   *         should be shown if it's both pinned and not disabled.
   */
  shouldShowInUrlbar(browserWindow) {
    return (
      this.pinnedToUrlbar &&
      !this.getDisabled(browserWindow) &&
      this.canShowInWindow(browserWindow)
    );
  },

  get _isBuiltIn() {
    let builtInIDs = ["screenshots_mozilla_org"].concat(
      gBuiltInActions.filter(a => !a.__isSeparator).map(a => a.id)
    );
    return builtInIDs.includes(this.id);
  },

  get _isMozillaAction() {
    return this._isBuiltIn || this.id == "webcompat-reporter_mozilla_org";
  },
};

PageActions.Action = Action;

PageActions.ACTION_ID_BUILT_IN_SEPARATOR = ACTION_ID_BUILT_IN_SEPARATOR;
PageActions.ACTION_ID_TRANSIENT_SEPARATOR = ACTION_ID_TRANSIENT_SEPARATOR;

// These are only necessary so that the test can use them.
PageActions.ACTION_ID_BOOKMARK = ACTION_ID_BOOKMARK;
PageActions.PREF_PERSISTED_ACTIONS = PREF_PERSISTED_ACTIONS;

// Sorted in the order in which they should appear in the page action panel.
// Does not include the page actions of extensions bundled with the browser.
// They're added by the relevant extension code.
// NOTE: If you add items to this list (or system add-on actions that we
// want to keep track of), make sure to also update Histograms.json for the
// new actions.
var gBuiltInActions;

PageActions._initBuiltInActions = function () {
  gBuiltInActions = [
    // bookmark
    {
      id: ACTION_ID_BOOKMARK,
      urlbarIDOverride: "star-button-box",
      _urlbarNodeInMarkup: true,
      pinnedToUrlbar: true,
      onShowingInPanel(buttonNode) {
        browserPageActions(buttonNode).bookmark.onShowingInPanel(buttonNode);
      },
      onCommand(event, buttonNode) {
        browserPageActions(buttonNode).bookmark.onCommand(event, buttonNode);
      },
    },
  ];
};

/**
 * Gets a BrowserPageActions object in a browser window.
 *
 * @param  obj
 *         Either a DOM node or a browser window.
 * @return The BrowserPageActions object in the browser window related to the
 *         given object.
 */
function browserPageActions(obj) {
  if (obj.BrowserPageActions) {
    return obj.BrowserPageActions;
  }
  return obj.ownerGlobal.BrowserPageActions;
}

/**
 * A generator function for all open browser windows.
 *
 * @param browserWindow (DOM window, optional)
 *        If given, then only this window will be yielded.  That may sound
 *        pointless, but it can make callers nicer to write since they don't
 *        need two separate cases, one where a window is given and another where
 *        it isn't.
 */
function* allBrowserWindows(browserWindow = null) {
  if (browserWindow) {
    yield browserWindow;
    return;
  }
  yield* Services.wm.getEnumerator("navigator:browser");
}

/**
 * A generator function for BrowserPageActions objects in all open windows.
 *
 * @param browserWindow (DOM window, optional)
 *        If given, then the BrowserPageActions for only this window will be
 *        yielded.
 */
function* allBrowserPageActions(browserWindow = null) {
  for (let win of allBrowserWindows(browserWindow)) {
    yield browserPageActions(win);
  }
}

/**
 * A simple function that sets properties on a given object while doing basic
 * required-properties checking.  If a required property isn't specified in the
 * given options object, or if the options object has properties that aren't in
 * the given schema, then an error is thrown.
 *
 * @param  obj
 *         The object to set properties on.
 * @param  options
 *         An options object supplied by the consumer.
 * @param  schema
 *         An object a property for each required and optional property.  The
 *         keys are property names; the value of a key is a bool that is true if
 *         the property is required.
 */
function setProperties(obj, options, schema) {
  for (let name in schema) {
    let required = schema[name];
    if (required && !(name in options)) {
      throw new Error(`'${name}' must be specified`);
    }
    let nameInObj = "_" + name;
    if (name[0] == "_") {
      // The property is "private".  If it's defined in the options, then define
      // it on obj exactly as it's defined on options.
      if (name in options) {
        obj[nameInObj] = options[name];
      }
    } else {
      // The property is "public".  Make sure the property is defined on obj.
      obj[nameInObj] = options[name] || null;
    }
  }
  for (let name in options) {
    if (!(name in schema)) {
      throw new Error(`Unrecognized option '${name}'`);
    }
  }
}