Repository: violentmonkey/violentmonkey (js)

Issue:
[BUG] UserScripts localization for zh-* broken on Firefox Android
<!-- Please search the existing issues first as the problem may be already reported.
     Please verify the bug is not fixed in the latest beta:
     https://github.com/violentmonkey/violentmonkey/releases -->

### Sequence of actions: <!-- e.g. install userscript, open example.com, click page -->
1. 
2.
3.

### Problem: <!-- e.g. error in devtools console -->

Firefox for Android, weirdly, uses "zh-Hant-TW" instead of "zh-TW" as the language code for Traditional Chinese (Taiwan), which breaks the function (see below) used to get localized metadata since most (if not all) of the scripts use the code "zh-TW". Same should happen on Simplified Chinese (zh-CN, zh-Hans-CN).

https://github.com/violentmonkey/violentmonkey/blob/5eb2c5ad8de7ad5df6ac081e4167e715f02c6056/src/common/index.js#L159

### Expected result:  <!-- e.g. "ok" in console or some button appears -->

Language code should be minimized first using function like `new Intl.Locale("zh-Hant-TW").minimize().baseName // -> "zh-TW"`.

#### Devtools console contents:

N/A

#### Environment:
- OS: Android 14
- Browser: Firefox Nightly for Android 124.0a1 <!-- specify the real version -->
- Violentmonkey Version: 2.18.0


Pull request:
fix: support extended language tags
fix #2031

Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 638db7a3..5099619d 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -26,7 +26,7 @@ module.exports = {
     'no-use-before-define': ['error', {
       'functions': false,
       'classes': true,
-      'variables': true,
+      'variables': false, // allowing for upper scopes
       'allowNamedExports': true,
     }],
     // copied from airbnb-base, replaced 4 with 8
diff --git a/src/background/utils/tab-redirector.js b/src/background/utils/tab-redirector.js
index 7ba82568..a159bf0b 100644
--- a/src/background/utils/tab-redirector.js
+++ b/src/background/utils/tab-redirector.js
@@ -7,48 +7,52 @@ import { parseMeta, matchUserScript } from './script';
 import { fileSchemeRequestable, getTabUrl, NEWTAB_URL_RE, tabsOnUpdated } from './tabs';
 import { FIREFOX } from './ua';
 
-const CONFIRM_URL_BASE = `${extensionRoot}confirm/index.html#`;
-
 addPublicCommands({
   async CheckInstallerTab(tabId, src) {
     const tab = IS_FIREFOX && (src.url || '').startsWith('file:')
       && await browser.tabs.get(tabId).catch(noop);
     return tab && getTabUrl(tab).startsWith(CONFIRM_URL_BASE);
   },
-  async ConfirmInstall({ code, from, url, fs, parsed }, { tab = {} }) {
-    if (!fs) {
-      if (!code) code = (await request(url)).data;
-      // TODO: display the error in UI
-      if (!parsed && !matchUserScript(code)) {
-        throw `${i18n('msgInvalidScript')}\n\n${
-          code.trim().split(/[\r\n]+\s*/, 9/*max lines*/).join('\n')
-            .slice(0, 500/*max overall length*/)
-        }...`;
-      }
-      cache.put(url, code, 3000);
-    }
-    const confirmKey = getUniqId();
-    const { active, id: tabId, incognito } = tab;
-    // Not testing tab.pendingUrl because it will be always equal to `url`
-    const canReplaceCurTab = (!incognito || IS_FIREFOX) && (
-      url === from
-      || cache.has(`autoclose:${tabId}`)
-      || NEWTAB_URL_RE.test(from));
-    /** @namespace VM.ConfirmCache */
-    cache.put(`confirm-${confirmKey}`, { incognito, url, from, tabId, fs, ff: FIREFOX });
-    const confirmUrl = CONFIRM_URL_BASE + confirmKey;
-    const { [kWindowId]: windowId } = canReplaceCurTab
-      // The tab may have been closed already, in which case we'll open a new tab
-      && await browser.tabs.update(tabId, { url: confirmUrl }).catch(noop)
-      || await commands.TabOpen({ url: confirmUrl, active: !!active }, { tab });
-    if (active && windowId !== tab[kWindowId]) {
-      await browserWindows?.update(windowId, { focused: true });
-    }
-  },
+  ConfirmInstall: confirmInstall,
 });
 
+async function confirmInstall({ code, from, url, fs, parsed }, { tab = {} }) {
+  if (!fs) {
+    code ??= parsed
+      ? request(url).then(r => r.data) // cache the Promise and start fetching now
+      : (await request(url)).data;
+    // TODO: display the error in UI
+    if (!parsed && !matchUserScript(code)) {
+      throw `${i18n('msgInvalidScript')}\n\n${
+        code.trim().split(/[\r\n]+\s*/, 9/*max lines*/).join('\n')
+          .slice(0, 500/*max overall length*/)
+      }...`;
+    }
+    cache.put(url, code, 3000);
+  }
+  const confirmKey = getUniqId();
+  const { active, id: tabId, incognito } = tab;
+  // Not testing tab.pendingUrl because it will be always equal to `url`
+  const canReplaceCurTab = (!incognito || IS_FIREFOX) && (
+    url === from
+    || cache.has(`autoclose:${tabId}`)
+    || NEWTAB_URL_RE.test(from));
+  /** @namespace VM.ConfirmCache */
+  cache.put(`confirm-${confirmKey}`, { incognito, url, from, tabId, fs, ff: FIREFOX });
+  const confirmUrl = CONFIRM_URL_BASE + confirmKey;
+  const { [kWindowId]: windowId } = canReplaceCurTab
+  // The tab may have been closed already, in which case we'll open a new tab
+  && await browser.tabs.update(tabId, { url: confirmUrl }).catch(noop)
+  || await commands.TabOpen({ url: confirmUrl, active: !!active }, { tab });
+  if (active && windowId !== tab[kWindowId]) {
+    await browserWindows?.update(windowId, { focused: true });
+  }
+}
+
+const CONFIRM_URL_BASE = `${extensionRoot}confirm/index.html#`;
 const whitelistRe = re`/^https:\/\/(
   (greas|sleaz)yfork\.org\/scripts\/[^/]*\/code|
+  update\.(greas|sleaz)yfork\.org\/scripts|
   openuserjs\.org\/install\/[^/]*|
   github\.com\/[^/]*\/[^/]*\/(
     raw\/[^/]*|
@@ -74,12 +78,12 @@ const maybeRedirectVirtualUrlFF = virtualUrlRe && ((tabId, src) => {
   }
 });
 
-async function maybeInstallUserJs(tabId, url) {
+async function maybeInstallUserJs(tabId, url, isWhitelisted) {
   // Getting the tab now before it navigated
   const tab = tabId >= 0 && await browser.tabs.get(tabId) || {};
-  const { data: code } = await request(url).catch(noop) || {};
-  if (code && parseMeta(code).name) {
-    commands.ConfirmInstall({ code, url, from: tab.url, parsed: true }, { tab });
+  const { data: code } = !isWhitelisted && await request(url).catch(noop) || {};
+  if (isWhitelisted || code && parseMeta(code).name) {
+    confirmInstall({ code, url, from: tab.url, parsed: true }, { tab });
   } else {
     cache.put(`bypass:${url}`, true, 10e3);
     const error = `${VIOLENTMONKEY} installer skipped ${url}.
@@ -118,7 +122,7 @@ browser.tabs.onCreated.addListener((tab) => {
   }
   if (isUserJS && isFile && !fileSchemeRequestable && !IS_FIREFOX
   && getOption('helpForLocalFile')) {
-    commands.ConfirmInstall({ url, fs: true }, { tab });
+    confirmInstall({ url, fs: true }, { tab });
   }
 });
 
