From 50db8577944ec847fa3319aa4828ee529fbf77db Mon Sep 17 00:00:00 2001 From: Nikolay Bogoychev Date: Wed, 13 Apr 2022 21:14:08 +0100 Subject: [PATCH] Initial native message iface (#93) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial native message iface I will develop a more complete native message interface, with json communication that will accept commands from the browser. For now though it is a simple stdin listener... * Add an easy tester The python tester allows to quickly test some changes. Extra work to be done. Pending. * Producer consumer queue, test program and json. Translation is broken The old translation method will not and I am not sure why. We need to create a new service anyways, but this is a problem for the Nick of tomorrow. * One producer thread consumes stdin and enqueues a bunch of translation requests. Lock on stdout writing via callback Early versions. Several issues. Number one is termination. Is there a way of knowing when all service work is done so that we can safely die, before we have any queued translations pending? * More progress Removed service shared pointer and put it inside the thread. Clean shutdown. Removed syncrhonisation with C style iostream. Modified the json * Firefox always calls translateLocally with two arguments: path-to-manifest.json extension-id No way to change that. So easy hack for now: if you see the extension-id as an argument, switch to nativeMessaging. * Add the length header to the output And flush, otherwise the message won't make it directly to Firefox * Add toggle for HTML I need HTML in the extension because there is no non-HTML mode anymore * Add support for langid tags and some code cleanup Wrapped one string in tr, removed unneeded headers and forward defines and used constexpr if template instead of overloaded functions * Implement initial version of model swapping and pivoting What isn't implemented is downloading a model that is not already downloaded. Pretty much eventLoop is used wrongly * On the way to the api proposed by @jelmervd Lots of refactoring, but now we have more reliable parsing of the incoming messages. Outgoing API is not implemented. I have made some modifications of what I expect and I will post my proposed edits tomorrow morning. We are also facing an issue with working in a separate thread. Any sort of QT objects's connections must be called in the same thread as the one in which they were constructed, as opposed to in a child thread. This means that we need to rethink the design so that FetchRemoteModels and DownloadModels are both callled from the main thread. * Move everything to the main thread. Listing models works. Fetching models is implemneted but some extra details needed to make it useful * `return` at the wrong level? * Fix response message structure * Somewhat more proper functionality * No termination condition, but everything is working Finalise is never called, because it will not complete any slots that are in progress. The way to do this is to probably have a second thread that keeps track on the termination condition and calls finalise() there... * First fully functional prototype. Not very well tested * Do not set `success:true` on download progress messages This makes handling TranslateLocally's responses much easier since we can keep the 1 request 1 response (with success = true or false) model in the extension. `update:true` messages can later be handled by something similar to progress reporting QFuture-like promises in the extension. * Send single object in `data` As opposed to an array of objects * Fix bug where remote model was selected when local model was available * Add a todo note * Move model matching code to ModelManager Temporarily removed the map that speeds up finding models but needs to be kept in sync with the model list. Will bring it back at some point but keep it hidden inside ModelManager's internals. I've tasted std::variant and it is now my favourite food. * (hopefully) fix compilation issue for macOS 10.15 * Revert "(hopefully) fix compilation issue for macOS 10.15" This reverts commit 9512113680498212a54a627cb36eff9a6e2aeef2. It is essentially a "compiler isn't told properly that we're using C++17" issue. See https://stackoverflow.com/a/43631668 * Rewrite native_client.py example to use asyncio Necessary preparations for load testing later on, where I want to test concurrent requests like there would be from a browser extension trying to keep up with tabs loading & translating. * Simplify DownloadModel message handling * Update for changes to master * Upload timing test code as well * Catch nullptr returned from downloadFile The whole `operations_--` counter thing is very easy to mess up :( * Make progress messages more useful for a client Passing bytes instead of 0..1 so a client can show download speed and size if they want to. * Implement & test single shot connection type for signal handling * Implement model-specific translations It falls back too often to `QString id` when it already has access to a `Model`, but it has fewer separate code pathss. * Implement isSameModel through Model::id() * When includeRemote is false, don't include remote models. * Defensive work-around in test for broken model_info.json in eng-fin model. * Fill in srcTags and trgTag based on shortName if they're missing from model_info.json * Graceful shutdown of native client * Debug stuff It's a debug program. No use not having the debug print statements in there… * Make connectSingleShot also accept lambdas * Move respond logic to write* methods Also always pass along the request because it makes debugging a lot easier since you can see which request you're responding to. Moved a lot of the logic to single shot connections since ModelManager does not guarantee that every request that accepts `extradata` will also produce a signal. This also helps group all the logic to handling one request type in one place, which is nice. * Possibly fix undeclared `Args` in gcc * Attempt to fix compilation issue where QStringList (which extends QList) does not seem to have a constructor that accepts being and end iterators. * Base connectSingleShot template on the signal it connects to instead of the slot Maybe this works? Problem was in the Qt5 bit, not the Qt6 bit. * Fix macOS compilation issue by replacing `char[]` with `std::vector` * Add async shutdown test In which I write requests, close stdin, and then wait to see if all responses do end up on stdout. * Simplify iothread & operation count locking a bit Basically I wanted a semaphore that blocked till it was 0. But until then this will do. * Add code to register native messaging client with Firefox on launch Maybe we should first ask though * Revert to using atomic In my limited testing, it seems to work okay even when the condition_variable does not share the same mutex as the atomic. * Fix concurrent downloads * Add comments to native messaging launch detection * Print errors to stderr * Change modelID -> id in download update message to match format in ListModels and DownloadModel success messages * Document each of the requests * Remove `die_` * Replace `shared_ptr>` with QByteArray * Fix windows registry path … I think * Switch stdin & stdout to binary mode on Windows * Include the right headers on Windows * Use the other slash Cause Qt [is funny like that](https://github.com/qt/qtbase/blob/a0a2bf2d95d4fcd468b6ce3c2e728d95425dd760/src/corelib/io/qsettings_win.cpp#L103-L115) * Fix call to `std::isspace` Documentation is explicit about only calling it with unsigned char, and Windows' C++ runtime is checking for that. * Move extension ids to a single place * Update README with info about native messaging * Remove superfluous `)` * Mention limitations up front So nobody gets hurt Co-authored-by: Jelmer van der Linde --- 3rd_party/bergamot-translator | 2 +- CMakeLists.txt | 9 +- README.md | 23 ++ scripts/native_client.py | 342 ++++++++++++++++++++++ src/MarianInterface.cpp | 4 +- src/MarianInterface.h | 7 - src/Network.cpp | 16 +- src/Network.h | 8 +- src/{ => cli}/CLIParsing.h | 37 ++- src/{ => cli}/CommandLineIface.cpp | 0 src/{ => cli}/CommandLineIface.h | 0 src/cli/NativeMsgIface.cpp | 452 +++++++++++++++++++++++++++++ src/cli/NativeMsgIface.h | 430 +++++++++++++++++++++++++++ src/constants.h | 11 + src/inventory/ModelManager.cpp | 97 ++++++- src/inventory/ModelManager.h | 151 +++++++--- src/main.cpp | 21 +- src/mainwindow.cpp | 64 +++- src/mainwindow.h | 6 + 19 files changed, 1587 insertions(+), 93 deletions(-) create mode 100755 scripts/native_client.py rename src/{ => cli}/CLIParsing.h (64%) rename src/{ => cli}/CommandLineIface.cpp (100%) rename src/{ => cli}/CommandLineIface.h (100%) create mode 100644 src/cli/NativeMsgIface.cpp create mode 100644 src/cli/NativeMsgIface.h create mode 100644 src/constants.h diff --git a/3rd_party/bergamot-translator b/3rd_party/bergamot-translator index 049815e3..df5db525 160000 --- a/3rd_party/bergamot-translator +++ b/3rd_party/bergamot-translator @@ -1 +1 @@ -Subproject commit 049815e366782bac5d534c78ef7fff34d5a1682b +Subproject commit df5db525132fb24b02f80ac07dc98ba02f536e92 diff --git a/CMakeLists.txt b/CMakeLists.txt index 88fe3896..903a1eff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -128,6 +128,7 @@ include_directories(logo) add_subdirectory(src) # This doesn't quite work if places in the subdirectory's CMakeLists.txt set(PROJECT_SOURCES + src/constants.h src/main.cpp src/mainwindow.cpp src/mainwindow.h @@ -145,9 +146,11 @@ set(PROJECT_SOURCES src/Translation.h src/Translation.cpp src/types.h - src/CommandLineIface.h - src/CommandLineIface.cpp - src/CLIParsing.h + src/cli/CLIParsing.h + src/cli/CommandLineIface.cpp + src/cli/CommandLineIface.h + src/cli/NativeMsgIface.cpp + src/cli/NativeMsgIface.h src/inventory/ModelManager.cpp src/inventory/ModelManager.h src/inventory/RepoManager.cpp diff --git a/README.md b/README.md index 215eb562..52c91805 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,29 @@ sacrebleu -t wmt13 -l en-es --echo ref > /tmp/es.in cat /tmp/es.in | ./translateLocally -m es-en-tiny | ./translateLocally -m en-de-tiny -o /tmp/de.out ``` +# NativeMessaging interface +translateLocally can integrate with other applications and browser extensions using [native messaging](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging). This functionality is similar to using pipes on the command line, except that the message format is JSON which allows you to specify options per input fragment, and the translated fragments are returned when they become available as opposed to the input order. + +## Limitations +Right now there is a 10MB message limit for incoming messages. This matches the limitations of Firefox. Responses are limited to about 4GB due to the native messaging message format. + +## Using NativeMessaging from Python +Start translateLocally in a subprocess with the `-p` option, and pass it messages [formatted as described here](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging#app_side) to its stdin. All supported messages are described in the [NativeMsgIface.h](src/cli/NativeMsgIface.h) file. + +There is an example, [native_client.py](scripts/native_client.py), that demonstrates how to use translateLocally as an async Python API. + +## Using NativeMessaging from browser extensions +Right now, the functionality is only automatically available to Firefox. + +translateLocally automatically registers itself with Firefox when you start translateLocally in GUI mode. Then you can install the [Firefox translation addon](https://github.com/jelmervdl/firefox-translations/releases). After installation of the addon, go into the addon settings and pick "translateLocally" as translation provider. + +### Developing your own browser extension +Due to the way Firefox and Chrome call translateLocally, you will need to add your browser extension id to the translateLocally source code before it is able to accept native messages. + +Add your extension id to [constants.h](src/constants.h) and rebuild translateLocally from source. Once you start it in GUI mode, it will re-register itself with support for your extension. + +If you want your extension id added to translateLocally permanently, please open an issue or send us a pull request! + # Importing custom models translateLocally supports importing custom models. translateLocally uses the [Bergamot](https://github.com/browsermt/marian-dev) fork of [marian](https://github.com/marian-nmt/marian-dev). As such, it supports the vast majority marian models out of the box. You can just train your marian model and place it a directory. ## Basic model import diff --git a/scripts/native_client.py b/scripts/native_client.py new file mode 100755 index 00000000..b869b757 --- /dev/null +++ b/scripts/native_client.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +'''A native client simulating the plugin to use for testing the server''' +import asyncio +import itertools +import struct +import json +import time +import sys +import csv +from pathlib import Path +from pprint import pprint +from tqdm import tqdm + + +class Timer: + """Little helper class top measure runtime of async function calls and dump + all of those to a CSV. + """ + def __init__(self): + self.measurements = [] + + async def measure(self, coro, *details): + start = time.perf_counter() + result = await coro + end = time.perf_counter() + self.measurements.append([end - start, *details]) + return result + + def dump(self, fh): + # TODO stats? For now I just export to Excel or something + writer = csv.writer(fh) + writer.writerows(self.measurements) + + +class Client: + """asyncio based native messaging client. Main interface is just calling + `request()` with the right parameters and awaiting the future it returns. + """ + def __init__(self, *args): + self.serial = itertools.count(1) + self.futures = {} + self.args = args + + async def __aenter__(self): + self.proc = await asyncio.create_subprocess_exec(*self.args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE) + self.read_task = asyncio.create_task(self.reader()) + return self + + async def __aexit__(self, *args): + self.proc.stdin.close() + await self.proc.wait() + + def request(self, command, data, *, update=lambda data: None): + message_id = next(self.serial) + message = json.dumps({"command": command, "id": message_id, "data": data}).encode() + # print(f"Sending: {message}", file=sys.stderr) + future = asyncio.get_running_loop().create_future() + self.futures[message_id] = future, update + self.proc.stdin.write(struct.pack("@I", len(message))) + self.proc.stdin.write(message) + return future + + async def reader(self): + while True: + try: + raw_length = await self.proc.stdout.readexactly(4) + length = struct.unpack("@I", raw_length)[0] + raw_message = await self.proc.stdout.readexactly(length) + + # print(f"Receiving: {raw_message.decode()}", file=sys.stderr) + message = json.loads(raw_message) + + # Not cool if there is no response message "id" here + if not "id" in message: + continue + + # print(f"Receiving response to {message['id']}", file=sys.stderr) + future, update = self.futures[message["id"]] + + if "success" in message: + del self.futures[message["id"]] + if message["success"]: + future.set_result(message["data"]) + else: + future.set_exception(Exception(message["error"])) + elif "update" in message: + update(message["data"]) + except asyncio.IncompleteReadError: + break # Stop read loop if EOF is reached + except asyncio.CancelledError: + break # Also stop reading if we're cancelled + + +class TranslateLocally(Client): + """TranslateLocally wrapper around Client that translates + our defined messages into functions with arguments. + """ + async def list_models(self, *, include_remote=False): + return await self.request("ListModels", {"includeRemote": bool(include_remote)}) + + async def translate(self, text, src=None, trg=None, *, model=None, pivot=None, html=False): + if src and trg: + if model or pivot: + raise InvalidArgumentException("Cannot combine src + trg and model + pivot arguments") + spec = {"src": str(src), "trg": str(trg)} + elif model: + if pivot: + spec = {"model": str(model), "pivot": str(pivot)} + else: + spec = {"model": str(model)} + else: + raise InvalidArgumentException("Missing src + trg or model argument") + + result = await self.request("Translate", {**spec, "text": str(text), "html": bool(html)}) + return result["target"]["text"] + + async def download_model(self, model_id, *, update=lambda data: None): + return await self.request("DownloadModel", {"modelID": str(model_id)}, update=update) + + +def first(iterable, *default): + """Returns the first value of anything iterable, or throws StopIteration + if it is empty. Or, if you specify a default argument, it will return that. + """ + return next(iter(iterable), *default) # passing as rest argument so it can be nothing and trigger StopIteration exception + + +def get_build(): + """Instantiate an asyncio TranslateLocally client that connects to + tranlateLocally in your local build directory. + """ + return TranslateLocally(Path(__file__).resolve().parent / Path("../build/translateLocally"), "-p") + + +async def download_with_progress(tl, model, position): + """tl.download but with a tqdm powered progress bar.""" + with tqdm(position=position, desc=model["modelName"], unit="b", unit_scale=True, leave=False) as bar: + def update(data): + assert data["read"] <= data["size"] + bar.total = data["size"] + diff = data["read"] - bar.n + bar.update(diff) + return await tl.download_model(model["id"], update=update) + + +async def test(): + """Test TranslateLocally functionality.""" + async with get_build() as tl: + models = await tl.list_models(include_remote=True) + pprint(models) + + # Models necessary for tests, both direct & pivot + necessary_models = {("en", "de"), ("en", "es"), ("es", "en")} + + # From all models available, pick one for every necessary language pair + # (preferring tiny ones) so we can make sure these are downloaded. + selected_models = { + (src,trg): first(sorted( + ( + model + for model in models + if src in model["srcTags"] and trg == model["trgTag"] + ), + key=lambda model: 0 if model["type"] == "tiny" else 1 + )) + for src, trg in necessary_models + } + + pprint(selected_models) + + # Download them. Even if they're already model['local'] == True, to test + # that in that case this is a no-op. + await asyncio.gather(*( + download_with_progress(tl, model, position) + for position, model in enumerate(selected_models.values()) + )) + print() # tqdm messes a lot with the print position, this makes it less bad + + # Test whether the model list has been updated to reflect that the + # downloaded models are now local. + models = await tl.list_models(include_remote=True) + assert all( + model["local"] + for selected_model in selected_models.values() + for model in models + if model["id"] == selected_model["id"] + ) + + # Perform some translations, switching between the models + translations = await asyncio.gather( + tl.translate("Hello world!", "en", "de"), + tl.translate("Let's translate another sentence to German.", "en", "de"), + tl.translate("Sticks and stones may break my bones but words WILL NEVER HURT ME!", "en", "es"), + tl.translate("I like to drive my car. But I don't have one.", "en", "de", html=True), + tl.translate("¿Por qué no funciona bien?", "es", "de"), + tl.translate("This will be the last sentence of the day.", "en", "de"), + ) + + pprint(translations) + + assert translations == [ + "Hallo Welt!", + "Übersetzen wir einen weiteren Satz mit Deutsch.", + "Palos y piedras pueden romper mis huesos, pero las palabras NUNCA HURT ME.", + "Ich fahre gerne mein Auto. Aber ich habe keine.", #fahre??? + "Warum funktioniert es nicht gut?", + "Dies wird der letzte Satz des Tages sein.", + ] + + # Test bad input + try: + await tl.translate("This is impossible to translate", "en", "xx") + assert False, "How are we able to translate to 'xx'???" + except Exception as e: + assert "Could not find the necessary translation models" in str(e) + + print("Fin") + + +async def test_third_party(): + """Test whether TranslateLocally can switch between different types of + models. This test assumes you have the OPUS repository in your list: + https://object.pouta.csc.fi/OPUS-MT-models/app/models.json + """ + async with get_build() as tl: + models_to_try = [ + 'en-de-tiny', + 'en-de-base', + 'eng-fin-tiny', # model has broken model_info.json so won't work anyway :( + 'eng-ukr-tiny', + ] + + models = await tl.list_models(include_remote=True) + + # Select a model from the model list for each of models_to_try, but + # leave it out if there is no model available. + selected_models = { + shortname: model + for shortname in models_to_try + if (model := first((model for model in models if model["shortname"] == shortname), None)) + } + + await asyncio.gather(*( + download_with_progress(tl, model, position) + for position, model in enumerate(selected_models.values()) + )) + + # TODO: Temporary filter to figure out 'failed' downloads. eng-fin-tiny + # has a broken JSON file so it will download correctly, but still not + # be available or show up in this list. We should probably make the + # download fail in that scenario. + models = await tl.list_models(include_remote=False) + for shortname in list(selected_models.keys()): + if not any(True for model in models if model["shortname"] == shortname): + print(f"Skipping {shortname} because it didn't show up in model list after downloading", file=sys.stderr) + del selected_models[shortname] + + translations = await asyncio.gather(*[ + tl.translate("This is a very simple test sentence", model=model["id"]) + for model in selected_models.values() + ]) + + pprint(list(zip(selected_models.keys(), translations))) + + +async def test_latency(): + timer = Timer() + + # Our line generator: just read Crime & Punishment from stdin :D + lines = (line.strip() for line in sys.stdin) + + async with get_build() as tl: + for epoch in range(100): + print(f"Epoch {epoch}...", file=sys.stderr) + for batch_size in [1, 5, 10, 20, 50, 100]: + await asyncio.gather(*( + timer.measure( + tl.translate(line, "en", "de"), + epoch, + batch_size, + len(line.split(' '))) + for n, line in zip(range(batch_size), lines) + )) + + timer.dump(sys.stdout) + + +async def test_concurrency(): + async with get_build() as tl: + fetch_one = tl.list_models(include_remote=True) + fetch_two = tl.list_models(include_remote=False) + fetch_three = tl.list_models(include_remote=True) + await asyncio.gather(fetch_one, fetch_two, fetch_three) + + +async def test_shutdown(): + tasks = [] + async with get_build() as tl: + for n in range(10): + print(f"Requesting translation {n}") + tasks.append(tl.request("Translate", { + "src": "en", + "trg": "de", + "text": f"This is simple sentence number {n}!", + "html": False + })) + print("Shutting down") + print("Shutdown complete") + for translation in asyncio.as_completed(tasks): + print(await translation) + print("Fin.") + + +async def test_concurrent_download(): + """Test parallel downloads.""" + async with get_build() as tl: + models = await tl.list_models(include_remote=True) + remote = [model for model in models if not model["local"]] + downloads = [ + tl.download_model(model["id"]) + for model, _ in zip(remote, range(3)) + ] + await asyncio.gather(*downloads) + + +def main(): + tests = { + "test": test, + "third-party": test_third_party, + "latency": test_latency, + "concurrency": test_concurrency, + "shutdown": test_shutdown, + "concurrent-downloads": test_concurrent_download + } + + if len(sys.argv) == 1 or sys.argv[1] not in tests: + print(f"Usage: {sys.argv[0]} {' | '.join(tests.keys())}", file=sys.stderr) + else: + asyncio.run(tests[sys.argv[1]]()) + + +main() \ No newline at end of file diff --git a/src/MarianInterface.cpp b/src/MarianInterface.cpp index f43bee30..bfa6800c 100644 --- a/src/MarianInterface.cpp +++ b/src/MarianInterface.cpp @@ -1,9 +1,7 @@ #include "MarianInterface.h" #include "3rd_party/bergamot-translator/src/translator/service.h" -#include "3rd_party/bergamot-translator/src/translator/service.h" #include "3rd_party/bergamot-translator/src/translator/parser.h" #include "3rd_party/bergamot-translator/src/translator/response.h" -#include "3rd_party/bergamot-translator/3rd_party/marian-dev/src/3rd_party/spdlog/spdlog.h" #include #include #include @@ -29,7 +27,7 @@ int countWords(std::string input) { int numWords = 0; while (*str != '\0') { - if (std::isspace(*str)) { + if (std::isspace(static_cast(*str))) { inSpaces = true; } else if (inSpaces) { numWords++; diff --git a/src/MarianInterface.h b/src/MarianInterface.h index 40e1b49a..9153bd83 100644 --- a/src/MarianInterface.h +++ b/src/MarianInterface.h @@ -10,13 +10,6 @@ #include #include -// If we include the actual header, we break QT compilation. -namespace marian { - namespace bergamot { - class Service; - } -} - struct ModelDescription; constexpr const size_t kTranslationCacheSize = 1 << 16; diff --git a/src/Network.cpp b/src/Network.cpp index f125c57d..f101427e 100644 --- a/src/Network.cpp +++ b/src/Network.cpp @@ -18,13 +18,13 @@ QNetworkReply* Network::get(QNetworkRequest request) { return nam_->get(request); } -QNetworkReply* Network::downloadFile(QUrl url, QFile *dest, QCryptographicHash::Algorithm algorithm, QByteArray hash) { +QNetworkReply* Network::downloadFile(QUrl url, QFile *dest, QCryptographicHash::Algorithm algorithm, QByteArray hash, QVariant extradata) { QNetworkReply *reply = get(QNetworkRequest(url)); // Open in read/write so we can easily read the data when handling the // downloadComplete signal. if (!dest->open(QIODevice::ReadWrite)) { - emit error(tr("Cannot open file for downloading.")); + emit error(tr("Cannot open file for downloading."), extradata); return nullptr; } @@ -37,7 +37,7 @@ QNetworkReply* Network::downloadFile(QUrl url, QFile *dest, QCryptographicHash:: QByteArray buffer = reply->readAll(); if (dest->write(buffer) == -1) { - emit error(tr("An error occurred while writing the downloaded data to disk: %1").arg(dest->errorString())); + emit error(tr("An error occurred while writing the downloaded data to disk: %1").arg(dest->errorString()), extradata); reply->abort(); } @@ -56,11 +56,11 @@ QNetworkReply* Network::downloadFile(QUrl url, QFile *dest, QCryptographicHash:: emit error(tr("The cryptographic hash of %1 does not match the provided hash.\nExpected: %2\nActual: %3\nFile size: %4").arg(url.toString(), QString(hash.toHex()), QString(hasher->result().toHex()), - QString::number(dest->size()))); + QString::number(dest->size())), extradata); break; } - emit downloadComplete(dest, reply->url().fileName()); + emit downloadComplete(dest, reply->url().fileName(), extradata); break; case QNetworkReply::OperationCanceledError: @@ -68,7 +68,7 @@ QNetworkReply* Network::downloadFile(QUrl url, QFile *dest, QCryptographicHash:: break; default: - emit error(reply->errorString()); + emit error(reply->errorString(), extradata); break; } @@ -86,9 +86,9 @@ QNetworkReply* Network::downloadFile(QUrl url, QFile *dest, QCryptographicHash:: * do not change the parent of the QTemporaryFile, it will be deleted * automatically after the downloadComplete(QFile*,QString) signal is handled. */ -QNetworkReply* Network::downloadFile(QUrl url, QCryptographicHash::Algorithm algorithm, QByteArray hash) { +QNetworkReply* Network::downloadFile(QUrl url, QCryptographicHash::Algorithm algorithm, QByteArray hash, QVariant extradata) { QTemporaryFile *dest = new QTemporaryFile(); - QNetworkReply *reply = downloadFile(url, dest, algorithm, hash); + QNetworkReply *reply = downloadFile(url, dest, algorithm, hash, extradata); // Make the lifetime of the temporary as long as the reply object itself if (reply != nullptr) diff --git a/src/Network.h b/src/Network.h index 18826c0f..356e9218 100644 --- a/src/Network.h +++ b/src/Network.h @@ -24,7 +24,7 @@ class Network : public QObject { * with a pointer to the file and suggested filename. During download the * `progressBar(qint64,qint64)` signal is emitted. */ - QNetworkReply *downloadFile(QUrl url, QFile* dest, QCryptographicHash::Algorithm algorithm = QCryptographicHash::Sha256, QByteArray hash = QByteArray()); + QNetworkReply *downloadFile(QUrl url, QFile* dest, QCryptographicHash::Algorithm algorithm = QCryptographicHash::Sha256, QByteArray hash = QByteArray(), QVariant extradata = QVariant()); /** * Overloaded version of `downloadFile(QUrl,QFile)` that downloads to a @@ -32,15 +32,15 @@ class Network : public QObject { * `QNetworkReply` object is destroyed. But you can change this by changing * the file's parent. */ - QNetworkReply *downloadFile(QUrl url, QCryptographicHash::Algorithm algorithm = QCryptographicHash::Sha256, QByteArray hash = QByteArray()); + QNetworkReply *downloadFile(QUrl url, QCryptographicHash::Algorithm algorithm = QCryptographicHash::Sha256, QByteArray hash = QByteArray(), QVariant extradata = QVariant()); private: std::unique_ptr nam_; signals: - void downloadComplete(QFile* file, QString filename); + void downloadComplete(QFile* file, QString filename, QVariant extradata = QVariant()); void progressBar(qint64 ist, qint64 max); - void error(QString); + void error(QString err, QVariant extradata = QVariant()); }; #endif // NETWORK_H diff --git a/src/CLIParsing.h b/src/cli/CLIParsing.h similarity index 64% rename from src/CLIParsing.h rename to src/cli/CLIParsing.h index f1040229..23086abb 100644 --- a/src/CLIParsing.h +++ b/src/cli/CLIParsing.h @@ -1,9 +1,16 @@ #pragma once +#include "constants.h" #include #include namespace translateLocally { +enum AppType { + CLI, + GUI, + NativeMsg +}; + /** * @brief CLIArgumentInit Inits the command line argument parsing. * @param translateLocallyApp The translateLocally QApplicaiton @@ -22,25 +29,41 @@ static void CLIArgumentInit(QAppType& translateLocallyApp, QCommandLineParser& p parser.addOption({{"m", "model"}, QObject::tr("Select model for translation."), "model", ""}); parser.addOption({{"i", "input"}, QObject::tr("Source translation file (or just used stdin)."), "input", ""}); parser.addOption({{"o", "output"}, QObject::tr("Target translation file (or just used stdout)."), "output", ""}); + parser.addOption({{"p", "plugin"}, QObject::tr("Start native message server to use for a browser plugin.")}); parser.process(translateLocallyApp); } /** - * @brief isCLIOnly Checks if the application should run in cliONLY mode or launch the GUI + * @brief runType Checks whether to run in CLI, GUI or native message interface server * @param parser The command line parser. - * @return + * @return the launched main type */ -static bool isCLIOnly(QCommandLineParser& parser) { +static AppType runType(QCommandLineParser& parser) { QList cmdonlyflags = {"l", "a", "d", "r", "m", "i", "o"}; - bool cliONLY = false; + QList nativemsgflags = {"p"}; + for (auto&& flag : nativemsgflags) { + if (parser.isSet(flag)) { + return NativeMsg; + } + } + for (auto&& flag : cmdonlyflags) { if (parser.isSet(flag)) { - cliONLY = true; - break; + return CLI; } } - return cliONLY; + + // Search for the extension ID among the start-up arguments. This is the only thing + // the native messaging APIs of Firefox and Chrome have in common. See also: + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging#extension_side + for (auto&& path : parser.positionalArguments()) { + if (kNativeMessagingClients.contains(path)) { + return NativeMsg; + } + } + + return GUI; } } // namespace translateLocally diff --git a/src/CommandLineIface.cpp b/src/cli/CommandLineIface.cpp similarity index 100% rename from src/CommandLineIface.cpp rename to src/cli/CommandLineIface.cpp diff --git a/src/CommandLineIface.h b/src/cli/CommandLineIface.h similarity index 100% rename from src/CommandLineIface.h rename to src/cli/CommandLineIface.h diff --git a/src/cli/NativeMsgIface.cpp b/src/cli/NativeMsgIface.cpp new file mode 100644 index 00000000..8dbd6726 --- /dev/null +++ b/src/cli/NativeMsgIface.cpp @@ -0,0 +1,452 @@ +#include "NativeMsgIface.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// bergamot-translator +#include "3rd_party/bergamot-translator/src/translator/service.h" +#include "3rd_party/bergamot-translator/src/translator/parser.h" +#include "3rd_party/bergamot-translator/src/translator/response.h" +#include "inventory/ModelManager.h" +#include "translator/translation_model.h" + +#if defined(Q_OS_WIN) +// for _setmode, _fileno and _O_BINARY on Windows +#include +#include +#endif + +namespace { + +// Helper type for using std::visit() with multiple visitor lambdas. Copied +// from the C++ reference. +template struct overloaded : Ts... { using Ts::operator()...; }; + +// Explicit deduction guide (not needed as of C++20) +template overloaded(Ts...) -> overloaded; + +std::shared_ptr makeOptions(const std::string &path_to_model_dir, const translateLocally::marianSettings &settings) { + std::shared_ptr options(marian::bergamot::parseOptionsFromFilePath(path_to_model_dir + "/config.intgemm8bitalpha.yml")); + options->set("cpu-threads", settings.cpu_threads, + "workspace", settings.workspace, + "mini-batch-words", 1000, + "alignment", "soft", + "quiet", true); + return options; +} + +// Little helper function that sets up a SingleShot connection in both Qt 5 and 6 +template +QMetaObject::Connection connectSingleShot(Sender *sender, void (Emitter::*signal)(Args ...args), const QObject *context, Slot slot) { +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) + std::shared_ptr connection = std::make_shared(); + return *connection = QObject::connect(sender, signal, context, [=](Args ...args) { + QObject::disconnect(*connection); + slot(std::forward(args)...); + }); +#else + return QObject::connect(sender, signal, context, slot, Qt::SingleShotConnection); +#endif +} + +// Little helper to print QSet and QList without the need to +// convert them into a QStringList. +template +QString join(QString glue, T const &list) { + QString out; + for (auto &&item : list) { + if (!out.isEmpty()) + out += glue; + out += item; + } + return out; +}; + +} + +NativeMsgIface::NativeMsgIface(QObject * parent) : + QObject(parent) + , network_(this) + , settings_(this) + , models_(this, &settings_) + , operations_(0) + { + // Disable synchronisation with C style streams. That should make IO faster + std::ios_base::sync_with_stdio(false); + + // Init the marian translation service: + marian::bergamot::AsyncService::Config serviceConfig; + serviceConfig.numWorkers = settings_.marianSettings().cpu_threads; + serviceConfig.cacheSize = settings_.marianSettings().translation_cache ? kTranslationCacheSize : 0; + service_ = std::make_shared(serviceConfig); + + // Pick up on network errors: Right now these are only caused by DownloadRequest + // because of how Network.h is implemented. But in the future it might be that + // fetchRemoteModels() might also hook into this, and those can yield multiple + // errors for one request (e.g. multiple model repositories.) + connect(&network_, &Network::error, this, [&](QString err, QVariant data) { + if (data.canConvert()) + writeError(data.value(), std::move(err)); + else + qDebug() << "Network error without request data:" << err; + }); + + connect(&network_, &Network::downloadComplete, this, [this](QFile *file, QString filename, QVariant data) { + ABORT_UNLESS(data.canConvert(), "Model download completed without DownloadRequest data"); + auto model = models_.writeModel(file, filename); + if (model) { + DownloadRequest request = data.value(); + writeResponse(request, model->toJson()); + } + }); + + // Model manager errors are not always 1-on-1 mappable to requests. For now + // we'll just forward them to stderr. + connect(&models_, &ModelManager::error, this, [this](QString err) { + qDebug() << "Error from model manager:" << err; + }); + + connect(this, &NativeMsgIface::emitJson, this, &NativeMsgIface::processJson); +} + +void NativeMsgIface::run() { +#if defined(Q_OS_WIN) + // Switch stdin and stdout to binary mode to prevent Windows from attempting + // to do any newline conversion. Apparently there is no standard way of doing + // this so ifdef win only section here. + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); +#endif + + iothread_ = std::thread([this](){ + for (;;) { + // First part of the message: Find how long the input is. If that + // read fails, we're probably at EOF. + char len[4]; + if (!std::cin.read(len, 4)) + break; + + int ilen = *reinterpret_cast(len); + if (ilen >= kMaxInputLength || ilen < 2) { // >= 2 because JSON is at least "{}" + std::cerr << "Invalid message size. Shutting down." << std::endl; + break; + } + + // Read in the message into Json + QByteArray input(ilen, 0); + if (!std::cin.read(input.data(), ilen)) { + std::cerr << "Error while reading input message of length " << ilen << ". Shutting down." << std::endl; + break; + } + + // Keep track of the number of pending operations so we can wait for them to + // all finish before we shut down the main thread. + operations_++; + + emit emitJson(input); + } + + // Here we lock the reading thread until all work is completed because + // the NativeMsgIface destructor blocks on this thread finishing. Bit + // convoluted at the moment. + std::unique_lock lck(pendingOpsMutex_); + pendingOpsCV_.wait(lck, [this](){ return operations_ == 0; }); + emit finished(); + }); +} + +void NativeMsgIface::handleRequest(TranslationRequest request) { + // Initialise models based on the request. + if (!findModels(request)) + return writeError(request, "Could not find the necessary translation models."); + + if (!loadModels(request)) + return writeError(request, "Failed to load the necessary translation models."); + + // Initialise translator settings options + marian::bergamot::ResponseOptions options; + options.HTML = request.html; + std::function callback = [this,request](marian::bergamot::Response&& val) { + QJsonObject data = { + {"target", QJsonObject{ + {"text", QString::fromStdString(std::move(val.target.text))} + }} + }; + writeResponse(request, std::move(data)); + }; + + // Attempt translation. Beware of runtime errors + try { + std::visit(overloaded { + [&](DirectModelInstance &model) { + service_->translate(model.model, std::move(request.text.toStdString()), callback, options); + }, + [&](PivotModelInstance &model) { + service_->pivot(model.model, model.pivot, std::move(request.text.toStdString()), callback, options); + } + }, *model_); + } catch (const std::runtime_error &e) { + writeError(request, QString::fromStdString(std::move(e.what()))); + } +} + +void NativeMsgIface::handleRequest(ListRequest request) { + // Fetch remote models if necessary. + if (request.includeRemote && models_.getRemoteModels().isEmpty()) { + // Note: this might pick up the completion of an earlier fetchRemoteModels() + // request but that's okay since fetchRemoteModels() returns early if a + // fetch is still in progress. Also, fetchedRemoteModels() is called + // regardless of whether errors occurred during the fetching. + connectSingleShot(&models_, &ModelManager::fetchedRemoteModels, this, [this, request]([[maybe_unused]] QVariant ignored) { + handleRequest(request); + }); + return models_.fetchRemoteModels(); + } + + QJsonArray modelsJson; + for (auto&& model : models_.getInstalledModels()) { + modelsJson.append(model.toJson()); + } + + if (request.includeRemote) { + for (auto&& model : models_.getNewModels()) { + modelsJson.append(model.toJson()); + } + } + + writeResponse(request, modelsJson); +} + +void NativeMsgIface::handleRequest(DownloadRequest request) { + // Edge case: client issued a DownloadRequest before fetching the list of + // remote models because it knows the model ID from a previous run. We still + // need to dowload the remote model list to figure out which model it wants. + // We thus fire off a fetchRemoteModels() request, and re-handle the + // DownloadRequest from its "done!" signal. + if (models_.getRemoteModels().isEmpty()) { + // Note: this could pick up the signal emitted from a previous request + // to fetch the model list. But that's okay, because fetchRemoteModels() + // just returns if a request is still in progress. + connectSingleShot(&models_, &ModelManager::fetchedRemoteModels, this, [this, request]([[maybe_unused]] QVariant ignored) { + handleRequest(request); + }); + models_.fetchRemoteModels(); + return; + } + + auto model = models_.getModel(request.modelID); + if (!model) + return writeError(request, "Model not found"); + + // Model already downloaded? + if (model->isLocal()) { + QJsonObject response{{"modelID", model->id()}}; + return writeResponse(request, response); + } + + // Download new model + QNetworkReply *reply = network_.downloadFile(model->url, QCryptographicHash::Sha256, model->checksum, QVariant::fromValue(request)); + + // downloadFile can return nullptr if it can't open the temp file. In + // that case it will also emit an Network::error(QString) signal which + // we already handle above. + if (!reply) + return; + + // Pass any download progress updates along to the client. + connect(reply, &QNetworkReply::downloadProgress, this, [=](qint64 ist, qint64 max) { + QJsonObject update { + {"read", ist}, + {"size", max}, + {"url", model->url}, + {"id", model->id()} + }; + writeUpdate(request, update); + }); + + // Network::downloadComplete() or Network::error() will trigger the writeResponse or writeError for this request. +} + +void NativeMsgIface::handleRequest(MalformedRequest request) { + writeError(request, std::move(request.error)); +} + +request_variant NativeMsgIface::parseJsonInput(QByteArray input) { + QJsonDocument inputJson = QJsonDocument::fromJson(input); + QJsonObject jsonObj = inputJson.object(); + + // Define what are mandatory and what are optional request keys + static const QStringList mandatoryKeys({"command", "id", "data"}); // Expected in every message + static const QSet commandTypes({"ListModels", "DownloadModel", "Translate"}); + // Json doesn't have schema validation, so validate here, in place: + QString command; + int id; + QJsonObject data; + { + QJsonValueRef idVariant = jsonObj["id"]; + if (idVariant.isNull()) { + return MalformedRequest{-1, "ID field in message cannot be null!"}; + } else { + id = idVariant.toInt(); + } + + QJsonValueRef commandVariant = jsonObj["command"]; + if (commandVariant.isNull()) { + return MalformedRequest{id, "command field in message cannot be null!"}; + } else { + command = commandVariant.toString(); + if (commandTypes.find(command) == commandTypes.end()) { + return MalformedRequest{id, QString("Unrecognised message command: %1 AvailableCommands: %2").arg(command).arg(join(" ", commandTypes))}; + } + } + + QJsonValueRef dataVariant = jsonObj["data"]; + if (dataVariant.isNull()) { + return MalformedRequest{id, "data field in message cannot be null!"}; + } else { + data = dataVariant.toObject(); + } + + } + + if (command == "Translate") { + // Keys expected in a translation request + static const QStringList mandatoryKeysTranslate({"text"}); + static const QStringList optionalKeysTranslate({"html", "quality", "alignments", "src", "trg", "model", "pivot"}); + TranslationRequest ret; + ret.set("id", id); + for (auto&& key : mandatoryKeysTranslate) { + QJsonValueRef val = data[key]; + if (val.isNull()) { + return MalformedRequest{id, QString("data field key %1 cannot be null!").arg(key)}; + } else { + ret.set(key, val); + } + } + for (auto&& key : optionalKeysTranslate) { + QJsonValueRef val = data[key]; + if (!val.isNull()) { + ret.set(key, val); + } + } + if ((!ret.src.isEmpty() && !ret.trg.isEmpty()) == (!ret.model.isEmpty())) { + return MalformedRequest{id, QString("either the data fields src and trg, or the field model has to be specified")}; + } + return ret; + } else if (command == "ListModels") { + // Keys expected in a list requested + static const QStringList optionalKeysList({"includeRemote"}); + ListRequest ret; + ret.id = id; + for (auto&& key : optionalKeysList) { + QJsonValueRef val = data[key]; + if (!val.isNull()) { + ret.includeRemote = val.toBool(); + } + } + return ret; + } else if (command == "DownloadModel") { + // Keys expected in a download request: + static const QStringList mandatoryKeysDownload({"modelID"}); + DownloadRequest ret; + ret.id = id; + for (auto&& key : mandatoryKeysDownload) { + QJsonValueRef val = data[key]; + if (val.isNull()) { + return MalformedRequest{id, QString("data field key %1 cannot be null!").arg(key)}; + } else { + ret.modelID = val.toString(); + } + } + return ret; + } else { + return MalformedRequest{id, QString("Developer error. We shouldn't ever be here! Command: %1").arg(command)}; + } + + return MalformedRequest{id, QString("Developer error. We shouldn't ever be here! This makes the compiler happy though.")}; + +} + +void NativeMsgIface::lockAndWriteJsonHelper(QJsonDocument&& document) { + QByteArray arr = document.toJson(); + std::lock_guard lock(coutmutex_); + size_t outputSize = arr.size(); + std::cout.write(reinterpret_cast(&outputSize), 4); + std::cout.write(arr.data(), outputSize); + std::cout.flush(); +} + +// Fills in the TranslationRequest.{model,pivot} parameters if src + trg are specified. +bool NativeMsgIface::findModels(TranslationRequest &request) const { + if (!request.model.isEmpty()) + return true; + + if (std::optional directModel = models_.getModelForLanguagePair(request.src, request.trg)) { + request.model = directModel->id(); + return true; + } + + if (std::optional pivotModel = models_.getModelPairForLanguagePair(request.src, request.trg)) { + request.model = pivotModel->model.id(); + request.pivot = pivotModel->pivot.id(); + return true; + } + + return false; +} + +bool NativeMsgIface::loadModels(TranslationRequest const &request) { + // First, check if we have everything required already loaded: + if (model_ && std::visit(overloaded { + [&](DirectModelInstance const &instance) { return instance.modelID == request.model && request.pivot.isEmpty(); }, + [&](PivotModelInstance const &instance) { return instance.modelID == request.model && instance.pivotID == request.pivot; } + }, *model_)) + return true; + + if (!request.model.isEmpty() && !request.pivot.isEmpty()) { + auto model = models_.getModel(request.model); + auto pivot = models_.getModel(request.pivot); + + if (!model || !pivot || !model->isLocal() || !pivot->isLocal()) + return false; + + model_ = PivotModelInstance{model->id(), pivot->id(), makeModel(*model), makeModel(*pivot)}; + return true; + } else if (!request.model.isEmpty()) { + auto model = models_.getModel(request.model); + if (!model || !model->isLocal()) + return false; + + model_ = DirectModelInstance{model->id(), makeModel(*model)}; + return true; + } + + return false; // Should not happen, because we called findModels first, right? +} + +std::shared_ptr NativeMsgIface::makeModel(Model const &model) { + // TODO: Maybe cache these shared ptrs? With a weakptr? They might still be around in the + // translation queue even when we switched. No need to load them again. + return std::make_shared( + makeOptions(model.path.toStdString(), settings_.marianSettings()), + settings_.marianSettings().cpu_threads + ); +} + +void NativeMsgIface::processJson(QByteArray input) { + auto myJsonInputVariant = parseJsonInput(input); + std::visit([&](auto&& req){handleRequest(req);}, myJsonInputVariant); +} + +NativeMsgIface::~NativeMsgIface() { + if (iothread_.joinable()) { + iothread_.join(); + } +} diff --git a/src/cli/NativeMsgIface.h b/src/cli/NativeMsgIface.h new file mode 100644 index 00000000..6bad9aa0 --- /dev/null +++ b/src/cli/NativeMsgIface.h @@ -0,0 +1,430 @@ +#pragma once +#include + +#include +#include +#include +#include +#include +#include +#include "inventory/ModelManager.h" +#include "settings/Settings.h" +#include "MarianInterface.h" +#include "Translation.h" +#include "Network.h" +#include +#include + +// If we include the actual header, we break QT compilation. +namespace marian { + namespace bergamot { + class AsyncService; + class TranslationModel; + class Response; + } +} + + +const int constexpr kMaxInputLength = 10*1024*1024; // 10 MB limit on the input length via native messaging + +/** + * Incoming requests all extend Request which contains the client supplied message + * id. This id is used in any reply to this request. See parseJsonInput() for the + * code parsing JSON strings into one of the request structs. See all requests + * that extend this struct below for their JSON format. + * + * Generic request format: + * { + * "id": int + * "command": str + * "data": { + * ... command specific fields + * } + * } + * + * Generic success response: + * { + * "id": int same value as in the request + * "success": true, + * "data": { + * ... command specific fields + * } + * } + * + * Generic error response: + * { + * "id": int same value as in the request + * "success": false + * "error": str error message + * } + * + * Generic update format: + * { + * "id": int same value as in the request + * "update": true, + * "data": { + * ... command specific fields + * } + * } + */ +struct Request { + int id; +}; + +Q_DECLARE_METATYPE(Request); + +/** + * Request: + * { + * "id": int + * "command": "Translate", + * "data": { + * EIHER + * "src": str BCP-47 language code, + * "trg": str BCP-47 language code, + * OR + * "model": str model id, + * "pivot": str model id + * REQUIRED + * "text": str text to translate + * OPTIONAL + * "html": bool the input is HTML + * "quality": bool return quality scores + * "alignments" return token alignments + * } + * } + * + * Success response: + * { + * "id": int, + * "success": true, + * "data": { + * "target": { + * "text": str + * } + * } + * } + */ +struct TranslationRequest : public Request { + QString src; + QString trg; + QString model; + QString pivot; + QString text; + QString command; + bool html{false}; + bool quality{false}; + bool alignments{false}; + + + inline void set(QString key, QJsonValueRef& val) { + if (key == "src") { // String keys + src = val.toString(); + } else if (key == "trg") { + trg = val.toString(); + } else if (key == "model") { + model = val.toString(); + } else if (key == "pivot") { + pivot = val.toString(); + } else if (key == "text") { + text = val.toString(); + } else if (key == "command") { + command = val.toString(); + } else if (key == "id") { // Int keys + id = val.toInt(); + } else if (key == "html") { // Bool keys + html = val.toBool(); + } else if (key == "quality") { + quality = val.toBool(); + } else if (key == "alignments") { + alignments = val.toBool(); + } else { + std::cerr << "Unknown key type. " << key.toStdString() << " Something is very wrong!" << std::endl; + } + } + + // Id may be set differently + inline void set(QString key, int val) { + if (key == "id") { + id = val; + } else { + std::cerr << "Unknown key type. " << key.toStdString() << " Something is very wrong!" << std::endl; + } + } +}; + +Q_DECLARE_METATYPE(TranslationRequest); + +/** + * List of available models. + * + * Request: + * { + * "id": int, + * "command": "ListModels", + * "data": { + * OPTIONAL + * "includeRemote": bool whether to fetch and include models that are available but not already downloaded + * } + * } + * + * Successful response: + * { + * "id": int, + * "success": true, + * "data": [ + * { + * "id": str, + * "shortname": str, + * "modelName": str, + * "local": bool whether the model is already downloaded + * "src": str full name of source language + * "trg": str full name of target language + * "srcTags": { + * [str]: str Map of BCP-47 and full language name of supported source languages + * } + * "trgTag": str BCP-47 tag of target language + * "type": str often "base" or "tiny" + * "repository": str + * } + * ... + * ] + * } + */ +struct ListRequest : Request { + bool includeRemote; +}; + +Q_DECLARE_METATYPE(ListRequest); + +/** + * Request to download a model to the user's machine so it can be used. + * + * Request: + * { + * "id": int, + * "command": "DownloadModel", + * "data": { + * "modelID": str value of `id` field from one of the non-local models returned by the ListModels request. + * } + * } + * + * Successful response: + * { + * "id": int, + * "success": true, + * "data": { + * ... (See ListModels request for model fields) + * } + * } + * + * Download progress update: + * { + * "id": int + * "update": true, + * "data": { + * "id": str model id + * "url": str url of model being downloaded (listed url, not final redirected url) + * "read": int bytes downloaded so far + * "size": int estimated total bytes to download + * } + * } + */ +struct DownloadRequest : Request { + QString modelID; +}; + +Q_DECLARE_METATYPE(DownloadRequest); + +/** + * Internal structure to handle a request that is missing a required field. + */ +struct MalformedRequest : Request { + QString error; +}; + +using request_variant = std::variant; + +/** + * Internal structure to cache a loaded direct model (i.e. no pivoting) + */ +struct DirectModelInstance { + QString modelID; + std::shared_ptr model; +}; + +/** + * Internal structure to cache a loaded indirect model (i.e. needs to pivot) + */ +struct PivotModelInstance { + QString modelID; + QString pivotID; + std::shared_ptr model; + std::shared_ptr pivot; +}; + +/** + * A loaded model + */ +using ModelInstance = std::variant; + +class NativeMsgIface : public QObject { + Q_OBJECT + +public: + explicit NativeMsgIface(QObject * parent=nullptr); + ~NativeMsgIface(); + +public slots: + void run(); + +private slots: + /** + * @brief hooked to emitJson, called for every message that the native client + * receives, parses its json into a Request using `parseJsonInput`, and then + * calls the corresponding `handleRequest` overload. + * @param input char array of json + */ + void processJson(QByteArray input); + +private: + // Threading + std::thread iothread_; + //QEventLoop eventLoop_; + std::mutex coutmutex_; + + // Sadly we don't have C++20 on ubuntu 18.04, otherwise could use std::atomic::wait + std::atomic operations_; // Keeps track of all operations. So that we know when to quit + std::mutex pendingOpsMutex_; + std::condition_variable pendingOpsCV_; + + // Marian shared ptr. We should be using a unique ptr but including the actual header breaks QT compilation. Sue me. + std::shared_ptr service_; + + // TranslateLocally bits + Settings settings_; + Network network_; + ModelManager models_; + QMap>> modelMap_; + + std::optional model_; + + // Methods + request_variant parseJsonInput(QByteArray bytes); + QByteArray converTranslationTo(marian::bergamot::Response&& response, int myID); + + /** + * @brief This function tries its best to identify an appropriate model for + * the target language/languages. The id of the found model (and possibly + * pivot model) will be filled in in the `request` and the function will + * return `true`. + * @param TranslationRequest request + * @return whether we succeeded or not. + */ + bool findModels(TranslationRequest &request) const; + + /** + * @brief Loads the models specified in the request. Assumes `request.model` + * and possibly `request.pivot` are filled in. + * @param TranslationRequest request with `model` (and optionally `pivot`) + * filled in. + * @return Returns false if any of the necessary models is either not found + * or not downloaded. + */ + bool loadModels(TranslationRequest const &request); + + /** + * @brief instantiates a model that will work with the service. + * @returns model instance. + */ + std::shared_ptr makeModel(Model const &model); + + /** + * @brief lockAndWriteJsonHelper This function locks input stream and then writes the size and a + * json message after. It would be called in many places so it + * makes sense to put the common bits here to avoid code duplication. + * @param json QJsonDocument that will be stringified and written to stdout in a blocking fashion. + */ + void lockAndWriteJsonHelper(QJsonDocument&& json); + + template // T can be QJsonValue, QJsonArray or QJsonObject + void writeResponse(Request const &request, T &&data) { + // Decrement pending operation count + operations_--; + pendingOpsCV_.notify_one(); + + QJsonObject response = { + {"success", true}, + {"id", request.id}, + {"data", std::move(data)} + }; + lockAndWriteJsonHelper(QJsonDocument(std::move(response))); + } + + template + void writeUpdate(Request const &request, T &&data) { + QJsonObject response = { + {"update", true}, + {"id", request.id}, + {"data", std::move(data)} + }; + lockAndWriteJsonHelper(QJsonDocument(std::move(response))); + } + + void writeError(Request const &request, QString &&err) { + // Only writeResponse or writeError will decrement the counter, and thus + // only one should be called once per request. We can verify this by + // looking at the message ids in request, but that's too much runtime + // checking. I did do it in debug code. + operations_--; + pendingOpsCV_.notify_one(); + + QJsonObject response{ + {"success", false}, + {"error", err} + }; + + // We have request.id == -1 if the error is that the message id could + // not be parsed. + if (request.id >= 0) + response["id"] = request.id; + + lockAndWriteJsonHelper(QJsonDocument(std::move(response))); + } + + /** + * @brief handleRequest handles a request type translationRequest and writes to stdout + * @param myJsonInput translationRequest + */ + void handleRequest(TranslationRequest myJsonInput); + + /** + * @brief handleRequest handles a request type ListRequest and writes to stdout + * @param myJsonInput ListRequest + */ + void handleRequest(ListRequest myJsonInput); + + /** + * @brief handleRequest handles a request type DownloadRequest and writes to stdout + * @param myJsonInput DownloadRequest + */ + void handleRequest(DownloadRequest myJsonInput); + + /** + * @brief handleRequest handles a request type MalformedRequest and writes to stdout + * @param myJsonInput MalformedRequest + */ + void handleRequest(MalformedRequest myJsonInput); + +signals: + /** + * @brief Emitted when input is closed and all the messages have been processed. + */ + void finished(); + + /** + * @brief Internal signal that is emitted from the stdin reading thread whenever a full request message is read. + * @param input QByteArray of the json message + */ + void emitJson(QByteArray input); +}; diff --git a/src/constants.h b/src/constants.h new file mode 100644 index 00000000..bbb0374a --- /dev/null +++ b/src/constants.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace translateLocally { + +const QSet kNativeMessagingClients{ + // Firefox browser extension: https://github.com/jelmervdl/firefox-translations + "{c9cdf885-0431-4eed-8e18-967b1758c951}" +}; + +} diff --git a/src/inventory/ModelManager.cpp b/src/inventory/ModelManager.cpp index 91314f07..8045e14e 100644 --- a/src/inventory/ModelManager.cpp +++ b/src/inventory/ModelManager.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include namespace { /** @@ -45,6 +47,22 @@ namespace { // prefix.section("/", 0, -1)? return prefix.section("/", 0, -2); } + + std::optional findModel(QList const &models, QString src, QString trg) { + std::optional found; + + for (auto &&model : models) { + // Skip models that do not match src & trg + // @TODO deal with 'en' vs 'en-US' + if (!model.srcTags.contains(src) || model.trgTag != trg) + continue; + + if (!found || (found->type != "tiny" && model.type == "tiny")) + found = model; + } + + return found; + } } @@ -86,7 +104,42 @@ bool ModelManager::validateModel(QString path) { return true; } -Model ModelManager::writeModel(QFile *file, QString filename) { +std::optional ModelManager::getModel(QString const &id) const { + for (auto &&model : getInstalledModels()) + if (model.id() == id) + return model; + + for (auto &&model : getRemoteModels()) + if (model.id() == id) + return model; + + return std::nullopt; +} + +std::optional ModelManager::getModelForLanguagePair(QString src, QString trg) const { + // First search the already installed models. + std::optional found(findModel(getInstalledModels(), src, trg)); + + // Did we find an installed model? If not, search the remote models + if (!found) + found = findModel(getRemoteModels(), src, trg); + + return found; +} + +std::optional ModelManager::getModelPairForLanguagePair(QString src, QString trg, QString pivot) const { + std::optional sourceModel = getModelForLanguagePair(src, pivot); + if (!sourceModel) + return std::nullopt; + + std::optional pivotModel = getModelForLanguagePair(pivot, trg); + if (!pivotModel) + return std::nullopt; + + return ModelPair{*sourceModel, *pivotModel}; +} + +std::optional ModelManager::writeModel(QFile *file, QString filename) { // Default value for filename is the basename of the file. if (filename.isEmpty()) filename = QFileInfo(*file).fileName(); @@ -99,18 +152,18 @@ Model ModelManager::writeModel(QFile *file, QString filename) { QTemporaryDir tempDir(configDir_.filePath("extracting-XXXXXXX")); if (!tempDir.isValid()) { emit error(tr("Could not create temporary directory in %1 to extract the model archive to.").arg(configDir_.path())); - return Model{}; + return std::nullopt; } // Try to extract the archive to the temporary directory QStringList extracted; if (!extractTarGz(file, tempDir.path(), extracted)) - return Model{}; + return std::nullopt; // Assert we extracted at least something. if (extracted.isEmpty()) { emit error(tr("Did not extract any files from the model archive.")); - return Model{}; + return std::nullopt; } // Get the common prefix of all files. In the ideal case, it's the same as @@ -118,7 +171,7 @@ Model ModelManager::writeModel(QFile *file, QString filename) { QString prefix = getCommonPrefixPath(extracted); if (prefix.isEmpty()) { emit error(tr("Could not determine prefix path of extracted model.")); - return Model{}; + return std::nullopt; } Q_ASSERT(prefix.startsWith(tempDir.path())); @@ -126,14 +179,14 @@ Model ModelManager::writeModel(QFile *file, QString filename) { // Try determining whether the model is any good before we continue to safe // it to a permanent destination if (!validateModel(prefix)) // validateModel emits its own error() signals (hence validateModel and not isModelValid) - return Model{}; + return std::nullopt; QString newModelDirName = QString("%1-%2").arg(filename.split(".tar.gz")[0]).arg(QDateTime::currentMSecsSinceEpoch() / 1000); QString newModelDirPath = configDir_.absoluteFilePath(newModelDirName); if (!QDir().rename(prefix, newModelDirPath)) { emit error(tr("Could not move extracted model from %1 to %2.").arg(tempDir.path(), newModelDirPath)); - return Model{}; + return std::nullopt; } // Only remove the temp directory if we moved a directory within it. Don't @@ -247,6 +300,7 @@ Model ModelManager::parseModelInfo(QJsonObject& obj, translateLocally::models::L QString{"modelName"}, QString{"src"}, QString{"trg"}, + QString("trgTag"), QString{"type"}, QString("repository"), QString{"checksum"}}; @@ -272,6 +326,26 @@ Model ModelManager::parseModelInfo(QJsonObject& obj, translateLocally::models::L } } + // srcTags keys. It's a json object. Non-critical. + { + auto iter = obj.find(QString("srcTags")); + if (iter != obj.end()) { + model.set("srcTags", iter.value().toObject()); + } + } + + // Fill in srcTags based on model name if it is an old-style model_info.json + { + // split 'eng-ukr-tiny11' into 'eng', 'ukr', and the rest. + auto parts = model.shortName.split('-'); + if (parts.size() > 2) { + if (model.srcTags.isEmpty()) + model.srcTags = {{parts[0], model.src}}; + if (model.trgTag.isEmpty()) + model.trgTag = parts[1]; + } + } + // Critical key. If this key is missing the json is completely invalid and needs to be discarded // it's either the path to the model or the url to its download location auto iter = obj.find(criticalKey); @@ -282,7 +356,6 @@ Model ModelManager::parseModelInfo(QJsonObject& obj, translateLocally::models::L "If the path variable is missing, it is added automatically, so please file a bug report at: https://github.com/XapaJIaMnu/translateLocally/issues").arg(criticalKey)); return Model{}; } - return model; } @@ -440,7 +513,7 @@ bool ModelManager::extractTarGzInCurrentPath(QFile *file, QStringList &files) { return true; } -void ModelManager::fetchRemoteModels() { +void ModelManager::fetchRemoteModels(QVariant extradata) { if (isFetchingRemoteModels()) return; @@ -467,7 +540,7 @@ void ModelManager::fetchRemoteModels() { } if (--(*num_repos) == 0) { // Once we have fetched all repositories, re-enable fetch. isFetchingRemoteModels_ = false; - emit fetchedRemoteModels(); + emit fetchedRemoteModels(extradata); } reply->deleteLater(); @@ -511,12 +584,12 @@ const QList& ModelManager::getUpdatedModels() const { return updatedModels_; } -Model ModelManager::getModelForPath(QString path) const { +std::optional ModelManager::getModelForPath(QString path) const { for (Model const &model : getInstalledModels()) if (model.path == path) return model; - return Model{}; + return std::nullopt; } void ModelManager::updateAvailableModels() { diff --git a/src/inventory/ModelManager.h b/src/inventory/ModelManager.h index 7a7fdff5..82524600 100644 --- a/src/inventory/ModelManager.h +++ b/src/inventory/ModelManager.h @@ -1,10 +1,15 @@ #ifndef MODELMANAGER_H #define MODELMANAGER_H #include +#include #include +#include #include #include #include +#include +#include + #include "Network.h" #include "types.h" #include "settings/Settings.h" @@ -26,51 +31,73 @@ struct Model { QString path; // This is full path to the directory. Only available if the model is local QString src; QString trg; + QMap srcTags; // The second QVariant is a QString. This is done so that we can have direct toJson and fromJson conversion. + QString trgTag; QString type; // Base or tiny - QString repository = "unknown"; // Repository that the model belongs to. If we don't have that information, default to unknown. + QString repository = QObject::tr("unknown"); // Repository that the model belongs to. If we don't have that information, default to unknown. QByteArray checksum; int localversion = -1; int localAPI = -1; int remoteversion = -1; int remoteAPI = -1; - inline void set(QString key, QString val) { - if (key == "shortName") { - shortName = val; - } else if (key == "modelName") { - modelName = val; - } else if (key == "url") { - url = val; - } else if (key == "path") { - path = val; - } else if (key == "src") { - src = val; - } else if (key == "trg") { - trg = val; - } else if (key == "type") { - type = val; - } else if (key == "repository") { - repository = val; - } else if (key == "checksum") { - checksum = QByteArray::fromHex(val.toUtf8()); - } else { - std::cerr << "Unknown key type. " << key.toStdString() << " Something is very wrong!" << std::endl; + template + inline void set(QString key, T val) { + bool parseError = false; + if constexpr (std::is_same_v) { + if (key == "shortName") { + shortName = val; + } else if (key == "modelName") { + modelName = val; + } else if (key == "url") { + url = val; + } else if (key == "path") { + path = val; + } else if (key == "src") { + src = val; + } else if (key == "trg") { + trg = val; + } else if (key == "trgTag") { + trgTag = val; + } else if (key == "type") { + type = val; + } else if (key == "repository") { + repository = val; + } else if (key == "checksum") { + checksum = QByteArray::fromHex(val.toUtf8()); + } else { + parseError = true; + } + } else if constexpr (std::is_same_v) { + if (key == "localversion") { + localversion = val; + } else if (key == "localAPI") { + localAPI = val; + } else if (key == "remoteversion") { + remoteversion = val; + } else if (key == "remoteAPI") { + remoteAPI = val; + } else { + parseError = true; + } + } else if constexpr (std::is_same_v) { + if (key == "srcTags") { + srcTags = val.toVariantMap(); + } else { + parseError = true; + } } - } - inline void set(QString key, int val) { - if (key == "localversion") { - localversion = val; - } else if (key == "localAPI") { - localAPI = val; - } else if (key == "remoteversion") { - remoteversion = val; - } else if (key == "remoteAPI") { - remoteAPI = val; - } else { + if (parseError) { std::cerr << "Unknown key type. " << key.toStdString() << " Something is very wrong!" << std::endl; } } + inline QString id() const { + // @TODO make this something globally unique (so not just depended on what is in the JSON) + // but also something that stays the same before/after downloading the model. + return QString("%1%2").arg(shortName).arg(qHash(repository)); + } + inline bool isLocal() const { return !path.isEmpty(); } @@ -80,8 +107,7 @@ struct Model { } inline bool isSameModel(Model const &model) const { - // TODO: matching by name might not be very robust - return shortName == model.shortName && repository == model.repository; + return id() == model.id(); } inline bool operator<(const Model& other) const { @@ -106,15 +132,60 @@ struct Model { " type: " << type.toStdString() << " localversion " << localversion << " localAPI " << localAPI << " remoteversion: " << remoteversion << " remoteAPI " << remoteAPI << std::endl; } + /** + * @brief toJson Returns a json representation of the model. The only difference between the struct is that url and path will not be part of the json. + * Instead, we will have one bool that says "Is it local, or is it remote". We also don't report checksums and API versions as those + * should be handled by the backend. + * @return Json representation of a model + */ + QJsonObject toJson() const { + QJsonObject ret; + ret["id"] = id(); + ret["shortname"] = shortName; + ret["modelName"] = modelName; + ret["local"] = isLocal(); + ret["src"] = src; + ret["trg"] = trg; + ret["srcTags"] = QJsonObject::fromVariantMap(srcTags); + ret["trgTag"] = trgTag; + ret["type"] = type; + ret["repository"] = repository; + return ret; + } }; Q_DECLARE_METATYPE(Model) +/** + * @Brief model pair for src -> pivot -> trg translation. + */ +struct ModelPair { + Model model; + Model pivot; +}; + +Q_DECLARE_METATYPE(ModelPair) + class ModelManager : public QAbstractTableModel { Q_OBJECT public: ModelManager(QObject *parent, Settings *settings); + /** + * @Brief get model by its id + */ + std::optional getModel(QString const &id) const; + + /** + * @Brief find model to translate directly from src to trg language. + */ + std::optional getModelForLanguagePair(QString src, QString trg) const; + + /** + * @Brief find model to translate via pivot from src to trg language. + */ + std::optional getModelPairForLanguagePair(QString src, QString trg, QString pivot = QString("en")) const; + /** * @Brief extract a model into the directory of models managed by this * program. The optional filename argument is used to make up a folder name @@ -123,7 +194,7 @@ class ModelManager : public QAbstractTableModel { * and the function will return the filled in model instance. On failure, an * empty Model object is returned (i.e. model.isLocal() returns false). */ - Model writeModel(QFile *file, QString filename = QString()); + std::optional writeModel(QFile *file, QString filename = QString()); /** * @Brief Tries to delete a model from the getInstalledModels() list. Also @@ -142,7 +213,7 @@ class ModelManager : public QAbstractTableModel { * Useful for checking whether a model for which you've saved the path * is still available. */ - Model getModelForPath(QString path) const; + std::optional getModelForPath(QString path) const; /** * @Brief list of locally available models @@ -200,8 +271,10 @@ public slots: * models causes updates on the outdated() status of local models. * By default, it fetches models from the official translateLocally repo, but can also fetch * models from a 3rd party repository. + * + * @param extradata Optional argument that is indended if we want to pass extra data to the slot */ - void fetchRemoteModels(); + void fetchRemoteModels(QVariant extradata = QVariant()); private: void startupLoad(); @@ -248,7 +321,7 @@ public slots: signals: void fetchingRemoteModels(); - void fetchedRemoteModels(); // when finished fetching (might be error) + void fetchedRemoteModels(QVariant extradata = QVariant()); // when finished fetching (might be error) void localModelsChanged(); void error(QString); }; diff --git a/src/main.cpp b/src/main.cpp index d525b2a3..27971a89 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,8 +4,10 @@ #include "Translation.h" #include -#include "CLIParsing.h" -#include "CommandLineIface.h" +#include +#include "cli/CLIParsing.h" +#include "cli/CommandLineIface.h" +#include "cli/NativeMsgIface.h" int main(int argc, char *argv[]) { @@ -31,8 +33,19 @@ int main(int argc, char *argv[]) translateLocally::CLIArgumentInit(translateLocally, parser); // Launch application unless we're supposed to be in CLI mode - if (translateLocally::isCLIOnly(parser)) { - return CommandLineIface().run(parser); // Also takes care of exit codes + translateLocally::AppType runtime = translateLocally::runType(parser); + switch (runtime) { + case translateLocally::AppType::CLI: + return CommandLineIface().run(parser); + case translateLocally::AppType::NativeMsg: + { + NativeMsgIface * nativeMSG = new NativeMsgIface(&translateLocally); + QObject::connect(nativeMSG, &NativeMsgIface::finished, &translateLocally, &QCoreApplication::quit); + QTimer::singleShot(0, nativeMSG, &NativeMsgIface::run); + return translateLocally.exec(); + } + case translateLocally::AppType::GUI: + break; //Handled later outside this scope. } } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 308cfac0..064f6ef9 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -17,8 +17,10 @@ #include #include #include +#include #include #include "Translation.h" +#include "constants.h" #include "logo/logo_svg.h" #include #include @@ -66,14 +68,21 @@ MainWindow::MainWindow(QWidget *parent) updateLocalModels(); // If we have preferred model, but it no longer exists on disk, reset it to empty - if (!settings_.translationModel().isEmpty() && !models_.getModelForPath(settings_.translationModel()).isLocal()) - settings_.translationModel.setValue(""); + if (!settings_.translationModel().isEmpty()) { + auto model = models_.getModelForPath(settings_.translationModel()); + if (!model || !model->isLocal()) + settings_.translationModel.setValue(""); + } // If no model is preferred, load the first available one. if (settings_.translationModel().isEmpty() && !models_.getInstalledModels().empty()) settings_.translationModel.setValue(models_.getInstalledModels().at(0).path); // Attach slots + + // As soon as we've started up, try to register this application as a native messaging host + connect(this, &MainWindow::launched, this, &MainWindow::registerNativeMessagingAppManifest); + connect(&models_, &ModelManager::error, this, &MainWindow::popupError); // All errors from the model class will be propagated to the GUI // Update when new models are discovered connect(&models_, &ModelManager::localModelsChanged, this, &MainWindow::updateLocalModels); @@ -238,6 +247,8 @@ MainWindow::MainWindow(QWidget *parent) if (settings_.syncScrolling()) ::copyScrollPosition(ui_->inputBox, ui_->outputBox); }, Qt::QueuedConnection); + + emit launched(); } void MainWindow::showEvent(QShowEvent *ev) { @@ -293,9 +304,9 @@ void MainWindow::showDownloadPane(bool visible) } void MainWindow::handleDownload(QFile *file, QString filename) { - Model model = models_.writeModel(file, filename); - if (model.isLocal()) // if writeModel fails, model will be empty (and not local) - settings_.translationModel.setValue(model.path, Setting::AlwaysEmit); + auto model = models_.writeModel(file, filename); + if (model) // if writeModel didn't fail + settings_.translationModel.setValue(model->path, Setting::AlwaysEmit); } void MainWindow::downloadProgress(qint64 ist, qint64 max) { @@ -505,3 +516,46 @@ void MainWindow::on_outputBox_cursorPositionChanged() { alignmentWorker_->query(Translation(), Translation::translation_to_source, 0, 0); } } + +bool MainWindow::registerNativeMessagingAppManifest() { + using translateLocally::kNativeMessagingClients; + + // See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_manifests + // Intentionally lower case to avoid any issues/confusion with case-sensitive filesystems + QString name = "translatelocally"; + + QJsonDocument manifest({ + {"name", name}, + {"description", "Fast and secure translation on your local machine, powered by marian and Bergamot."}, + {"type", "stdio"}, + {"path", QCoreApplication::applicationFilePath()}, + {"allowed_extensions", QJsonArray::fromStringList(kNativeMessagingClients.values())} + }); + +#if defined(Q_OS_MACOS) + QString manifestPath = QString("%1/Mozilla/NativeMessagingHosts/%2.json").arg(QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation)).arg(name); +#elif defined (Q_OS_LINUX) + QString manifestPath = QString("%1/.mozilla/native-messaging-hosts/%2.json").arg(QStandardPaths::writableLocation(QStandardPaths::HomeLocation)).arg(name); +#elif defined (Q_OS_WIN) + // On Windows, we write the manifest to some safe directory, and then point to it from the Registry. + QString manifestPath = QString("%1/%2.json").arg(QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation)).arg(name); + QSettings registry(QSettings::NativeFormat, QSettings::UserScope, "Mozilla", "NativeMessagingHosts"); + registry.setValue(QString("%1/Default").arg(name), manifestPath); +#else + return false; +#endif + + QFileInfo manifestInfo(manifestPath); + + if (!manifestInfo.dir().exists()) { + if (!QDir().mkpath(manifestInfo.absolutePath())) { + qDebug() << "Cannot create directory:" << manifestInfo.absolutePath(); + return false; + } + } + + QFile manifestFile(manifestPath); + manifestFile.open(QFile::WriteOnly); + manifestFile.write(manifest.toJson()); + return true; +} diff --git a/src/mainwindow.h b/src/mainwindow.h index 39d8f5a5..19ee5c63 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -34,6 +34,10 @@ class MainWindow : public QMainWindow }; Q_ENUM(Action); +signals: + // Emitted at the end of MainWindow's initialisation + void launched(); + public slots: @@ -70,6 +74,8 @@ private slots: void updateSelectedModel(); + bool registerNativeMessagingAppManifest(); + protected: // We use this for first run dialog void showEvent(QShowEvent *ev); #if QT_VERSION < QT_VERSION_CHECK(5, 10, 0)