extension-create 4.1.18 → 4.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,7 @@ Creates a new extension project with the specified configuration.
47
47
 
48
48
  - `projectName` (string, required) - The name of your extension project
49
49
  - `options` (object) - Configuration options
50
- - `template` (string, optional) - Template name or URL. Defaults to `'javascript'` (`init` is an alias)
50
+ - `template` (string, optional) - Template name or URL. Defaults to `'javascript'` (`init` is an alias). A URL must use `https://`. A plain `http://` URL, and an `http://` value in `EXTENSION_CREATE_TEMPLATE_URL`, fails unless `EXTENSION_ALLOW_HTTP_TEMPLATE=true` is set
51
51
  - `install` (boolean, optional) - Whether to install dependencies after scaffolding. Defaults to `false` so project creation is fast and users see the familiar `npm install` output on their own.
52
52
  - `cliVersion` (string, optional) - CLI version for package.json
53
53
 
@@ -11,6 +11,7 @@ export declare function writingTypeDefinitionsError(error: unknown): string;
11
11
  export declare function installingFromTemplate(projectName: string, templateName: string): string;
12
12
  export declare function usingTemplate(templateName: string, source: string): string;
13
13
  export declare function installingFromTemplateError(template: string, error: unknown): string;
14
+ export declare function templateUrlNotHttps(url: string): string;
14
15
  export declare function templateFetchTimedOut(templateName: string, ms: number): string;
15
16
  export declare function templateNotFoundInCatalog(templateName: string, error?: unknown): string;
16
17
  export declare function templateDownloadFailed(templateName: string, error: unknown): string;
