From acca6cf01db1cee9178a0f66f0e2a2e3faa1f9d3 Mon Sep 17 00:00:00 2001 From: kmbys <55523468+kmbys@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:45:46 +0000 Subject: [PATCH 1/3] Migrate to Manifest V3 - Replace background.scripts with a service worker (background.js) that loads the existing scripts via importScripts. The script list the build tooling needs lives in a separate background_scripts.json, keeping the manifest free of MV2-only keys. - Persist contextId and lastFocusedWindowUrl to chrome.storage.session so key events that revive a terminated service worker can still be remapped (onFocus does not fire again on revival). This adds the storage permission. - Make wscript emit an MV3 manifest plus a generated background.js service worker for combined IMEs. An IME's scripts are found in background_scripts.json next to its manifest, under background.scripts (MV2), or as background.service_worker (MV3). --- README.md | 6 ++++ remapper/background.js | 12 +++++++ remapper/background_scripts.json | 1 + remapper/engine.js | 15 ++++++++ remapper/manifest.json | 11 ++---- wscript | 59 ++++++++++++++++++++++++++------ 6 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 remapper/background.js create mode 100644 remapper/background_scripts.json diff --git a/README.md b/README.md index abe35ba..b97b7c5 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,16 @@ This repo contains: keys with a US English keyboard. `C-a` and `C-k`, to name a few. 2. Tooling to build a custom IME that combines the remapper engine and other 3rd party IMEs. +The extension uses Manifest V3: the background scripts run in a single +service worker, loaded via `importScripts`. + ## Limitations - All the combined IMEs share the same JavaScript scope. Name collision can happen. +- Background scripts of all the combined IMEs run inside a Manifest V3 + service worker. Fallback IMEs whose background scripts rely on DOM + APIs (`window`, `document`, etc.) won't work. - The options page of the custom IME can only display the options page of a single IME. - Keys can be only remapped to other key combinations. i.e. can't do things diff --git a/remapper/background.js b/remapper/background.js new file mode 100644 index 0000000..a6085ab --- /dev/null +++ b/remapper/background.js @@ -0,0 +1,12 @@ +// Manifest V3 allows only a single background service worker, so this +// entry point pulls in the actual scripts with importScripts. +// +// The same list lives in background_scripts.json, which the build +// tooling (wscript) reads to know which scripts to stack when combining +// this remapper with fallback IMEs. Keep the two in sync. +importScripts( + 'preamble.js', + 'engine.js', + 'keymap.js', + 'main.js' +); diff --git a/remapper/background_scripts.json b/remapper/background_scripts.json new file mode 100644 index 0000000..5f29508 --- /dev/null +++ b/remapper/background_scripts.json @@ -0,0 +1 @@ +["preamble.js", "engine.js", "keymap.js", "main.js"] diff --git a/remapper/engine.js b/remapper/engine.js index 6820a87..330a99c 100644 --- a/remapper/engine.js +++ b/remapper/engine.js @@ -3,6 +3,19 @@ Remapper.Engine = function (keymap) { var lastFocusedWindowUrl = null; const debug = false; + // The MV3 service worker can be terminated at any time and revived + // by a key event alone, in which case onFocus won't fire again. + // Restore the state saved by the last handleFocus call so the event + // can still be remapped. + chrome.storage.session.get(['contextId', 'lastFocusedWindowUrl'], function(items) { + if (contextId === -1 && typeof items.contextId === 'number') { + contextId = items.contextId; + } + if (lastFocusedWindowUrl === null && items.lastFocusedWindowUrl) { + lastFocusedWindowUrl = items.lastFocusedWindowUrl; + } + }); + const urlBlacklist = [ 'chrome-extension://pnhechapfaindjhompbnflcldabbghjo/html/crosh.html' ]; @@ -58,12 +71,14 @@ Remapper.Engine = function (keymap) { // be a delay due to the API being async. this.handleFocus = function(context) { contextId = context.contextID; + chrome.storage.session.set({contextId: contextId}); chrome.windows.getLastFocused({ populate: true, windowTypes: ['popup', 'normal', 'panel', 'app', 'devtools'] }, function(window) { if (window && window.tabs.length > 0) { lastFocusedWindowUrl = window.tabs[0].url; + chrome.storage.session.set({lastFocusedWindowUrl: lastFocusedWindowUrl}); } }); } diff --git a/remapper/manifest.json b/remapper/manifest.json index 5f45dbe..4ed4b00 100644 --- a/remapper/manifest.json +++ b/remapper/manifest.json @@ -1,18 +1,13 @@ { "name": "US keyboard x emacs", "version": "1.0", - "manifest_version": 2, + "manifest_version": 3, "description": "US keyboard with emacs-like cursor movement", "background": { - "scripts": [ - "preamble.js", - "engine.js", - "keymap.js", - "main.js" - ] + "service_worker": "background.js" }, "permissions": [ - "input", "tabs" + "input", "tabs", "storage" ], "input_components": [ { diff --git a/wscript b/wscript index f8065fa..c86fcb5 100644 --- a/wscript +++ b/wscript @@ -76,8 +76,12 @@ def _build_ime(ctx, name, spec): # build manifest - manifests = [out.find_or_declare(ime_name).find_or_declare('manifest.json') - for ime_name in ime_stack] + manifests = [] + for ime_name in ime_stack: + ime_out = out.find_or_declare(ime_name) + manifests.append(ime_out.find_or_declare('manifest.json')) + if imes_root.find_dir(ime_name).find_node('background_scripts.json'): + manifests.append(ime_out.find_or_declare('background_scripts.json')) manifest_env = ctx.env.derive() manifest_env.identifier = name manifest_env.name = spec['name'] @@ -87,7 +91,8 @@ def _build_ime(ctx, name, spec): manifest_env.options_page = spec['options_page'] manifest_task = manifest(env=manifest_env) manifest_task.set_inputs(manifests) - manifest_task.set_outputs(out.find_or_declare('manifest.json')) + manifest_task.set_outputs([out.find_or_declare('manifest.json'), + out.find_or_declare('background.js')]) ctx.add_to_group(manifest_task) @@ -97,19 +102,44 @@ class jscodeshift(Task.Task): class manifest(Task.Task): ''' - Builds a manifest JSON by collecting background scripts and permissions - from input files and reading the name, description, language, and layout - from `self.env`. + Builds a Manifest V3 manifest JSON and a background service worker by + collecting background scripts and permissions from input files and + reading the name, description, language, and layout from `self.env`. + + Manifest V3 only allows a single background service worker, so the + collected scripts are loaded via importScripts from a generated + background.js. The scripts of an IME are found, in order of + precedence: in a background_scripts.json file next to its manifest + (used by the remapper itself, whose MV3 manifest can't list them), + under background.scripts (MV2 IMEs), or as the single + background.service_worker script (MV3 IMEs). ''' def run(self): target = self.outputs[0] + background_target = self.outputs[1] out = target.parent - submanifests = OrderedDict([(path, path.read_json()) for path in self.inputs]) + submanifests = OrderedDict() + script_lists = {} + for path in self.inputs: + if path.name == 'manifest.json': + submanifests[path] = path.read_json() + elif path.name == 'background_scripts.json': + script_lists[path.parent] = path.read_json() + + def background_scripts(path, submanifest): + if path.parent in script_lists: + return script_lists[path.parent] + background = submanifest.get('background', {}) + if 'scripts' in background: + return background['scripts'] + if 'service_worker' in background: + return [background['service_worker']] + return [] scripts = [path.parent.find_or_declare(script).path_from(out) for path, submanifest in submanifests.items() - for script in submanifest['background']['scripts']] + for script in background_scripts(path, submanifest)] permissions = [permission for path, submanifest in submanifests.items() @@ -126,12 +156,12 @@ class manifest(Task.Task): manifest = { "name": self.env.name, "version": "1.0", - "manifest_version": 2, + "manifest_version": 3, "description": self.env.description, "background": { - "scripts": scripts, + "service_worker": background_target.path_from(out), }, - "permissions": list(set(permissions)), + "permissions": sorted(set(permissions)), "input_components": [ input_component ] @@ -140,6 +170,13 @@ class manifest(Task.Task): manifest['options_page'] = self.env.options_page target.write_json(manifest) + imports = ',\n'.join(" '%s'" % script for script in scripts) + background_target.write( + '// Generated by wscript: Manifest V3 allows only a single\n' + '// background service worker, so load each stacked IME\'s\n' + '// scripts from here, remapper first.\n' + 'importScripts(\n%s\n);\n' % imports) + def imes(ctx): imes_root = ctx.path.find_dir('imes') From 8593197a71e5f9d6ace63dc42ab275cd3e12e259 Mon Sep 17 00:00:00 2001 From: kmbys <55523468+kmbys@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:45:46 +0000 Subject: [PATCH 2/3] Keep the service worker alive while the IME is active ChromeOS doesn't wait for a terminated MV3 service worker to wake up before dispatching a key event: keys pressed while the worker is asleep fall through to the browser's default handling, so restoring state from chrome.storage.session on revival isn't enough on its own. Prevent the worker from idling out while the IME is in use by calling a cheap extension API every 20 seconds, which resets Chrome's 30-second idle timer. The keepalive starts on onActivate (and on focus/key events, the wake-up paths when the worker died mid-activation) and stops on onDeactivated so the worker can still be reclaimed while the IME isn't active. --- remapper/engine.js | 37 +++++++++++++++++++++++++++++++++++++ remapper/main.js | 4 ++++ 2 files changed, 41 insertions(+) diff --git a/remapper/engine.js b/remapper/engine.js index 330a99c..7b1a650 100644 --- a/remapper/engine.js +++ b/remapper/engine.js @@ -16,6 +16,38 @@ Remapper.Engine = function (keymap) { } }); + // Restoring state isn't enough on its own: ChromeOS doesn't wait for a + // terminated worker to wake up before dispatching a key event, so keys + // pressed while the worker is asleep fall through to the browser's + // default handling. Prevent the worker from idling out while the IME is + // active by calling a cheap extension API every 20s; each call resets + // Chrome's 30s idle timer. + var keepAliveTimer = null; + + function startKeepAlive() { + if (keepAliveTimer !== null) { + return; + } + keepAliveTimer = setInterval(function() { + chrome.runtime.getPlatformInfo(function() {}); + }, 20000); + } + + function stopKeepAlive() { + if (keepAliveTimer !== null) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } + } + + this.handleActivate = function() { + startKeepAlive(); + } + + this.handleDeactivated = function() { + stopKeepAlive(); + } + const urlBlacklist = [ 'chrome-extension://pnhechapfaindjhompbnflcldabbghjo/html/crosh.html' ]; @@ -69,7 +101,11 @@ Remapper.Engine = function (keymap) { // grab the last focused window's URL for blacklisting. note that there will // be a delay due to the API being async. + // Also (re)start the keepalive from focus and key events: they are the + // wake-up paths when the worker somehow died while the IME was active, + // in which case onActivate won't fire again. this.handleFocus = function(context) { + startKeepAlive(); contextId = context.contextID; chrome.storage.session.set({contextId: contextId}); chrome.windows.getLastFocused({ @@ -84,6 +120,7 @@ Remapper.Engine = function (keymap) { } this.handleKeyEvent = function(engineID, keyData) { + startKeepAlive(); if (keyData.type === "keydown") { if (debug) { console.log(keyData.type, keyData.key, keyData.code, keyData); diff --git a/remapper/main.js b/remapper/main.js index aca419c..2305cad 100644 --- a/remapper/main.js +++ b/remapper/main.js @@ -1,5 +1,9 @@ (function() { var remapper = new Remapper.Engine(keymap); + // handleActivate and handleDeactivated return undefined so that the + // events keep propagating to fallback IMEs. + Remapper.hijack.onActivate.addListener(remapper.handleActivate.bind(remapper)); + Remapper.hijack.onDeactivated.addListener(remapper.handleDeactivated.bind(remapper)); Remapper.hijack.onFocus.addListener(remapper.handleFocus.bind(remapper)); Remapper.hijack.onKeyEvent.addListener(remapper.handleKeyEvent.bind(remapper)); })(); From 9f545c1a3b128035599c6587faf4c940d82d255e Mon Sep 17 00:00:00 2001 From: kmbys <55523468+kmbys@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:45:46 +0000 Subject: [PATCH 3/3] Recognize crosh's System Web App URL in the blacklist ChromeOS moved crosh from the crosh extension to a System Web App at chrome-untrusted://crosh/, so the hardcoded extension URL no longer matches and keys were being remapped inside crosh. Match known URL prefixes instead of exact URLs, and add the crosh SWA and the Terminal app, which wants raw keys for the same reason. --- remapper/engine.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/remapper/engine.js b/remapper/engine.js index 7b1a650..a099e9f 100644 --- a/remapper/engine.js +++ b/remapper/engine.js @@ -48,10 +48,22 @@ Remapper.Engine = function (keymap) { stopKeepAlive(); } + // URL prefixes of windows in which no remapping should happen. const urlBlacklist = [ - 'chrome-extension://pnhechapfaindjhompbnflcldabbghjo/html/crosh.html' + // crosh as it appeared before ChromeOS moved it to a System Web App + 'chrome-extension://pnhechapfaindjhompbnflcldabbghjo/html/crosh.html', + // crosh as a System Web App + 'chrome-untrusted://crosh/', + // the Terminal app; a terminal wants raw keys for the same reason crosh does + 'chrome-untrusted://terminal/' ]; + function isBlacklisted(url) { + return urlBlacklist.some(function(prefix) { + return url.indexOf(prefix) === 0; + }); + } + const nullKeyData = { 'altKey': false, 'ctrlKey': false, @@ -132,7 +144,7 @@ Remapper.Engine = function (keymap) { return false; } - if (lastFocusedWindowUrl && urlBlacklist.indexOf(lastFocusedWindowUrl) !== -1) { + if (lastFocusedWindowUrl && isBlacklisted(lastFocusedWindowUrl)) { // don't remap in blacklisted windows return false; }