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..3633d12 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 ole32 uuid) +if(MSVC) + target_compile_options(canto-dictd PRIVATE /W4 /permissive-) +endif() diff --git a/installer/setup.iss b/installer/setup.iss index a44ad4e..cd8d186 100644 --- a/installer/setup.iss +++ b/installer/setup.iss @@ -57,11 +57,22 @@ 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 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. @@ -80,8 +91,19 @@ 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 (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 [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"""; \ @@ -210,6 +232,17 @@ begin Result := SelectedVariant() = Tag; 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/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..d46b548 --- /dev/null +++ b/src/DictService.cpp @@ -0,0 +1,265 @@ +// 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 // ITfInputProcessorProfiles (added-keyboard check) + +#include +#include +#include +#include + +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; +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; + + // 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 747159c..e7f1e25 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 @@ -174,13 +171,13 @@ 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). + // Apply per-user settings: candidate font size + tell the service which + // 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). @@ -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;