@@ -0,0 +1 @@
1
+ export declare function isUrlProjectName(projectNameInput: string): boolean;
package/dist/module.cjs CHANGED
@@ -252,6 +252,9 @@ function usingTemplate(templateName, source) {
252
252
  function installingFromTemplateError(template, error) {
253
253
  return `${prefix('error')} Couldn't find the template ${external_pintor_default().blue(template)}.\n${fmt.label('REASON')} ${fmt.val(fmt.truncate(String(error)))}\n${external_pintor_default().red('Choose a template name from')} ${external_pintor_default().blue('extension create --help')}${external_pintor_default().red(', or pass a GitHub URL.')}`;
254
254
  }
255
+ function templateUrlNotHttps(url) {
256
+ return `${prefix('error')} Can't download a template over plain HTTP.\n${fmt.label('GOT')} ${fmt.val(fmt.truncate(url, 120))}\n${external_pintor_default().red('- Use the')} ${external_pintor_default().blue('https://')} ${external_pintor_default().red('address of the template.')}\n${external_pintor_default().red('- Set')} ${external_pintor_default().blue('EXTENSION_ALLOW_HTTP_TEMPLATE=true')} ${external_pintor_default().red('to allow plain HTTP.')}`;
257
+ }
255
258
  function templateFetchTimedOut(templateName, ms) {
256
259
  return `${prefix('error')} Couldn't fetch the template ${external_pintor_default().blue(templateName)} within ${Math.round(ms / 1000)}s.\n${external_pintor_default().red('- Check your network connection.')}\n${external_pintor_default().red('- Set')} ${external_pintor_default().blue('EXTENSION_CREATE_TIMEOUT_MS')} ${external_pintor_default().red('to allow more time.')}`;
257
260
  }
@@ -355,6 +358,9 @@ function keepingExistingGitignore(projectName) {
355
358
  function existingRepositoryKept(projectName) {
356
359
  return `${prefix('info')} ${external_pintor_default().blue(projectName)} is already a git repository with history.\nLeft it uncommitted. Review the scaffold and run ${external_pintor_default().blue('git add -A && git commit')} yourself.`;
357
360
  }
361
+ function isUrlProjectName(projectNameInput) {
362
+ return /^https?:\/\//i.test(projectNameInput.trim());
363
+ }
358
364
  const promises_namespaceObject = require("node:fs/promises");
359
365
  async function copyDirectoryWithSymlinks(source, destination) {
360
366
  const entries = await promises_namespaceObject.readdir(source, {
@@ -606,7 +612,7 @@ const NETWORK_TIMEOUT_MS = (()=>{
606
612
  return Number.isFinite(raw) && raw > 0 ? raw : 60000;
607
613
  })();
608
614
  const CODELOAD_BASE = 'https://codeload.github.com/extension-js/examples/zip';
609
- const DEFAULT_TEMPLATES_REF = 'e552f6db6f2862093ba9d7026e0d376f002d95cc';
615
+ const DEFAULT_TEMPLATES_REF = '39448a235ea1b02516e6d555bcae409db2258b7f';
610
616
  const BUNDLED_TEMPLATES = [
611
617
  "javascript"
612
618
  ];
@@ -655,6 +661,29 @@ class TemplateNotFoundError extends Error {
655
661
  if (cause) this.cause = cause;
656
662
  }
657
663
  }
664
+ class InsecureTemplateUrlError extends Error {
665
+ constructor(url){
666
+ super(`template URL is not https: ${url}`), _define_property(this, "url", void 0);
667
+ this.name = 'InsecureTemplateUrlError';
668
+ this.url = url;
669
+ }
670
+ }
671
+ function isRefusedHttpTemplateUrl(url) {
672
+ if ('true' === process.env.EXTENSION_ALLOW_HTTP_TEMPLATE) return false;
673
+ return /^http:\/\//i.test(url);
674
+ }
675
+ function refuseHttpRedirect(options) {
676
+ const target = String(options.href || `${options.protocol || ''}//`);
677
+ if (isRefusedHttpTemplateUrl(target)) throw new InsecureTemplateUrlError(target);
678
+ }
679
+ function findInsecureTemplateUrlError(error) {
680
+ let current = error;
681
+ for(let depth = 0; current && depth < 4; depth++){
682
+ if (current instanceof InsecureTemplateUrlError) return current;
683
+ current = current.cause;
684
+ }
685
+ return null;
686
+ }
658
687
  class TemplateDownloadError extends Error {
659
688
  constructor(templateName, cause){
660
689
  const msg = cause?.message ?? String(cause);
@@ -674,11 +703,13 @@ async function downloadArchive(url, timeoutMs, attempts = 2) {
674
703
  timeout: timeoutMs,
675
704
  headers: {
676
705
  'User-Agent': 'extension-create'
677
- }
706
+ },
707
+ beforeRedirect: refuseHttpRedirect
678
708
  });
679
709
  return Buffer.from(data);
680
710
  } catch (error) {
681
711
  lastError = error;
712
+ if (findInsecureTemplateUrlError(error)) break;
682
713
  const status = error?.response?.status;
683
714
  const retriable = void 0 === status || 429 === status || status >= 500;
684
715
  if (attempt >= attempts || !retriable) break;
@@ -712,6 +743,7 @@ async function extractExamplesTemplateFromZip(zipBuffer, templateName, projectPa
712
743
  async function importFromExamplesCatalog(templateName, projectPath) {
713
744
  const ref = process.env.EXTENSION_CREATE_TEMPLATE_REF || DEFAULT_TEMPLATES_REF;
714
745
  const overrideUrl = process.env.EXTENSION_CREATE_TEMPLATE_URL || void 0;
746
+ if (overrideUrl && isRefusedHttpTemplateUrl(overrideUrl)) throw new InsecureTemplateUrlError(overrideUrl);
715
747
  const urls = resolveCatalogUrls(ref, overrideUrl);
716
748
  let buffer;
717
749
  let source;
@@ -721,6 +753,8 @@ async function importFromExamplesCatalog(templateName, projectPath) {
721
753
  source = candidate;
722
754
  break;
723
755
  } catch (error) {
756
+ const insecure = findInsecureTemplateUrlError(error);
757
+ if (insecure) throw insecure;
724
758
  lastError = error;
725
759
  }
726
760
  if (!buffer || !source) throw new TemplateDownloadError(templateName, lastError);
@@ -884,6 +918,7 @@ async function importExternalTemplate(projectPath, projectName, template, logger
884
918
  } catch {}
885
919
  const ownerGitignore = dirExistedBeforeImport ? await readOwnerGitignore(projectPath) : null;
886
920
  try {
921
+ if (isRefusedHttpTemplateUrl(template)) throw new InsecureTemplateUrlError(template);
887
922
  await promises_namespaceObject.mkdir(projectPath, {
888
923
  recursive: true
889
924
  });
@@ -931,7 +966,8 @@ async function importExternalTemplate(projectPath, projectName, template, logger
931
966
  const { data, headers } = await external_axios_default().get(template, {
932
967
  responseType: 'arraybuffer',
933
968
  maxRedirects: 5,
934
- timeout: NETWORK_TIMEOUT_MS
969
+ timeout: NETWORK_TIMEOUT_MS,
970
+ beforeRedirect: refuseHttpRedirect
935
971
  });
936
972
  const contentType = String(headers?.['content-type'] || '');
937
973
  const looksZip = /zip|octet-stream/i.test(contentType) || template.toLowerCase().endsWith('.zip');
@@ -974,7 +1010,9 @@ async function importExternalTemplate(projectPath, projectName, template, logger
974
1010
  return fallback;
975
1011
  }
976
1012
  }
977
- if (error instanceof TemplateNotFoundError) logger.error(templateNotFoundInCatalog(templateName, error.cause));
1013
+ const insecureUrl = findInsecureTemplateUrlError(error);
1014
+ if (insecureUrl) logger.error(templateUrlNotHttps(insecureUrl.url));
1015
+ else if (error instanceof TemplateNotFoundError) logger.error(templateNotFoundInCatalog(templateName, error.cause));
978
1016
  else if (error instanceof TemplateDownloadError) logger.error(templateDownloadFailed(templateName, error));
979
1017
  else logger.error(installingFromTemplateError(templateName, error));
980
1018
  await cleanupFailedImport(projectPath, ownsProjectDir, preExistingEntries);
@@ -2100,7 +2138,7 @@ async function extensionCreate(projectNameInput, { cliVersion, template, install
2100
2138
  if (!projectNameInput) throw new Error(noProjectName());
2101
2139
  const templateWasOmitted = null == template || '' === String(template).trim();
2102
2140
  const effectiveTemplate = templateWasOmitted ? DEFAULT_TEMPLATE_NAME : String(template);
2103
- if (projectNameInput.startsWith('http')) throw new Error(noUrlAllowed());
2141
+ if (isUrlProjectName(projectNameInput)) throw new Error(noUrlAllowed());
2104
2142
  const projectPath = external_node_path_namespaceObject.isAbsolute(projectNameInput) ? projectNameInput : external_node_path_namespaceObject.join(process.cwd(), projectNameInput);
2105
2143
  const projectName = external_node_path_namespaceObject.basename(projectPath);
2106
2144
  const updateSuffix = process.env.EXTENSION_CLI_UPDATE_SUFFIX || '';
@@ -1,4 +1,4 @@
1
- export declare const DEFAULT_TEMPLATES_REF = "e552f6db6f2862093ba9d7026e0d376f002d95cc";
1
+ export declare const DEFAULT_TEMPLATES_REF = "39448a235ea1b02516e6d555bcae409db2258b7f";
2
2
  export declare const BUNDLED_TEMPLATES: readonly string[];
3
3
  export declare const DEFAULT_TEMPLATE_NAME = "typescript";
4
4
  export declare const OFFLINE_FALLBACK_TEMPLATE = "javascript";
@@ -14,6 +14,15 @@ export declare class TemplateNotFoundError extends Error {
14
14
  readonly templateName: string;
15
15
  constructor(templateName: string, cause?: unknown);
16
16
  }
17
+ export declare class InsecureTemplateUrlError extends Error {
18
+ readonly url: string;
19
+ constructor(url: string);
20
+ }
21
+ export declare function isRefusedHttpTemplateUrl(url: string): boolean;
22
+ export declare function refuseHttpRedirect(options: {
23
+ protocol?: string;
24
+ href?: string;
25
+ }): void;
17
26
  export declare class TemplateDownloadError extends Error {
18
27
  readonly templateName: string;
19
28
  constructor(templateName: string, cause: unknown);
@@ -2,10 +2,8 @@ import { type ScaffoldPackageManager } from '../lib/package-manager.js';
2
2
  export declare function resolveExtensionBinary(): Promise<string>;
3
3
  export declare function getTemplateAwareScripts(template: string, extensionBinary: string): Record<string, string>;
4
4
  interface OverridePackageJsonOptions {
5
- /** Defaults to `javascript` when omitted (same as `extensionCreate`). */
6
5
  template?: string;
7
6
  cliVersion?: string;
8
- /** The one manager the project uses; resolved from the scaffold when omitted. */
9
7
  packageManager?: ScaffoldPackageManager;
10
8
  }
11
9
  export declare function resolveExtensionDevDependencyVersion(cliVersion?: string): string;
@@ -1,6 +1,6 @@
1
1
  {
2
- "createdWith": "extension-create@4.1.17",
2
+ "createdWith": "extension-create@4.1.19",
3
3
  "template": "typescript",
4
- "source": "https://codeload.github.com/extension-js/examples/zip/e552f6db6f2862093ba9d7026e0d376f002d95cc",
5
- "ref": "e552f6db6f2862093ba9d7026e0d376f002d95cc"
4
+ "source": "https://codeload.github.com/extension-js/examples/zip/39448a235ea1b02516e6d555bcae409db2258b7f",
5
+ "ref": "39448a235ea1b02516e6d555bcae409db2258b7f"
6
6
  }
@@ -9,7 +9,7 @@ Packaging your extension is local and free. Submitting the result to a
9
9
  store is what [extension.dev](https://docs.extension.dev/publish/overview?utm_source=store-md)
10
10
  does, and it sponsors Extension.js.
11
11
 
12
- Last updated: 2026-09-14
12
+ Last updated: 2026-09-16
13
13
 
14
14
  ## Listing
15
15
 
@@ -8,7 +8,7 @@
8
8
  "devDependencies": {
9
9
  "@types/chrome": "^0.0.287",
10
10
  "typescript": "7.0.2",
11
- "extension": "^4.1.17"
11
+ "extension": "^4.1.19"
12
12
  },
13
13
  "scripts": {
14
14
  "dev": "extension dev",
@@ -6,22 +6,61 @@ const isFirefoxLike =
6
6
  import.meta.env.EXTENSION_PUBLIC_BROWSER === 'firefox' ||
7
7
  import.meta.env.EXTENSION_PUBLIC_BROWSER === 'gecko-based'
8
8
 
9
+ const isSafariLike =
10
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'safari' ||
11
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'webkit-based'
12
+
13
+ // Safari has no side panel surface, so the sidebar page opens in a tab.
14
+ let sidebarTabId: number | undefined
15
+
16
+ function openSidebarTab() {
17
+ const url = chrome.runtime.getURL('sidebar/index.html')
18
+
19
+ const openNewTab = () => {
20
+ chrome.tabs.create({url}, (tab) => {
21
+ sidebarTabId = tab?.id
22
+ })
23
+ }
24
+
25
+ // A repeat click focuses the tab already opened instead of a new copy.
26
+ const knownTabId = sidebarTabId
27
+
28
+ if (knownTabId === undefined) {
29
+ openNewTab()
30
+
31
+ return
32
+ }
33
+
34
+ chrome.tabs.update(knownTabId, {active: true}, () => {
35
+ if (chrome.runtime.lastError) openNewTab()
36
+ })
37
+ }
38
+
9
39
  if (isFirefoxLike) {
40
+ // Firefox refuses sidebarAction.open() outside a user input handler, and a
41
+ // message listener is not one, so the toolbar click is the only route.
10
42
  browser.browserAction.onClicked.addListener(() => {
11
43
  browser.sidebarAction.open()
12
44
  })
45
+ }
13
46
 
14
- browser.runtime.onMessage.addListener((message: any) => {
47
+ if (isSafariLike) {
48
+ // Safari never had setPanelBehavior, so the toolbar click needs a listener.
49
+ chrome.action?.onClicked.addListener(() => {
50
+ openSidebarTab()
51
+ })
52
+
53
+ chrome.runtime.onMessage.addListener((message) => {
15
54
  if (!message || message.type !== 'openSidebar') return
16
55
 
17
- browser.sidebarAction.open()
56
+ openSidebarTab()
18
57
  })
19
58
  }
20
59
 
21
- if (!isFirefoxLike) {
60
+ if (!isFirefoxLike && !isSafariLike) {
22
61
  // setPanelBehavior only affects FUTURE action clicks, registering it
23
62
  // inside onClicked would swallow the first toolbar click.
24
- chrome.sidePanel.setPanelBehavior({openPanelOnActionClick: true})
63
+ chrome.sidePanel?.setPanelBehavior({openPanelOnActionClick: true})
25
64
 
26
65
  // The side panel API only exists in Chromium. Firefox opens the sidebar in
27
66
  // the listener above, so this listener is compiled out of gecko builds.
@@ -32,13 +71,13 @@ if (!isFirefoxLike) {
32
71
  // allowed inside the user gesture that the content-script click carries, and
33
72
  // a tabs.query callback outlives it: the panel then silently refuses to open.
34
73
  // sender.tab is the tab the click came from, so no lookup is needed at all.
35
- chrome.sidePanel.setPanelBehavior({openPanelOnActionClick: true})
74
+ chrome.sidePanel?.setPanelBehavior({openPanelOnActionClick: true})
36
75
 
37
76
  const tabId = sender.tab?.id
38
- if (!chrome.sidePanel.open || tabId === undefined) return
77
+ if (!chrome.sidePanel?.open || tabId === undefined) return
39
78
 
40
79
  try {
41
- chrome.sidePanel.open({tabId})
80
+ chrome.sidePanel?.open({tabId})
42
81
  } catch (error) {
43
82
  console.error(error)
44
83
  }
@@ -1,20 +1,27 @@
1
1
  import logo from '../images/icon.png'
2
2
 
3
+ const isFirefoxLike =
4
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'firefox' ||
5
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'gecko-based'
6
+
3
7
  export default function createContentApp(): HTMLDivElement {
4
8
  const container = document.createElement('div')
5
9
  container.className = 'content_script'
6
10
 
7
- const pill = document.createElement('button')
8
- pill.type = 'button'
9
- pill.className = 'content_pill'
10
- pill.setAttribute('aria-label', 'Open sidebar')
11
- pill.addEventListener('click', () => {
12
- if (import.meta.env.EXTENSION_PUBLIC_BROWSER === 'firefox') {
13
- browser.runtime.sendMessage({type: 'openSidebar'})
14
- } else {
11
+ // Firefox cannot open a sidebar from a message listener, so the gecko build
12
+ // renders a hint naming the toolbar action instead of a dead control.
13
+ const pill = document.createElement(isFirefoxLike ? 'div' : 'button')
14
+ pill.className = isFirefoxLike
15
+ ? 'content_pill content_pill_static'
16
+ : 'content_pill'
17
+
18
+ if (!isFirefoxLike) {
19
+ ;(pill as HTMLButtonElement).type = 'button'
20
+ pill.setAttribute('aria-label', 'Open sidebar')
21
+ pill.addEventListener('click', () => {
15
22
  chrome.runtime.sendMessage({type: 'openSidebar'})
16
- }
17
- })
23
+ })
24
+ }
18
25
 
19
26
  const img = document.createElement('img')
20
27
  img.className = 'content_pill_logo'
@@ -26,7 +33,9 @@ export default function createContentApp(): HTMLDivElement {
26
33
 
27
34
  const text = document.createElement('span')
28
35
  text.className = 'content_pill_text'
29
- text.textContent = 'Open sidebar'
36
+ text.textContent = isFirefoxLike
37
+ ? 'Use the toolbar icon to open the sidebar'
38
+ : 'Open sidebar'
30
39
 
31
40
  pill.appendChild(img)
32
41
  pill.appendChild(text)
@@ -40,5 +40,6 @@ async function fetchCSS() {
40
40
  const cssUrl = new URL('./styles.css', import.meta.url)
41
41
  const response = await fetch(cssUrl)
42
42
  const text = await response.text()
43
+
43
44
  return response.ok ? text : Promise.reject(text)
44
45
  }
@@ -22,7 +22,7 @@
22
22
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
23
23
  }
24
24
 
25
- .content_pill:hover {
25
+ .content_pill:not(.content_pill_static):hover {
26
26
  background: #11151c;
27
27
  }
28
28
 
@@ -40,6 +40,12 @@
40
40
  font-weight: 600;
41
41
  line-height: 1;
42
42
  font-family:
43
- -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
44
- Arial, "Noto Sans", sans-serif;
43
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
44
+ Arial, 'Noto Sans', sans-serif;
45
+ }
46
+
47
+ /* The gecko build renders this hint in place of the pill, so it must not read
48
+ as a control: no pointer cursor and no hover response. */
49
+ .content_pill_static {
50
+ cursor: default;
45
51
  }
@@ -11,7 +11,9 @@
11
11
  body {
12
12
  background-color: var(--sidebar-bg);
13
13
  color: var(--sidebar-text);
14
- height: 100vh;
14
+ /* The margin insets the panel, so a full-viewport height would push the
15
+ content below centre and overflow the panel by twice the margin. */
16
+ height: calc(100vh - 2 * var(--sidebar-margin));
15
17
  margin: var(--sidebar-margin);
16
18
  border-radius: 6px;
17
19
  display: flex;
@@ -26,7 +28,7 @@ body {
26
28
  align-items: center;
27
29
  padding: 0 1rem;
28
30
  text-align: center;
29
- max-height: 100vh;
31
+ max-height: 100%;
30
32
  overflow-y: auto;
31
33
  }
32
34
 
@@ -39,8 +41,8 @@ body {
39
41
  font-size: 1.85em;
40
42
  line-height: 1.1;
41
43
  font-family:
42
- -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
43
- Arial, "Noto Sans", sans-serif;
44
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
45
+ Arial, 'Noto Sans', sans-serif;
44
46
  font-weight: 700;
45
47
  margin: 0;
46
48
  text-align: center;
package/package.json CHANGED
@@ -25,7 +25,7 @@
25
25
  "templates"
26
26
  ],
27
27
  "name": "extension-create",
28
- "version": "4.1.18",
28
+ "version": "4.1.20",
29
29
  "description": "The standalone extension creation engine for Extension.js",
30
30
  "author": {
31
31
  "name": "Cezar Augusto",
@@ -45,7 +45,7 @@
45
45
  "watch": "rslib build --watch",
46
46
  "compile": "rslib build",
47
47
  "format": "biome format --write .",
48
- "lint": "biome lint .",
48
+ "lint": "biome lint . && eslint .",
49
49
  "pretest:create": "pnpm compile",
50
50
  "pretest": "pnpm compile",
51
51
  "test": "vitest run",
@@ -6,22 +6,61 @@ const isFirefoxLike =
6
6
  import.meta.env.EXTENSION_PUBLIC_BROWSER === 'firefox' ||
7
7
  import.meta.env.EXTENSION_PUBLIC_BROWSER === 'gecko-based'
8
8
 
9
+ const isSafariLike =
10
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'safari' ||
11
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'webkit-based'
12
+
13
+ // Safari has no side panel surface, so the sidebar page opens in a tab.
14
+ let sidebarTabId
15
+
16
+ function openSidebarTab() {
17
+ const url = chrome.runtime.getURL('sidebar/index.html')
18
+
19
+ const openNewTab = () => {
20
+ chrome.tabs.create({url}, (tab) => {
21
+ sidebarTabId = tab?.id
22
+ })
23
+ }
24
+
25
+ // A repeat click focuses the tab already opened instead of a new copy.
26
+ const knownTabId = sidebarTabId
27
+
28
+ if (knownTabId === undefined) {
29
+ openNewTab()
30
+
31
+ return
32
+ }
33
+
34
+ chrome.tabs.update(knownTabId, {active: true}, () => {
35
+ if (chrome.runtime.lastError) openNewTab()
36
+ })
37
+ }
38
+
9
39
  if (isFirefoxLike) {
40
+ // Firefox refuses sidebarAction.open() outside a user input handler, and a
41
+ // message listener is not one, so the toolbar click is the only route.
10
42
  browser.browserAction.onClicked.addListener(() => {
11
43
  browser.sidebarAction.open()
12
44
  })
45
+ }
13
46
 
14
- browser.runtime.onMessage.addListener((message) => {
47
+ if (isSafariLike) {
48
+ // Safari never had setPanelBehavior, so the toolbar click needs a listener.
49
+ chrome.action?.onClicked.addListener(() => {
50
+ openSidebarTab()
51
+ })
52
+
53
+ chrome.runtime.onMessage.addListener((message) => {
15
54
  if (!message || message.type !== 'openSidebar') return
16
55
 
17
- browser.sidebarAction.open()
56
+ openSidebarTab()
18
57
  })
19
58
  }
20
59
 
21
- if (!isFirefoxLike) {
60
+ if (!isFirefoxLike && !isSafariLike) {
22
61
  // setPanelBehavior only affects FUTURE action clicks, registering it
23
62
  // inside onClicked would swallow the first toolbar click.
24
- chrome.sidePanel.setPanelBehavior({openPanelOnActionClick: true})
63
+ chrome.sidePanel?.setPanelBehavior({openPanelOnActionClick: true})
25
64
 
26
65
  // The side panel API only exists in Chromium. Firefox opens the sidebar in
27
66
  // the listener above, so this listener is compiled out of gecko builds.
@@ -32,13 +71,13 @@ if (!isFirefoxLike) {
32
71
  // allowed inside the user gesture that the content-script click carries, and
33
72
  // a tabs.query callback outlives it: the panel then silently refuses to open.
34
73
  // sender.tab is the tab the click came from, so no lookup is needed at all.
35
- chrome.sidePanel.setPanelBehavior({openPanelOnActionClick: true})
74
+ chrome.sidePanel?.setPanelBehavior({openPanelOnActionClick: true})
36
75
 
37
76
  const tabId = sender.tab?.id
38
- if (!chrome.sidePanel.open || tabId === undefined) return
77
+ if (!chrome.sidePanel?.open || tabId === undefined) return
39
78
 
40
79
  try {
41
- chrome.sidePanel.open({tabId})
80
+ chrome.sidePanel?.open({tabId})
42
81
  } catch (error) {
43
82
  console.error(error)
44
83
  }
@@ -1,27 +1,31 @@
1
1
  import logo from '../images/icon.png'
2
2
 
3
+ const isFirefoxLike =
4
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'firefox' ||
5
+ import.meta.env.EXTENSION_PUBLIC_BROWSER === 'gecko-based'
6
+
3
7
  export default function createContentApp() {
4
8
  const container = document.createElement('div')
5
9
  container.className = 'content_script'
6
10
 
7
- const pill = document.createElement('button')
8
- pill.type = 'button'
9
- pill.className = 'content_pill'
10
- pill.setAttribute('aria-label', 'Open sidebar')
11
- pill.addEventListener('click', () => {
12
- try {
13
- if (
14
- import.meta.env.EXTENSION_PUBLIC_BROWSER === 'firefox' ||
15
- import.meta.env.EXTENSION_PUBLIC_BROWSER === 'gecko-based'
16
- ) {
17
- browser.runtime.sendMessage({type: 'openSidebar'})
18
- } else {
11
+ // Firefox cannot open a sidebar from a message listener, so the gecko build
12
+ // renders a hint naming the toolbar action instead of a dead control.
13
+ const pill = document.createElement(isFirefoxLike ? 'div' : 'button')
14
+ pill.className = isFirefoxLike
15
+ ? 'content_pill content_pill_static'
16
+ : 'content_pill'
17
+
18
+ if (!isFirefoxLike) {
19
+ pill.type = 'button'
20
+ pill.setAttribute('aria-label', 'Open sidebar')
21
+ pill.addEventListener('click', () => {
22
+ try {
19
23
  chrome.runtime.sendMessage({type: 'openSidebar'})
24
+ } catch (error) {
25
+ console.error(error)
20
26
  }
21
- } catch (error) {
22
- console.error(error)
23
- }
24
- })
27
+ })
28
+ }
25
29
 
26
30
  const img = document.createElement('img')
27
31
  img.className = 'content_pill_logo'
@@ -31,7 +35,9 @@ export default function createContentApp() {
31
35
 
32
36
  const text = document.createElement('span')
33
37
  text.className = 'content_pill_text'
34
- text.textContent = 'Open sidebar'
38
+ text.textContent = isFirefoxLike
39
+ ? 'Use the toolbar icon to open the sidebar'
40
+ : 'Open sidebar'
35
41
 
36
42
  pill.appendChild(img)
37
43
  pill.appendChild(text)
@@ -40,5 +40,6 @@ async function fetchCSS() {
40
40
  const cssUrl = new URL('./styles.css', import.meta.url)
41
41
  const response = await fetch(cssUrl)
42
42
  const text = await response.text()
43
+
43
44
  return response.ok ? text : Promise.reject(text)
44
45
  }
@@ -22,7 +22,7 @@
22
22
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
23
23
  }
24
24
 
25
- .content_pill:hover {
25
+ .content_pill:not(.content_pill_static):hover {
26
26
  background: #11151c;
27
27
  }
28
28
 
@@ -40,6 +40,12 @@
40
40
  font-weight: 600;
41
41
  line-height: 1;
42
42
  font-family:
43
- -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
44
- Arial, "Noto Sans", sans-serif;
43
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
44
+ Arial, 'Noto Sans', sans-serif;
45
+ }
46
+
47
+ /* The gecko build renders this hint in place of the pill, so it must not read
48
+ as a control: no pointer cursor and no hover response. */
49
+ .content_pill_static {
50
+ cursor: default;
45
51
  }
@@ -11,7 +11,9 @@
11
11
  body {
12
12
  background-color: var(--sidebar-bg);
13
13
  color: var(--sidebar-text);
14
- height: 100vh;
14
+ /* The margin insets the panel, so a full-viewport height would push the
15
+ content below centre and overflow the panel by twice the margin. */
16
+ height: calc(100vh - 2 * var(--sidebar-margin));
15
17
  margin: var(--sidebar-margin);
16
18
  border-radius: 6px;
17
19
  display: flex;
@@ -26,7 +28,7 @@ body {
26
28
  align-items: center;
27
29
  padding: 0 1rem;
28
30
  text-align: center;
29
- max-height: 100vh;
31
+ max-height: 100%;
30
32
  overflow-y: auto;
31
33
  }
32
34
 
@@ -39,8 +41,8 @@ body {
39
41
  font-size: 1.85em;
40
42
  line-height: 1.1;
41
43
  font-family:
42
- -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
43
- Arial, "Noto Sans", sans-serif;
44
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
45
+ Arial, 'Noto Sans', sans-serif;
44
46
  font-weight: 700;
45
47
  margin: 0;
46
48
  text-align: center;