@@ -131,9 +135,10 @@ browser.webRequest.onBeforeRequest.addListener((req) => {
   if (url.startsWith(extensionRoot)) {
     return { redirectUrl: resolveVirtualUrl(url) };
   }
+  let isWhitelisted;
   if (!cache.has(`bypass:${url}`)
-  && (!blacklistRe.test(url) || whitelistRe.test(url))) {
-    maybeInstallUserJs(tabId, url);
+  && ((isWhitelisted = whitelistRe.test(url)) || !blacklistRe.test(url))) {
+    maybeInstallUserJs(tabId, url, isWhitelisted);
     return IS_FIREFOX
       ? { cancel: true } // for sites with strict CSP in FF
       : { redirectUrl: 'javascript:void 0' }; // eslint-disable-line no-script-url
diff --git a/src/background/utils/tabs.js b/src/background/utils/tabs.js
index 34bfaa6f..3bc87c03 100644
--- a/src/background/utils/tabs.js
+++ b/src/background/utils/tabs.js
@@ -94,13 +94,14 @@ addPublicCommands({
     const srcUrl = src.url;
     const isInternal = !srcUrl || srcUrl.startsWith(extensionRoot);
     // only incognito storeId may be specified when opening in an incognito window
-    const { incognito, [kWindowId]: windowId } = srcTab;
+    const { incognito } = srcTab;
     const canOpenIncognito = !incognito || IS_FIREFOX || !/^(chrome[-\w]*):/.test(url);
     const tabOpts = {
       // normalizing as boolean because the API requires strict types
       active: !!active,
       pinned: !!pinned,
     };
+    let windowId = srcTab[kWindowId];
     let newTab;
     // Chrome can't open chrome-xxx: URLs in incognito windows
     // TODO: for src._removed maybe create a new window if cookieStoreId of active tab is different
@@ -157,8 +158,11 @@ addPublicCommands({
         },
       });
     } catch (err) {
-      if (err.message.startsWith('Illegal to set private')) storeId = null;
-      else if (err.message.startsWith('Tabs cannot be edited')) await makePause(100);
+      const m = err.message;
+      if (m.startsWith('Illegal to set private')) storeId = null;
+      else if (m.startsWith('No tab')) srcTab.id = null;
+      else if (m.startsWith('No window')) windowId = null;
+      else if (m.startsWith('Tabs cannot be edited')) await makePause(100);
       else throw err; // TODO: put in storage and show in UI
     }
     if (active && newTab[kWindowId] !== windowId) {
diff --git a/src/common/index.js b/src/common/index.js
index ade3e9ca..9f5d2cab 100644
--- a/src/common/index.js
+++ b/src/common/index.js
@@ -150,15 +150,37 @@ export function leftpad(input, length, pad = '0') {
   return num;
 }
 
+/**
+ * @param {string} browserLang  Language tags from RFC5646 (`[lang]-[script]-[region]-[variant]`, all parts are optional)
+ * @param {string} locale  `<lang>`, `<lang>-<region>`
+ */
+function localeMatch(browserLang, metaLocale) {
+  const bParts = browserLang.toLowerCase().split('-');
+  const mParts = metaLocale.toLowerCase().split('-');
+  let bi = 0;
+  let mi = 0;
+  while (bi < bParts.length && mi < mParts.length) {
+    if (bParts[bi] === mParts[mi]) mi += 1;
+    bi += 1;
+  }
+  return mi === mParts.length;
+}
+
 /**
  * Get locale attributes such as `@name:zh-CN`
  */
-export function getLocaleString(meta, key) {
-  const localeMeta = navigator.languages
-  // Use `lang.toLowerCase()` since v2.6.5
-  .map(lang => meta[`${key}:${lang}`] || meta[`${key}:${lang.toLowerCase()}`])
-  .find(Boolean);
-  return localeMeta || meta[key] || '';
+export function getLocaleString(meta, key, languages = navigator.languages) {
+  // zh, zh-cn, zh-tw
+  const mls = Object.keys(meta)
+    .filter(metaKey => metaKey.startsWith(key + ':'))
+    .map(metaKey => metaKey.slice(key.length + 1))
+    .sort((a, b) => b.length - a.length);
+  let bestLocale;
+  for (const lang of languages) {
+    bestLocale = mls.find(ml => localeMatch(lang, ml));
+    if (bestLocale) break;
+  }
+  return meta[bestLocale ? `${key}:${bestLocale}` : key] || '';
 }
 
 /**
diff --git a/src/common/ui/setting-check.vue b/src/common/ui/setting-check.vue
index 02fda57f..2ff4366b 100644
--- a/src/common/ui/setting-check.vue
+++ b/src/common/ui/setting-check.vue
@@ -49,5 +49,8 @@ onBeforeUnmount(revoke);
   > :nth-child(2) {
     flex: 1 1 min-content; /* wrapping inside the label so it stays in the same row as [x] */
   }
+  .vl-dropdown-menu & {
+    width: max-content; /* CSS bug(?) workaround for absolutely positioned containers */
+  }
 }
 </style>


Test patch:
diff --git a/test/common/index.test.js b/test/common/index.test.js
index 5d1ed481..67ad3303 100644
--- a/test/common/index.test.js
+++ b/test/common/index.test.js
@@ -1,6 +1,6 @@
 import {
   isRemote, compareVersion, debounce, throttle,
-  loadQuery, dumpQuery,
+  loadQuery, dumpQuery, getLocaleString,
 } from '@/common';
 
 jest.useFakeTimers();
@@ -155,3 +155,17 @@ test('loadQuery/dumpQuery', () => {
   expect(obj).toEqual({ a: '|#,', b: '', c: '' });
   expect(dumpQuery(obj)).toEqual(normalized);
 });
+
+test('getLocaleString', () => {
+  const meta = {
+    'name': 'name without locale',
+    'name:zh': 'name for zh',
+    'name:zh-CN': 'name for zh-CN',
+    'name:zh-TW': 'name for zh-TW',
+  };
+  expect(getLocaleString(meta, 'name', ['zh-CN', 'zh'])).toBe('name for zh-CN');
+  expect(getLocaleString(meta, 'name', ['zh', 'zh-CN'])).toBe('name for zh');
+  expect(getLocaleString(meta, 'name', ['zh-Hant-TW', 'zh-Hant', 'zh'])).toBe('name for zh-TW');
+  expect(getLocaleString(meta, 'name', ['zh-Hant', 'zh'])).toBe('name for zh');
+  expect(getLocaleString(meta, 'name', ['en', 'en-US'])).toBe('name without locale');
+});