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..a099e9f 100644 --- a/remapper/engine.js +++ b/remapper/engine.js @@ -3,10 +3,67 @@ 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; + } + }); + + // 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(); + } + + // 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, @@ -56,19 +113,26 @@ 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({ 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}); } }); } this.handleKeyEvent = function(engineID, keyData) { + startKeepAlive(); if (keyData.type === "keydown") { if (debug) { console.log(keyData.type, keyData.key, keyData.code, keyData); @@ -80,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; } 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)); })(); 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')