From 26ce10fe630251b7c055cd681a4e596e55346a12 Mon Sep 17 00:00:00 2001 From: ArnauvGilotra Date: Tue, 11 Aug 2026 04:37:52 -0400 Subject: [PATCH 1/4] Move dictionary lookup into a resident service process The dictionary was parsed on every switch into the IME. Introduce canto-dictd.exe: a per-user, windowless, resident process that loads the dictionary once and answers candidate-resolution requests over a per-user named pipe. The IME becomes a thin client. - CandidateEngine: the romanization -> candidate segmentation logic, factored out of the text service so it runs where the dictionary lives. - DictProtocol: a small message-framed pipe protocol (SETDICT / RESOLVE / PING) shared by client and service. - DictService: loads the user's dictionary at startup and serves lookups; single instance per user via a named mutex. - DictClient: connects (spawning the service if absent), pushes the dictionary selection, resolves buffers. Falls back to an in-process load if the service is unreachable, so the IME never breaks. - TextService no longer loads/holds the dictionary; BuildCandidates asks the client. Async-load plumbing removed. - Installer ships the service, starts it at each user's logon (HKLM Run), and stops it on uninstall. Loading now happens once per logon session (at startup or first use), not per activation. --- .github/workflows/build.yml | 1 + CMakeLists.txt | 22 ++++ installer/setup.iss | 13 +++ src/CandidateEngine.cpp | 120 +++++++++++++++++++ src/CandidateEngine.h | 28 +++++ src/DictClient.cpp | 165 +++++++++++++++++++++++++++ src/DictClient.h | 60 ++++++++++ src/DictProtocol.h | 222 ++++++++++++++++++++++++++++++++++++ src/DictService.cpp | 205 +++++++++++++++++++++++++++++++++ src/TextService.cpp | 178 +++-------------------------- src/TextService.h | 22 ++-- 11 files changed, 859 insertions(+), 177 deletions(-) create mode 100644 src/CandidateEngine.cpp create mode 100644 src/CandidateEngine.h create mode 100644 src/DictClient.cpp create mode 100644 src/DictClient.h create mode 100644 src/DictProtocol.h create mode 100644 src/DictService.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 799880c..02c4e1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,6 +48,7 @@ jobs: build\Release\CantoIME-SG.dll, ` build\Release\traditional.txt, ` build\Release\simplified.txt, ` + build\Release\canto-dictd.exe, ` installer\Output\CantoIME-Setup.exe dist\ - name: Upload artifact diff --git a/CMakeLists.txt b/CMakeLists.txt index b454fe3..30019ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,8 @@ endif() set(CANTO_SOURCES src/Globals.cpp src/Dictionary.cpp + src/CandidateEngine.cpp + src/DictClient.cpp src/CandidateWindow.cpp src/TextService.cpp src/LangBarButton.cpp @@ -131,3 +133,23 @@ add_canto_variant(TW) # Taiwan (Traditional) add_canto_variant(MO) # Macao SAR (Traditional) add_canto_variant(CN) # China (Simplified) add_canto_variant(SG) # Singapore (Simplified) + +# The dictionary service (canto-dictd.exe): a per-user, windowless resident +# process that loads the dictionary once and answers candidate-resolution +# requests from the IME over a named pipe. WIN32 => no console window. Compiled +# with a default variant define (only affects the stock-dictionary default before +# the user has chosen one; the IME's SETDICT is authoritative). Built into the +# same Release folder so the installer and CI staging can pick it up. +add_executable(canto-dictd WIN32 + src/DictService.cpp + src/Dictionary.cpp + src/CandidateEngine.cpp + src/Settings.cpp) +set_target_properties(canto-dictd PROPERTIES OUTPUT_NAME "canto-dictd") +target_include_directories(canto-dictd PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") +target_compile_definitions(canto-dictd PRIVATE + UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX CANTO_VARIANT_HK) +target_link_libraries(canto-dictd PRIVATE advapi32) +if(MSVC) + target_compile_options(canto-dictd PRIVATE /W4 /permissive-) +endif() diff --git a/installer/setup.iss b/installer/setup.iss index a44ad4e..4f1e4f3 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -57,11 +57,21 @@ Source: "..\build\Release\CantoIME-SG.dll"; DestDir: "{app}"; Check: IsVariant(' ; reads; simplified.txt ships for future use. Removed with the app on uninstall. Source: "..\traditional.txt"; DestDir: "{app}"; Flags: ignoreversion Source: "..\simplified.txt"; DestDir: "{app}"; Flags: ignoreversion +; The dictionary service. A per-user resident process that loads the dictionary +; once and answers the IME's lookup requests over a named pipe, so switching into +; the IME does not re-parse the dictionary. Removed with the app on uninstall. +Source: "..\build\Release\canto-dictd.exe"; DestDir: "{app}"; Flags: ignoreversion 64bit restartreplace uninsrestartdelete [Registry] ; Clean up the per-user options key on uninstall (not created at install time; ; the IME writes it when the user changes options). Root: HKCU; Subkey: "Software\CantoIME"; Flags: uninsdeletekey dontcreatekey +; Start the dictionary service at each user's logon (running as that user, so it +; reads their settings), so the dictionary is already loaded before the first +; switch into the IME. If it is not yet running, the IME also starts it on demand. +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \ + ValueName: "CantoIME Dictionary Service"; ValueData: """{app}\canto-dictd.exe"""; \ + Flags: uninsdeletevalue [Run] ; Register the selected variant's TSF text service via the 64-bit regsvr32. @@ -82,6 +92,9 @@ Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-SG.dll"""; \ Flags: runhidden waituntilterminated [UninstallRun] +; Stop the resident dictionary service first, so its executable can be removed. +Filename: "{sys}\taskkill.exe"; Parameters: "/f /im canto-dictd.exe"; \ + Flags: runhidden; RunOnceId: "StopDictd" ; Unregister BEFORE files are removed. Only the variant DLL actually present in ; {app} gets unregistered (exactly the one that was installed). Filename: "{sys}\regsvr32.exe"; Parameters: "/u /s ""{app}\CantoIME-HK.dll"""; \ diff --git a/src/CandidateEngine.cpp b/src/CandidateEngine.cpp new file mode 100644 index 0000000..706b4d8 --- /dev/null +++ b/src/CandidateEngine.cpp @@ -0,0 +1,120 @@ +// CandidateEngine.cpp +// +// Extracted verbatim from the former CTextService::BuildCandidates. See the long +// comment below for the segmentation/ordering rules. + +#include "CandidateEngine.h" +#include "Dictionary.h" + +#include +#include +#include + +static const size_t kMaxCandidates = 300; +static const size_t kMaxPrefixKeys = 200; + +// The buffer is split into a run of leading exact keys (each locked to its best +// character) followed by a non-empty final segment that is prefix-matched. Every +// such split is enumerated, then ordered by its per-key consumption sequence +// (leading key lengths, then the final segment's length) compared largest-first. +// So for "abcdefg" with keys abc/def/g/abcd/efg, "abcd efg" (lengths 4,3) beats +// "abc def g" (lengths 3,3,1) because its first key is longer, not because it +// uses fewer keys. The 0-leading-key split is the whole buffer prefix-matched: +// its first key consumes all n characters, the maximum possible, so a real key +// spanning the buffer still ranks first, e.g. "hah" surfaces 哈哈 (key "haha") +// before 下下 (a doubling of "ha"). +// +// Each split emits one entry per character of each prefix-matched key, displayed +// as and committed as +// . All splits share one dedup set keyed on the commit string, so the first +// (greediest) segmentation to produce a given Chinese text wins and later +// duplicates drop. +std::vector BuildCandidates(const Dictionary& dict, + const std::wstring& romanization) +{ + std::vector candidates; + if (romanization.empty()) + return candidates; + + const std::wstring& buf = romanization; + const size_t n = buf.size(); + + std::unordered_set seenCommits; + + // Emits candidates for one (locked prefix, open final segment) split. + // Returns false once the candidate cap is reached so callers stop early. + auto emit = [&](const std::wstring& locked, const std::wstring& finalSeg) -> bool + { + std::vector keys = dict.KeysWithPrefix(finalSeg, kMaxPrefixKeys); + for (const std::wstring& key : keys) + { + std::wstring remainder = key.substr(finalSeg.size()); // "" for exact match + for (const std::wstring& ch : dict.Lookup(key)) + { + std::wstring commit = locked + ch; + if (!seenCommits.insert(commit).second) + continue; // already emitted this text via a greedier split + EngineCandidate c; + c.display = commit + remainder; + c.commit = std::move(commit); + candidates.push_back(std::move(c)); + if (candidates.size() >= kMaxCandidates) + return false; + } + } + return true; + }; + + // ---- Enumerate every split: partition a prefix [0, consumed) of the buffer + // into leading exact keys (locked to their best character), leaving a + // non-empty final segment [consumed, n) to prefix-match. `seq` is the split's + // consumption profile: the leading key lengths followed by the final segment + // length (always summing to n). ---- + struct Split { std::wstring locked; size_t consumed; std::vector seq; }; + std::vector splits; + const size_t kMaxSplits = 256; // safety valve against pathological fan-out + + std::function&)> gather = + [&](size_t pos, const std::wstring& locked, const std::vector& lens) + { + if (splits.size() >= kMaxSplits) + return; + std::vector seq = lens; + seq.push_back(n - pos); // the final segment's length + splits.push_back({ locked, pos, std::move(seq) }); // pos < n + + // Extend by one more leading exact key, keeping the final segment + // non-empty (pos + len < n). Lengths are bounded by the longest exact + // key at this position. + size_t maxLen = dict.LongestExactPrefixLen(buf, pos); + for (size_t len = 1; len <= maxLen && pos + len < n; ++len) + { + std::wstring key = buf.substr(pos, len); + const auto& chars = dict.Lookup(key); + if (chars.empty()) + continue; // not an exact key at this length + std::vector nextLens = lens; + nextLens.push_back(len); + gather(pos + len, locked + chars.front(), nextLens); + } + }; + gather(0, std::wstring(), std::vector()); + + // Greediest consumption first: compare the length sequences element by + // element, larger length wins. stable_sort keeps discovery order on ties. + std::stable_sort(splits.begin(), splits.end(), + [](const Split& a, const Split& b) + { + size_t m = std::min(a.seq.size(), b.seq.size()); + for (size_t i = 0; i < m; ++i) + if (a.seq[i] != b.seq[i]) + return a.seq[i] > b.seq[i]; + return a.seq.size() < b.seq.size(); + }); + + for (const Split& s : splits) + if (!emit(s.locked, buf.substr(s.consumed))) + break; + + return candidates; +} diff --git a/src/CandidateEngine.h b/src/CandidateEngine.h new file mode 100644 index 0000000..00b1f34 --- /dev/null +++ b/src/CandidateEngine.h @@ -0,0 +1,28 @@ +// CandidateEngine.h +// +// The pure romanization -> candidate logic, factored out of the text service so +// it can run wherever the dictionary lives: in the dictionary service process +// (canto-dictd) that owns the loaded dictionary, and in the in-process fallback +// the IME uses if the service is unavailable. Depends only on a Dictionary and +// the typed romanization buffer; no TSF or UI state. + +#pragma once + +#include +#include + +class Dictionary; + +// One candidate. `display` is what the candidate window shows (may include a +// trailing romanization remainder, e.g. L"高o"); `commit` is the Chinese text +// inserted when it is accepted (L"高"). +struct EngineCandidate +{ + std::wstring display; + std::wstring commit; +}; + +// Builds the ordered, de-duplicated candidate list for a romanization buffer +// against the given dictionary. Empty buffer -> empty result. +std::vector BuildCandidates(const Dictionary& dict, + const std::wstring& romanization); diff --git a/src/DictClient.cpp b/src/DictClient.cpp new file mode 100644 index 0000000..aeeacc2 --- /dev/null +++ b/src/DictClient.cpp @@ -0,0 +1,165 @@ +// DictClient.cpp + +#include "DictClient.h" +#include "DictProtocol.h" +#include "Globals.h" // GetModuleDirectory + +using namespace dictproto; + +DictClient::~DictClient() +{ + Close(); +} + +void DictClient::Close() +{ + if (m_pipe != INVALID_HANDLE_VALUE) + { + CloseHandle(m_pipe); + m_pipe = INVALID_HANDLE_VALUE; + } +} + +void DictClient::SpawnService() +{ + std::wstring dir = GetModuleDirectory(); // the DLL's directory (has the exe) + if (dir.empty()) + return; + std::wstring exe = dir + L"canto-dictd.exe"; + + std::wstring cmd = L"\"" + exe + L"\""; + std::vector cmdBuf(cmd.begin(), cmd.end()); + cmdBuf.push_back(L'\0'); + + STARTUPINFOW si = { sizeof(si) }; + PROCESS_INFORMATION pi = {}; + if (CreateProcessW(exe.c_str(), cmdBuf.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) + { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } +} + +bool DictClient::Connect() +{ + std::wstring name = PipeName(); + HANDLE pipe = CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, 0, nullptr); + if (pipe == INVALID_HANDLE_VALUE) + return false; + + DWORD mode = PIPE_READMODE_MESSAGE; + if (!SetNamedPipeHandleState(pipe, &mode, nullptr, nullptr)) + { + CloseHandle(pipe); + return false; + } + m_pipe = pipe; + + // Push the desired dictionary so the service serves the right one. SETDICT + // blocks until the (re)load finishes, so a subsequent RESOLVE is ready. + if (m_haveDesired) + { + std::vector req; + PutU32(req, OP_SETDICT); + PutWStr(req, m_path); + PutOverrides(req, m_overrides); + + std::vector resp; + if (!WriteMessage(m_pipe, req) || !ReadMessage(m_pipe, resp)) + { + Close(); + return false; + } + } + return true; +} + +bool DictClient::EnsureConnected() +{ + if (m_pipe != INVALID_HANDLE_VALUE) + return true; + if (Connect()) + return true; + + // No service yet: start it and retry connecting while it comes up + loads. + SpawnService(); + for (int i = 0; i < 40; ++i) // up to ~4s + { + if (Connect()) + return true; + Sleep(100); + } + return false; +} + +bool DictClient::Transact(const std::vector& req, std::vector& resp) +{ + for (int attempt = 0; attempt < 2; ++attempt) + { + if (!EnsureConnected()) + return false; + if (WriteMessage(m_pipe, req) && ReadMessage(m_pipe, resp)) + return true; + Close(); // broken pipe: drop it and retry once (service may have died) + } + return false; +} + +void DictClient::SetDictionary(const std::wstring& path, + const std::vector& overrides) +{ + m_path = path; + m_overrides = overrides; + m_haveDesired = true; + + // Force a fresh connection so the new choice is pushed (SETDICT on connect), + // and proactively bring the service up + loading now rather than on the first + // keystroke. + Close(); + EnsureConnected(); +} + +std::vector DictClient::Resolve(const std::wstring& romanization) +{ + std::vector req; + PutU32(req, OP_RESOLVE); + PutWStr(req, romanization); + + std::vector resp; + if (Transact(req, resp)) + { + Reader r(resp); + unsigned status = r.u32(); + if (status == STATUS_OK) + { + std::vector pairs; + if (GetCandidates(r, pairs)) + { + std::vector out; + out.reserve(pairs.size()); + for (Candidate& p : pairs) + out.push_back({ std::move(p.first), std::move(p.second) }); + return out; + } + } + } + return ResolveFallback(romanization); +} + +std::vector DictClient::ResolveFallback(const std::wstring& rom) +{ + if (!m_haveDesired || m_path.empty()) + return {}; + + if (!m_fbLoaded || m_fbPath != m_path || m_fbOverrides != m_overrides) + { + m_fallback.Load(m_path); + m_fallback.ApplyOverrides(m_overrides); + m_fbPath = m_path; + m_fbOverrides = m_overrides; + m_fbLoaded = true; + } + return BuildCandidates(m_fallback, rom); +} diff --git a/src/DictClient.h b/src/DictClient.h new file mode 100644 index 0000000..7836f6e --- /dev/null +++ b/src/DictClient.h @@ -0,0 +1,60 @@ +// DictClient.h +// +// The IME side of the dictionary service. Connects to canto-dictd over the +// per-user named pipe, tells it which dictionary to serve, and resolves +// romanization buffers to candidates. If the service is unreachable it spawns +// it; if it still cannot be reached, it falls back to loading the dictionary +// in-process so the IME keeps working (just without the shared-load speedup). + +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include +#include +#include + +#include "CandidateEngine.h" // EngineCandidate +#include "Dictionary.h" // in-process fallback + +class DictClient +{ +public: + using Override = std::pair>; + + DictClient() = default; + ~DictClient(); + + DictClient(const DictClient&) = delete; + DictClient& operator=(const DictClient&) = delete; + + // Selects the dictionary + overrides to serve (idempotent). Spawns the + // service if needed and pushes the choice to it. Best-effort. + void SetDictionary(const std::wstring& path, const std::vector& overrides); + + // Resolves a romanization buffer to candidates via the service, falling back + // to an in-process dictionary on any failure. + std::vector Resolve(const std::wstring& romanization); + +private: + HANDLE m_pipe = INVALID_HANDLE_VALUE; + std::wstring m_path; // desired dictionary path + std::vector m_overrides; // desired overrides + bool m_haveDesired = false; + + // In-process fallback, loaded lazily only when the service is unavailable. + Dictionary m_fallback; + std::wstring m_fbPath; + std::vector m_fbOverrides; + bool m_fbLoaded = false; + + void Close(); + bool Connect(); // open + configure the pipe, then send SETDICT + bool EnsureConnected(); // connect, spawning the service if the pipe is absent + void SpawnService(); + bool Transact(const std::vector& req, std::vector& resp); + std::vector ResolveFallback(const std::wstring& rom); +}; diff --git a/src/DictProtocol.h b/src/DictProtocol.h new file mode 100644 index 0000000..7e1936a --- /dev/null +++ b/src/DictProtocol.h @@ -0,0 +1,222 @@ +// DictProtocol.h +// +// The tiny request/response protocol spoken between the IME (client) and the +// dictionary service (canto-dictd). One message-mode named pipe per user; each +// message is length-framed by the pipe itself. Header-only so both the DLLs and +// the service share one definition. +// +// Requests: [u32 op][payload] +// Responses: [u32 status][payload] +// +// OP_SETDICT payload = path:wstr, overrides:list<(key:wstr, cands:list)> +// Loads that dictionary + overrides (a no-op if already current). +// Response: status only. +// OP_RESOLVE payload = romanization:wstr +// Response: status, then candidates:list<(display:wstr, commit:wstr)> +// OP_PING Response: status (STATUS_OK once a dictionary is loaded). + +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include // ConvertSidToStringSidW + +#include +#include +#include +#include + +namespace dictproto { + +enum Op : unsigned { OP_SETDICT = 1, OP_RESOLVE = 2, OP_PING = 3 }; +enum Status : unsigned { STATUS_OK = 0, STATUS_NOTLOADED = 1, STATUS_ERR = 2 }; + +using Override = std::pair>; +using Candidate = std::pair; // (display, commit) + +// Largest single message we will accept (guards against a corrupt length). +static const size_t kMaxMessage = 32u * 1024u * 1024u; + +// ---- Serialization into a byte buffer --------------------------------------- + +inline void PutU32(std::vector& b, unsigned v) +{ + b.insert(b.end(), reinterpret_cast(&v), reinterpret_cast(&v) + 4); +} + +inline void PutWStr(std::vector& b, const std::wstring& s) +{ + PutU32(b, static_cast(s.size())); + if (!s.empty()) + b.insert(b.end(), reinterpret_cast(s.data()), + reinterpret_cast(s.data()) + s.size() * sizeof(wchar_t)); +} + +// ---- Deserialization with bounds checks ------------------------------------- + +struct Reader +{ + const char* p = nullptr; + size_t n = 0; + size_t pos = 0; + bool bad = false; + + Reader(const std::vector& b) : p(b.data()), n(b.size()) {} + + bool need(size_t k) { if (bad || pos + k > n) { bad = true; return false; } return true; } + + unsigned u32() + { + if (!need(4)) return 0u; + unsigned v; std::memcpy(&v, p + pos, 4); pos += 4; return v; + } + + std::wstring wstr() + { + unsigned len = u32(); + if (bad || !need(static_cast(len) * sizeof(wchar_t))) return std::wstring(); + std::wstring s(len, L'\0'); + if (len) std::memcpy(&s[0], p + pos, static_cast(len) * sizeof(wchar_t)); + pos += static_cast(len) * sizeof(wchar_t); + return s; + } +}; + +inline void PutOverrides(std::vector& b, const std::vector& ovr) +{ + PutU32(b, static_cast(ovr.size())); + for (const Override& o : ovr) + { + PutWStr(b, o.first); + PutU32(b, static_cast(o.second.size())); + for (const std::wstring& c : o.second) + PutWStr(b, c); + } +} + +// Returns false on a malformed buffer. +inline bool GetOverrides(Reader& r, std::vector& out) +{ + unsigned count = r.u32(); + if (r.bad || count > 1000000u) return false; + out.clear(); + out.reserve(count); + for (unsigned i = 0; i < count && !r.bad; ++i) + { + std::wstring key = r.wstr(); + unsigned cc = r.u32(); + if (r.bad || cc > 100000u) return false; + std::vector cands; + cands.reserve(cc); + for (unsigned j = 0; j < cc && !r.bad; ++j) + cands.push_back(r.wstr()); + out.emplace_back(std::move(key), std::move(cands)); + } + return !r.bad; +} + +inline void PutCandidates(std::vector& b, const std::vector& cands) +{ + PutU32(b, static_cast(cands.size())); + for (const Candidate& c : cands) + { + PutWStr(b, c.first); + PutWStr(b, c.second); + } +} + +inline bool GetCandidates(Reader& r, std::vector& out) +{ + unsigned count = r.u32(); + if (r.bad || count > 1000000u) return false; + out.clear(); + out.reserve(count); + for (unsigned i = 0; i < count && !r.bad; ++i) + { + std::wstring display = r.wstr(); + std::wstring commit = r.wstr(); + out.emplace_back(std::move(display), std::move(commit)); + } + return !r.bad; +} + +// ---- Per-user pipe name ------------------------------------------------------ + +// A per-user pipe name so different users on one machine do not collide. Falls +// back to a fixed name if the SID cannot be read. +inline std::wstring PipeName() +{ + std::wstring sid; + HANDLE tok = nullptr; + if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) + { + DWORD len = 0; + GetTokenInformation(tok, TokenUser, nullptr, 0, &len); + if (len) + { + std::vector buf(len); + if (GetTokenInformation(tok, TokenUser, buf.data(), len, &len)) + { + TOKEN_USER* tu = reinterpret_cast(buf.data()); + LPWSTR s = nullptr; + if (ConvertSidToStringSidW(tu->User.Sid, &s)) + { + sid = s; + LocalFree(s); + } + } + } + CloseHandle(tok); + } + if (sid.empty()) + sid = L"default"; + return L"\\\\.\\pipe\\CantoIME.dict." + sid; +} + +// ---- Whole-message pipe I/O (message-mode pipe) ----------------------------- + +// Writes one framed message. Returns false on failure. +inline bool WriteMessage(HANDLE pipe, const std::vector& msg) +{ + DWORD written = 0; + const char* p = msg.data(); + DWORD total = static_cast(msg.size()); + // In message mode a single WriteFile is one message; loop only for partial + // writes (rare) to be safe. + DWORD off = 0; + while (off < total) + { + if (!WriteFile(pipe, p + off, total - off, &written, nullptr) || written == 0) + return false; + off += written; + } + return true; +} + +// Reads one framed message (message mode: assembles across ERROR_MORE_DATA). +// Returns false on failure or an over-long message. +inline bool ReadMessage(HANDLE pipe, std::vector& out) +{ + out.clear(); + char chunk[8192]; + for (;;) + { + DWORD read = 0; + BOOL ok = ReadFile(pipe, chunk, sizeof(chunk), &read, nullptr); + if (read) + { + if (out.size() + read > kMaxMessage) + return false; + out.insert(out.end(), chunk, chunk + read); + } + if (ok) + return true; // full message delivered + if (GetLastError() != ERROR_MORE_DATA) + return false; // real error or closed + // else: more of this message remains; keep reading + } +} + +} // namespace dictproto diff --git a/src/DictService.cpp b/src/DictService.cpp new file mode 100644 index 0000000..90e60ab --- /dev/null +++ b/src/DictService.cpp @@ -0,0 +1,205 @@ +// DictService.cpp -> canto-dictd.exe +// +// The CantoIME dictionary service. A per-user, resident, windowless process that +// loads the dictionary once and answers candidate-resolution requests over a +// named pipe (see DictProtocol.h). The IME (each DLL instance) is a client, so +// the multi-megabyte dictionary is parsed once per logon session instead of on +// every switch into the IME. +// +// Lifetime: single instance per user (named mutex). Started at logon (installer +// Run entry) and/or on demand by the IME. Loads the user's currently-selected +// dictionary at startup; a client's SETDICT is authoritative and corrects it if +// the user's variant/choice differs. + +#include "Dictionary.h" +#include "CandidateEngine.h" +#include "Settings.h" +#include "Globals.h" // CANTO_DICT_FILENAME / CANTO_DICT_SIMPLIFIED +#include "DictProtocol.h" + +#include +#include +#include +#include + +using namespace dictproto; + +// The one loaded dictionary, guarded for concurrent clients. RESOLVE reads it; +// SETDICT (and the startup preload) rebuild it. +static std::mutex g_mutex; +static Dictionary g_dict; +static std::wstring g_loadedPath; +static std::vector g_loadedOverrides; + +static std::wstring ExeDir() +{ + wchar_t path[MAX_PATH] = { 0 }; + DWORD n = GetModuleFileNameW(nullptr, path, MAX_PATH); + if (n == 0 || n >= MAX_PATH) + return std::wstring(); + std::wstring d(path); + size_t slash = d.find_last_of(L"\\/"); + return slash == std::wstring::npos ? std::wstring() : d.substr(0, slash + 1); +} + +static std::wstring ExpandEnv(const std::wstring& s) +{ + wchar_t buf[MAX_PATH]; + DWORD n = ExpandEnvironmentStringsW(s.c_str(), buf, MAX_PATH); + return (n != 0 && n <= MAX_PATH) ? std::wstring(buf) : s; +} + +// Resolves the dictionary path from settings the same way the IME does: a custom +// path (env-expanded), or the stock file next to this executable (which the +// installer places alongside the DLLs and dictionaries). +static std::wstring ResolvePathFromSettings(const CantoSettings& s) +{ + if (s.dictSource == DictSource::Custom) + return ExpandEnv(s.dictionaryPath); + return ExeDir() + (s.dictSource == DictSource::StockSimplified + ? CANTO_DICT_SIMPLIFIED : CANTO_DICT_FILENAME); +} + +// (Re)loads the dictionary if the (path, overrides) differ from what is loaded. +static void LoadDictionary(const std::wstring& path, const std::vector& overrides) +{ + std::lock_guard lock(g_mutex); + if (path == g_loadedPath && overrides == g_loadedOverrides && g_dict.IsLoaded()) + return; + g_dict.Load(path); + g_dict.ApplyOverrides(overrides); + g_loadedPath = path; + g_loadedOverrides = overrides; +} + +// Best-effort preload from the current user's settings, so the dictionary is hot +// before the first switch into the IME. +static void PreloadFromSettings() +{ + CantoSettings s = LoadSettings(); + std::wstring path = ResolvePathFromSettings(s); + std::vector overrides = + LoadOverrides(DictionaryIdentity(s.dictSource, s.dictionaryPath)); + LoadDictionary(path, overrides); +} + +// ---- Request handling ------------------------------------------------------- + +static std::vector HandleSetDict(Reader& r) +{ + std::wstring path = r.wstr(); + std::vector overrides; + std::vector resp; + if (!GetOverrides(r, overrides)) + { + PutU32(resp, STATUS_ERR); + return resp; + } + LoadDictionary(path, overrides); + PutU32(resp, STATUS_OK); + return resp; +} + +static std::vector HandleResolve(Reader& r) +{ + std::wstring rom = r.wstr(); + std::vector resp; + if (r.bad) + { + PutU32(resp, STATUS_ERR); + return resp; + } + + std::vector out; + { + std::lock_guard lock(g_mutex); + if (!g_dict.IsLoaded()) + { + PutU32(resp, STATUS_NOTLOADED); + PutCandidates(resp, out); + return resp; + } + for (const EngineCandidate& c : BuildCandidates(g_dict, rom)) + out.emplace_back(c.display, c.commit); + } + PutU32(resp, STATUS_OK); + PutCandidates(resp, out); + return resp; +} + +static std::vector Dispatch(const std::vector& msg) +{ + Reader r(msg); + unsigned op = r.u32(); + switch (op) + { + case OP_SETDICT: return HandleSetDict(r); + case OP_RESOLVE: return HandleResolve(r); + case OP_PING: + { + std::vector resp; + std::lock_guard lock(g_mutex); + PutU32(resp, g_dict.IsLoaded() ? STATUS_OK : STATUS_NOTLOADED); + return resp; + } + default: + { + std::vector resp; + PutU32(resp, STATUS_ERR); + return resp; + } + } +} + +// Serves one connected client until it disconnects. A client keeps the pipe open +// and sends many requests (one per keystroke), so loop until the read fails. +static void ServeClient(HANDLE pipe) +{ + std::vector msg; + while (ReadMessage(pipe, msg)) + { + std::vector resp = Dispatch(msg); + if (!WriteMessage(pipe, resp)) + break; + } + FlushFileBuffers(pipe); + DisconnectNamedPipe(pipe); + CloseHandle(pipe); +} + +static int RunServer() +{ + std::wstring name = PipeName(); + for (;;) + { + HANDLE pipe = CreateNamedPipeW( + name.c_str(), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, + PIPE_UNLIMITED_INSTANCES, + 64 * 1024, 64 * 1024, 0, nullptr); + if (pipe == INVALID_HANDLE_VALUE) + return 1; // fatal: cannot create the pipe + + BOOL connected = ConnectNamedPipe(pipe, nullptr) + ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED); + if (!connected) + { + CloseHandle(pipe); + continue; + } + std::thread(ServeClient, pipe).detach(); + } +} + +int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) +{ + // One service per user: if we are the second, exit and let the first serve. + std::wstring mutexName = L"Local\\CantoIME.dictd"; + HANDLE mutex = CreateMutexW(nullptr, FALSE, mutexName.c_str()); + if (mutex && GetLastError() == ERROR_ALREADY_EXISTS) + return 0; + + PreloadFromSettings(); // hot dictionary before the first request + return RunServer(); +} diff --git a/src/TextService.cpp b/src/TextService.cpp index 747159c..9a86af4 100644 --- a/src/TextService.cpp +++ b/src/TextService.cpp @@ -51,9 +51,6 @@ CTextService* CTextService::s_kbdOwner = nullptr; // the UI thread. static const UINT WM_CANTO_TOGGLE = WM_APP + 0x101; static const UINT WM_CANTO_OPTIONS = WM_APP + 0x102; // open the options dialog -// A background dictionary load finished. wParam is a heap Dictionary* whose -// ownership passes to the UI thread (see BeginLoadDictionary/OnDictionaryLoaded). -static const UINT WM_CANTO_DICT_READY = WM_APP + 0x103; static const wchar_t* kMsgWndClass = L"CantoIME_" CANTO_VARIANT_TAG L"_MsgWnd"; static bool IsShiftVK(WPARAM vk); // defined with the key-handling helpers below @@ -360,16 +357,6 @@ LRESULT CALLBACK CTextService::MsgWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP self->ApplySettings(); return 0; - case WM_CANTO_DICT_READY: - { - Dictionary* d = reinterpret_cast(wParam); - if (self) - self->OnDictionaryLoaded(d); - else - delete d; - return 0; - } - // Light/dark theme switched: WM_SETTINGCHANGE carries "ImmersiveColorSet". // Re-query the button icons so their glyphs recolor with the OS, the way // the built-in IMEs do. @@ -833,116 +820,23 @@ STDMETHODIMP CTextService::OnCompositionTerminated(TfEditCookie /*ec*/, ITfCompo // Candidate management // --------------------------------------------------------------------------- -// Upper bound on candidates we build for one buffer. Prefixes like "a" can -// match thousands of keys; the window pages through them 9 at a time, but we -// still cap the working set so a broad prefix stays responsive. -static const size_t kMaxCandidates = 300; -static const size_t kMaxPrefixKeys = 200; - -// Builds m_candidates from m_romanization, preferring segmentations that consume -// the buffer greedily: the longest key first, then the longest next key, etc. -// -// The buffer is split into a run of leading exact keys (each locked to its best -// character) followed by a non-empty final segment that is prefix-matched. Every -// such split is enumerated, then ordered by its per-key consumption sequence -// (leading key lengths, then the final segment's length) compared largest-first. -// So for "abcdefg" with keys abc/def/g/abcd/efg, "abcd efg" (lengths 4,3) beats -// "abc def g" (lengths 3,3,1) because its first key is longer, not because it -// uses fewer keys. The 0-leading-key split is the whole buffer prefix-matched: -// its first key consumes all n characters, the maximum possible, so a real key -// spanning the buffer still ranks first, e.g. "hah" surfaces 哈哈 (key "haha") -// before 下下 (a doubling of "ha"). -// -// Each split emits one entry per character of each prefix-matched key, displayed -// as and committed as -// . All splits share one dedup set keyed on the commit string, so the first -// (greediest) segmentation to produce a given Chinese text wins and later -// duplicates drop. +// Builds m_candidates from m_romanization by asking the dictionary service to +// resolve the buffer. The segmentation/ordering logic lives in CandidateEngine +// (shared with the service and the in-process fallback); DictClient returns the +// finished (display, commit) list. void CTextService::BuildCandidates() { m_candidates.clear(); if (m_romanization.empty()) return; - const std::wstring& buf = m_romanization; - const size_t n = buf.size(); - - std::unordered_set seenCommits; - - // Emits candidates for one (locked prefix, open final segment) split. - // Returns false once the candidate cap is reached so callers stop early. - auto emit = [&](const std::wstring& locked, const std::wstring& finalSeg) -> bool - { - std::vector keys = m_dict.KeysWithPrefix(finalSeg, kMaxPrefixKeys); - for (const std::wstring& key : keys) - { - std::wstring remainder = key.substr(finalSeg.size()); // "" for exact match - for (const std::wstring& ch : m_dict.Lookup(key)) - { - std::wstring commit = locked + ch; - if (!seenCommits.insert(commit).second) - continue; // already emitted this text via a greedier split - Candidate c; - c.display = commit + remainder; - c.commit = std::move(commit); - m_candidates.push_back(std::move(c)); - if (m_candidates.size() >= kMaxCandidates) - return false; - } - } - return true; - }; - - // ---- Enumerate every split: partition a prefix [0, consumed) of the buffer - // into leading exact keys (locked to their best character), leaving a - // non-empty final segment [consumed, n) to prefix-match. `seq` is the split's - // consumption profile: the leading key lengths followed by the final segment - // length (always summing to n). ---- - struct Split { std::wstring locked; size_t consumed; std::vector seq; }; - std::vector splits; - const size_t kMaxSplits = 256; // safety valve against pathological fan-out - - std::function&)> gather = - [&](size_t pos, const std::wstring& locked, const std::vector& lens) + for (EngineCandidate& c : m_dictClient.Resolve(m_romanization)) { - if (splits.size() >= kMaxSplits) - return; - std::vector seq = lens; - seq.push_back(n - pos); // the final segment's length - splits.push_back({ locked, pos, std::move(seq) }); // pos < n - - // Extend by one more leading exact key, keeping the final segment - // non-empty (pos + len < n). Lengths are bounded by the longest exact - // key at this position. - size_t maxLen = m_dict.LongestExactPrefixLen(buf, pos); - for (size_t len = 1; len <= maxLen && pos + len < n; ++len) - { - std::wstring key = buf.substr(pos, len); - const auto& chars = m_dict.Lookup(key); - if (chars.empty()) - continue; // not an exact key at this length - std::vector nextLens = lens; - nextLens.push_back(len); - gather(pos + len, locked + chars.front(), nextLens); - } - }; - gather(0, std::wstring(), std::vector()); - - // Greediest consumption first: compare the length sequences element by - // element, larger length wins. stable_sort keeps discovery order on ties. - std::stable_sort(splits.begin(), splits.end(), - [](const Split& a, const Split& b) - { - size_t m = std::min(a.seq.size(), b.seq.size()); - for (size_t i = 0; i < m; ++i) - if (a.seq[i] != b.seq[i]) - return a.seq[i] > b.seq[i]; - return a.seq.size() < b.seq.size(); - }); - - for (const Split& s : splits) - if (!emit(s.locked, buf.substr(s.consumed))) - return; + Candidate cand; + cand.display = std::move(c.display); + cand.commit = std::move(c.commit); + m_candidates.push_back(std::move(cand)); + } } void CTextService::UpdateCandidates(ITfContext* pic) @@ -1522,10 +1416,10 @@ void CTextService::ApplySettings() m_candWnd.SetFontSize(s.fontSize); m_toggleKey = s.toggleKey; - // Reload when the resolved path changed OR the overrides changed, since the - // overrides are merged into the loaded dictionary. Rebuilding from the base - // file is the clean way to apply removals, so overrides funnel through the - // same (background) load path. + // Push the dictionary selection to the service only when the resolved path + // or the overrides changed. The service (a resident process) keeps the + // dictionary loaded across activations, so this does not re-parse anything + // in the common case; SetDictionary is a fast no-op when nothing changed. std::wstring path = ResolveDictionaryPath(); std::vector overrides = LoadOverrides(DictionaryIdentity(s.dictSource, s.dictionaryPath)); @@ -1533,48 +1427,6 @@ void CTextService::ApplySettings() { m_overrides = std::move(overrides); m_dictPathLoaded = path; - BeginLoadDictionary(path); - } -} - -void CTextService::BeginLoadDictionary(const std::wstring& path) -{ - HWND hwnd = m_hMsgWnd; - if (!hwnd) - { - // No message window to hand the result back through: load synchronously. - // This should not happen post-activation (the window is created first). - m_dict.Load(path); - m_dict.ApplyOverrides(m_overrides); - return; - } - - // Parse off the UI thread, then post the finished dictionary back. The - // thread captures only the target window and path (not `this`), so a - // Deactivate that destroys the window mid-load cannot dangle: PostMessage - // simply fails and the orphaned dictionary is freed here. - // Pin the DLL for the lifetime of the worker so it cannot be unloaded out - // from under the thread if the service deactivates mid-load. - DllAddRef(); - Dictionary* d = new Dictionary(); - std::thread([d, path, hwnd]() - { - d->Load(path); - if (!PostMessageW(hwnd, WM_CANTO_DICT_READY, reinterpret_cast(d), 0)) - delete d; - DllRelease(); - }).detach(); -} - -void CTextService::OnDictionaryLoaded(Dictionary* loaded) -{ - // Ignore a stale load whose target was superseded while it was parsing (the - // user switched dictionaries again). Only the load matching the latest - // requested path wins. - if (loaded->Path() == m_dictPathLoaded) - { - m_dict = std::move(*loaded); - m_dict.ApplyOverrides(m_overrides); + m_dictClient.SetDictionary(path, m_overrides); } - delete loaded; } diff --git a/src/TextService.h b/src/TextService.h index c897163..0f01ba4 100644 --- a/src/TextService.h +++ b/src/TextService.h @@ -16,7 +16,7 @@ #pragma once #include "Globals.h" -#include "Dictionary.h" +#include "DictClient.h" // dictionary service client (+ EngineCandidate, fallback) #include "CandidateWindow.h" #include "Settings.h" // ToggleKeyMode @@ -206,26 +206,20 @@ class CTextService final : CLangBarButton* m_pMainButton = nullptr; CLangBarButton* m_pModeButton = nullptr; - Dictionary m_dict; + DictClient m_dictClient; // talks to the resident dictionary service CandidateWindow m_candWnd; // Applies per-user settings (HKCU): candidate font size, and the dictionary - // (reloaded only if the resolved path changed). Called at activation and when - // the IME regains focus, so options-dialog changes take effect promptly. + // (pushed to the service only if the resolved path/overrides changed). Called + // at activation and when the IME regains focus, so options-dialog changes + // take effect promptly. void ApplySettings(); - std::wstring m_dictPathLoaded; // resolved path of the currently-loaded dict + std::wstring m_dictPathLoaded; // resolved path last pushed to the service - // User candidate overrides merged into m_dict after each (re)load. Kept so a - // completed background load can re-apply them, and so ApplySettings can tell - // when they changed (and must trigger a reload). + // User candidate overrides. Kept so ApplySettings can tell when they changed + // (and must push a new dictionary selection to the service). std::vector m_overrides; - // Loads the dictionary on a background thread so switching to CantoIME does - // not block the UI thread. The parsed dictionary is handed back to the UI - // thread via WM_CANTO_DICT_READY (see MsgWndProc / OnDictionaryLoaded). - void BeginLoadDictionary(const std::wstring& path); - void OnDictionaryLoaded(Dictionary* loaded); // UI-thread completion handler - // Active service for the low-level mouse hook (one composition at a time). static HHOOK s_wheelHook; static CTextService* s_wheelOwner; From 0fe864af9d557af1650ab41da1d280ba83678dd8 Mon Sep 17 00:00:00 2001 From: ArnauvGilotra Date: Tue, 11 Aug 2026 04:55:17 -0400 Subject: [PATCH 2/4] Gate the dictionary service on the keyboard being added The service should run only for users who have added CantoIME, not for everyone at logon. - Drop the unconditional HKLM logon autostart. - The IME registers a per-user logon autostart (HKCU Run) when it activates, i.e. when the keyboard has been added and used. Users who never add CantoIME never get it, so the service never starts for them. The IME also starts the service on demand at activation. - On update, the installer restarts the service only if that per-user marker already exists (keyboard added before), running it as the original user; a fresh install does not start it. - The installer stops any running service before copying files so an update swaps the executable cleanly, and removes the autostart marker on uninstall. --- installer/setup.iss | 40 ++++++++++++++++++++++++++++++++++------ src/TextService.cpp | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/installer/setup.iss b/installer/setup.iss index 4f1e4f3..40ba8ed 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -66,12 +66,6 @@ Source: "..\build\Release\canto-dictd.exe"; DestDir: "{app}"; Flags: ignoreversi ; Clean up the per-user options key on uninstall (not created at install time; ; the IME writes it when the user changes options). Root: HKCU; Subkey: "Software\CantoIME"; Flags: uninsdeletekey dontcreatekey -; Start the dictionary service at each user's logon (running as that user, so it -; reads their settings), so the dictionary is already loaded before the first -; switch into the IME. If it is not yet running, the IME also starts it on demand. -Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \ - ValueName: "CantoIME Dictionary Service"; ValueData: """{app}\canto-dictd.exe"""; \ - Flags: uninsdeletevalue [Run] ; Register the selected variant's TSF text service via the 64-bit regsvr32. @@ -90,11 +84,24 @@ Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-CN.dll"""; \ Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-SG.dll"""; \ Check: IsVariant('SG'); StatusMsg: "Registering Singapore (Simplified) input method..."; \ Flags: runhidden waituntilterminated +; Start the dictionary service now ONLY if this user had already added CantoIME +; before (marker written by the IME on first use, present across an update). So +; an update brings the service back up without waiting for the next logon. A +; fresh install does NOT start it: it starts when the keyboard is first used (the +; IME registers its per-user autostart then). Runs as the original (non-elevated) +; user so the per-user pipe matches the IME. +Filename: "{app}\canto-dictd.exe"; Check: ServiceWasEnabled(); \ + StatusMsg: "Starting dictionary service..."; \ + Flags: nowait runhidden runasoriginaluser [UninstallRun] ; Stop the resident dictionary service first, so its executable can be removed. Filename: "{sys}\taskkill.exe"; Parameters: "/f /im canto-dictd.exe"; \ Flags: runhidden; RunOnceId: "StopDictd" +; Remove the per-user logon-autostart marker the IME wrote (as that user). +Filename: "{sys}\reg.exe"; \ + Parameters: "delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v ""CantoIME Dictionary Service"" /f"; \ + Flags: runhidden runasoriginaluser; RunOnceId: "DelDictdAutostart" ; Unregister BEFORE files are removed. Only the variant DLL actually present in ; {app} gets unregistered (exactly the one that was installed). Filename: "{sys}\regsvr32.exe"; Parameters: "/u /s ""{app}\CantoIME-HK.dll"""; \ @@ -223,6 +230,27 @@ begin Result := SelectedVariant() = Tag; end; +{ True if this user has already added/used CantoIME (the IME writes this Run + marker on first activation). Used to decide whether an update should restart + the dictionary service. A fresh install leaves it absent, so the service is + not started until the keyboard is first used. } +function ServiceWasEnabled(): Boolean; +begin + Result := RegValueExists(HKEY_CURRENT_USER, + 'Software\Microsoft\Windows\CurrentVersion\Run', 'CantoIME Dictionary Service'); +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + ResultCode: Integer; +begin + { Before files are copied, stop any running (previous) dictionary service so an + update can replace its executable rather than deferring to a reboot. } + if CurStep = ssInstall then + Exec(ExpandConstant('{sys}\taskkill.exe'), '/f /im canto-dictd.exe', '', + SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + function DllPresent(Name: String): Boolean; begin Result := FileExists(ExpandConstant('{app}\') + Name); diff --git a/src/TextService.cpp b/src/TextService.cpp index 9a86af4..799faf2 100644 --- a/src/TextService.cpp +++ b/src/TextService.cpp @@ -151,6 +151,30 @@ STDMETHODIMP CTextService::Activate(ITfThreadMgr* ptim, TfClientId tid) return ActivateEx(ptim, tid, 0); } +// Registers the dictionary service for autostart at this user's logon. Written +// only when the IME actually activates (i.e. the keyboard has been added and +// used), so the service never runs for users who have not added CantoIME. Also +// serves as the "was added" marker the installer checks to (re)start the service +// on update. Idempotent. +static void EnsureServiceAutostart() +{ + std::wstring dir = GetModuleDirectory(); + if (dir.empty()) + return; + std::wstring cmd = L"\"" + dir + L"canto-dictd.exe\""; + + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &key, nullptr) == ERROR_SUCCESS) + { + RegSetValueExW(key, L"CantoIME Dictionary Service", 0, REG_SZ, + reinterpret_cast(cmd.c_str()), + static_cast((cmd.size() + 1) * sizeof(wchar_t))); + RegCloseKey(key); + } +} + STDMETHODIMP CTextService::ActivateEx(ITfThreadMgr* ptim, TfClientId tid, DWORD /*dwFlags*/) { m_pThreadMgr = ptim; @@ -171,13 +195,17 @@ STDMETHODIMP CTextService::ActivateEx(ITfThreadMgr* ptim, TfClientId tid, DWORD OnCandidateClicked(globalIndex); }); - // Create the hidden message window first: ApplySettings kicks off the - // dictionary load on a background thread and hands the result back through - // this window, and it also receives the lone-Shift toggle (see header note). + // Create the hidden message window first: it receives the lone-Shift toggle + // (see header note) and the options-dialog request. CreateMsgWindow(); - // Apply per-user settings: candidate font size + load the selected dictionary - // (asynchronously, so switching to CantoIME does not block). + // The keyboard is in use: register the dictionary service for logon + // autostart (per-user), then apply settings, which connects to the service + // (starting it on demand if it is not already running). + EnsureServiceAutostart(); + + // Apply per-user settings: candidate font size + tell the service which + // dictionary to serve (starting it if needed). ApplySettings(); // Add the language-bar buttons (main 粵 icon + 中/英 input-mode toggle). From 6ee18afb5c17b3c8f0b1d2d838382eb335ac7d4c Mon Sep 17 00:00:00 2001 From: ArnauvGilotra Date: Tue, 11 Aug 2026 04:58:27 -0400 Subject: [PATCH 3/4] installer: drop unsupported runasoriginaluser flag in UninstallRun --- installer/setup.iss | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/installer/setup.iss b/installer/setup.iss index 40ba8ed..938cd7f 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -98,10 +98,12 @@ Filename: "{app}\canto-dictd.exe"; Check: ServiceWasEnabled(); \ ; Stop the resident dictionary service first, so its executable can be removed. Filename: "{sys}\taskkill.exe"; Parameters: "/f /im canto-dictd.exe"; \ Flags: runhidden; RunOnceId: "StopDictd" -; Remove the per-user logon-autostart marker the IME wrote (as that user). +; Remove the per-user logon-autostart marker the IME wrote. (UninstallRun does +; not support runasoriginaluser, so this cleans the uninstalling user's HKCU; +; a stale entry for another user is harmless — it points at a removed exe.) Filename: "{sys}\reg.exe"; \ Parameters: "delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v ""CantoIME Dictionary Service"" /f"; \ - Flags: runhidden runasoriginaluser; RunOnceId: "DelDictdAutostart" + Flags: runhidden; RunOnceId: "DelDictdAutostart" ; Unregister BEFORE files are removed. Only the variant DLL actually present in ; {app} gets unregistered (exactly the one that was installed). Filename: "{sys}\regsvr32.exe"; Parameters: "/u /s ""{app}\CantoIME-HK.dll"""; \ From 1e93f19d7e4ba3e407b4e38d22fcbd2562df11e9 Mon Sep 17 00:00:00 2001 From: ArnauvGilotra Date: Tue, 11 Aug 2026 05:10:54 -0400 Subject: [PATCH 4/4] Start the service at logon, self-gated on the keyboard being added First-switch start was too late. Now the service starts early (at every user's logon, via HKLM Run) but self-gates: at startup it checks via TSF whether any CantoIME variant is enabled for the user (IsEnabledLanguage- Profile across all five variants) and exits immediately if not, so it never stays resident for users who have not added the keyboard. - Service: KeyboardIsAdded() check before loading; inconclusive -> run. - Installer: HKLM Run autostart for all users; start it once post-install (self-gates, so a fresh not-yet-added install just exits, an already- added update stays); still kills the old instance before an update. - Dropped the IME per-user autostart marker and the installer's ServiceWasEnabled logic (superseded by the self-gate). --- CMakeLists.txt | 2 +- installer/setup.iss | 36 ++++++++++----------------- src/DictService.cpp | 60 +++++++++++++++++++++++++++++++++++++++++++++ src/TextService.cpp | 32 ++---------------------- 4 files changed, 76 insertions(+), 54 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 30019ff..3633d12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,7 +149,7 @@ set_target_properties(canto-dictd PROPERTIES OUTPUT_NAME "canto-dictd") target_include_directories(canto-dictd PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") target_compile_definitions(canto-dictd PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX CANTO_VARIANT_HK) -target_link_libraries(canto-dictd PRIVATE advapi32) +target_link_libraries(canto-dictd PRIVATE advapi32 ole32 uuid) if(MSVC) target_compile_options(canto-dictd PRIVATE /W4 /permissive-) endif() diff --git a/installer/setup.iss b/installer/setup.iss index 938cd7f..cd8d186 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -66,6 +66,13 @@ Source: "..\build\Release\canto-dictd.exe"; DestDir: "{app}"; Flags: ignoreversi ; Clean up the per-user options key on uninstall (not created at install time; ; the IME writes it when the user changes options). Root: HKCU; Subkey: "Software\CantoIME"; Flags: uninsdeletekey dontcreatekey +; Start the dictionary service at every user's logon, running as that user. It +; starts early (before the first switch into the IME) but self-gates: it stays +; and loads the dictionary only if that user has actually added a CantoIME +; keyboard, and exits immediately otherwise. +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \ + ValueName: "CantoIME Dictionary Service"; ValueData: """{app}\canto-dictd.exe"""; \ + Flags: uninsdeletevalue [Run] ; Register the selected variant's TSF text service via the 64-bit regsvr32. @@ -84,13 +91,12 @@ Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-CN.dll"""; \ Filename: "{sys}\regsvr32.exe"; Parameters: "/s ""{app}\CantoIME-SG.dll"""; \ Check: IsVariant('SG'); StatusMsg: "Registering Singapore (Simplified) input method..."; \ Flags: runhidden waituntilterminated -; Start the dictionary service now ONLY if this user had already added CantoIME -; before (marker written by the IME on first use, present across an update). So -; an update brings the service back up without waiting for the next logon. A -; fresh install does NOT start it: it starts when the keyboard is first used (the -; IME registers its per-user autostart then). Runs as the original (non-elevated) -; user so the per-user pipe matches the IME. -Filename: "{app}\canto-dictd.exe"; Check: ServiceWasEnabled(); \ +; Start the dictionary service now (as the original, non-elevated user, so its +; per-user pipe and keyboard check match the IME). It self-gates: on a fresh +; install where the keyboard is not yet added it just exits; on an update where +; the keyboard was already added it stays and serves. Either way the logon +; autostart brings it back on subsequent sessions. +Filename: "{app}\canto-dictd.exe"; \ StatusMsg: "Starting dictionary service..."; \ Flags: nowait runhidden runasoriginaluser @@ -98,12 +104,6 @@ Filename: "{app}\canto-dictd.exe"; Check: ServiceWasEnabled(); \ ; Stop the resident dictionary service first, so its executable can be removed. Filename: "{sys}\taskkill.exe"; Parameters: "/f /im canto-dictd.exe"; \ Flags: runhidden; RunOnceId: "StopDictd" -; Remove the per-user logon-autostart marker the IME wrote. (UninstallRun does -; not support runasoriginaluser, so this cleans the uninstalling user's HKCU; -; a stale entry for another user is harmless — it points at a removed exe.) -Filename: "{sys}\reg.exe"; \ - Parameters: "delete ""HKCU\Software\Microsoft\Windows\CurrentVersion\Run"" /v ""CantoIME Dictionary Service"" /f"; \ - Flags: runhidden; RunOnceId: "DelDictdAutostart" ; Unregister BEFORE files are removed. Only the variant DLL actually present in ; {app} gets unregistered (exactly the one that was installed). Filename: "{sys}\regsvr32.exe"; Parameters: "/u /s ""{app}\CantoIME-HK.dll"""; \ @@ -232,16 +232,6 @@ begin Result := SelectedVariant() = Tag; end; -{ True if this user has already added/used CantoIME (the IME writes this Run - marker on first activation). Used to decide whether an update should restart - the dictionary service. A fresh install leaves it absent, so the service is - not started until the keyboard is first used. } -function ServiceWasEnabled(): Boolean; -begin - Result := RegValueExists(HKEY_CURRENT_USER, - 'Software\Microsoft\Windows\CurrentVersion\Run', 'CantoIME Dictionary Service'); -end; - procedure CurStepChanged(CurStep: TSetupStep); var ResultCode: Integer; diff --git a/src/DictService.cpp b/src/DictService.cpp index 90e60ab..d46b548 100644 --- a/src/DictService.cpp +++ b/src/DictService.cpp @@ -17,6 +17,8 @@ #include "Globals.h" // CANTO_DICT_FILENAME / CANTO_DICT_SIMPLIFIED #include "DictProtocol.h" +#include // ITfInputProcessorProfiles (added-keyboard check) + #include #include #include @@ -24,6 +26,59 @@ using namespace dictproto; +// True if any CantoIME variant is currently enabled as an input method for this +// user, i.e. the keyboard has been added. The service is started early (at logon, +// for every user), but only stays and loads the dictionary for users who have +// actually added CantoIME; everyone else exits immediately. If TSF cannot be +// queried, err toward running (better early than not at all). +static bool KeyboardIsAdded() +{ + struct Variant { CLSID clsid; LANGID lang; GUID profile; }; + static const Variant kVariants[] = { + // HK + {{0xBC8FB43B,0x879A,0x4B9C,{0x87,0x23,0x3F,0xE7,0x55,0x6D,0x57,0xE2}}, 0x0C04, + {0x38F83831,0xD93F,0x473A,{0x97,0xB7,0x8E,0x59,0x0D,0x41,0xA7,0x2D}}}, + // TW + {{0xC732EC0C,0x99F6,0x43EC,{0x83,0xC5,0x42,0xEC,0x1C,0x94,0x43,0x25}}, 0x0404, + {0x973BC8C9,0xA5A2,0x4BC6,{0x90,0xAB,0x8C,0xF4,0xEA,0x25,0xFF,0xBF}}}, + // MO + {{0x7E96AB83,0xF0BF,0x4F24,{0xB4,0x1F,0x2E,0x32,0xBD,0x56,0x2D,0xA6}}, 0x1404, + {0x2CB45F66,0x00A5,0x418C,{0x83,0x54,0x87,0xAC,0x7B,0x7C,0x8C,0x85}}}, + // CN + {{0x7C5C8388,0x430C,0x4C15,{0x8A,0x87,0x78,0xA0,0xBB,0x9A,0x48,0x2B}}, 0x0804, + {0x1307330C,0xCDBA,0x484F,{0x89,0x1F,0xFE,0xB4,0x59,0x5D,0x43,0x7D}}}, + // SG + {{0xB551180B,0xE874,0x4B04,{0x8B,0x8F,0x3F,0x16,0x34,0x4A,0xD6,0x83}}, 0x1004, + {0x43CCFF03,0x2E82,0x4938,{0x88,0xCB,0x15,0xD4,0xCB,0xE0,0x69,0xA7}}}, + }; + + bool added = false; + bool determined = false; + if (SUCCEEDED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) + { + ITfInputProcessorProfiles* profiles = nullptr; + if (SUCCEEDED(CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr, + CLSCTX_INPROC_SERVER, IID_ITfInputProcessorProfiles, + reinterpret_cast(&profiles))) && profiles) + { + determined = true; + for (const Variant& v : kVariants) + { + BOOL enabled = FALSE; + if (SUCCEEDED(profiles->IsEnabledLanguageProfile( + v.clsid, v.lang, v.profile, &enabled)) && enabled) + { + added = true; + break; + } + } + profiles->Release(); + } + CoUninitialize(); + } + return determined ? added : true; // inconclusive -> run anyway +} + // The one loaded dictionary, guarded for concurrent clients. RESOLVE reads it; // SETDICT (and the startup preload) rebuild it. static std::mutex g_mutex; @@ -200,6 +255,11 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) if (mutex && GetLastError() == ERROR_ALREADY_EXISTS) return 0; + // Started early for every user at logon; stay only if this user has actually + // added a CantoIME keyboard. Otherwise exit without loading anything. + if (!KeyboardIsAdded()) + return 0; + PreloadFromSettings(); // hot dictionary before the first request return RunServer(); } diff --git a/src/TextService.cpp b/src/TextService.cpp index 799faf2..e7f1e25 100644 --- a/src/TextService.cpp +++ b/src/TextService.cpp @@ -151,30 +151,6 @@ STDMETHODIMP CTextService::Activate(ITfThreadMgr* ptim, TfClientId tid) return ActivateEx(ptim, tid, 0); } -// Registers the dictionary service for autostart at this user's logon. Written -// only when the IME actually activates (i.e. the keyboard has been added and -// used), so the service never runs for users who have not added CantoIME. Also -// serves as the "was added" marker the installer checks to (re)start the service -// on update. Idempotent. -static void EnsureServiceAutostart() -{ - std::wstring dir = GetModuleDirectory(); - if (dir.empty()) - return; - std::wstring cmd = L"\"" + dir + L"canto-dictd.exe\""; - - HKEY key = nullptr; - if (RegCreateKeyExW(HKEY_CURRENT_USER, - L"Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, nullptr, - REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, &key, nullptr) == ERROR_SUCCESS) - { - RegSetValueExW(key, L"CantoIME Dictionary Service", 0, REG_SZ, - reinterpret_cast(cmd.c_str()), - static_cast((cmd.size() + 1) * sizeof(wchar_t))); - RegCloseKey(key); - } -} - STDMETHODIMP CTextService::ActivateEx(ITfThreadMgr* ptim, TfClientId tid, DWORD /*dwFlags*/) { m_pThreadMgr = ptim; @@ -199,13 +175,9 @@ STDMETHODIMP CTextService::ActivateEx(ITfThreadMgr* ptim, TfClientId tid, DWORD // (see header note) and the options-dialog request. CreateMsgWindow(); - // The keyboard is in use: register the dictionary service for logon - // autostart (per-user), then apply settings, which connects to the service - // (starting it on demand if it is not already running). - EnsureServiceAutostart(); - // Apply per-user settings: candidate font size + tell the service which - // dictionary to serve (starting it if needed). + // dictionary to serve. This connects to the service, starting it on demand + // if the logon autostart has not already brought it up. ApplySettings(); // Add the language-bar buttons (main 粵 icon + 中/英 input-mode toggle).