Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions remapper/background.js
Original file line number Diff line number Diff line change
@@ -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'
);
1 change: 1 addition & 0 deletions remapper/background_scripts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["preamble.js", "engine.js", "keymap.js", "main.js"]
68 changes: 66 additions & 2 deletions remapper/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions remapper/main.js
Original file line number Diff line number Diff line change
@@ -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));
})();
11 changes: 3 additions & 8 deletions remapper/manifest.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand Down
59 changes: 48 additions & 11 deletions wscript
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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)


Expand All @@ -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()
Expand All @@ -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
]
Expand All @@ -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')
Expand Down