diff --git a/.gitignore b/.gitignore index f1b3b6243..d24faabe5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ build64 build_arm64/ node/input_methods/McBopomofo installer/*.exe +PIMELauncher/target/ +.claude/ +cleanup_x64.ps1 +replace_x64_dll.ps1 diff --git a/PIMELauncher/src/backend_manager.rs b/PIMELauncher/src/backend_manager.rs index ae5381546..4319ef71f 100644 --- a/PIMELauncher/src/backend_manager.rs +++ b/PIMELauncher/src/backend_manager.rs @@ -72,17 +72,31 @@ impl BackendManager { /// Retrieves a channel to send messages directly to the backend. pub async fn get_backend_input(&self, backend_name: &str) -> Option> { - let mut state = self.state.lock().await; - if !state.backends.contains_key(backend_name) { - // Dynamically look up the backend configuration - if let Some(config) = self.registry.get_backend(backend_name) { - let backend = self.spawn_backend_process(config).await; - state.backends.insert(backend_name.to_string(), backend); - } else { + // Fast path: backend already running — take and release the lock immediately. + { + let state = self.state.lock().await; + if let Some(b) = state.backends.get(backend_name) { + return Some(b.stdin_tx.clone()); + } + } + + // Slow path: spawn the backend without holding the lock, so other clients + // are not blocked during the (potentially multi-second) process startup. + let config = match self.registry.get_backend(backend_name) { + Some(c) => c.clone(), + None => { error!("Unknown backend requested: {}", backend_name); return None; } - } + }; + let backend = self.spawn_backend_process(&config).await; + + // Re-acquire lock to insert; use entry() so a concurrent spawn doesn't overwrite. + let mut state = self.state.lock().await; + state + .backends + .entry(backend_name.to_string()) + .or_insert(backend); state.backends.get(backend_name).map(|b| b.stdin_tx.clone()) } @@ -213,6 +227,7 @@ impl BackendManager { .current_dir(&working_dir) .creation_flags(CREATE_NO_WINDOW) .env("PYTHONIOENCODING", "utf-8:ignore") + .env("PYTHONUNBUFFERED", "1") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -289,14 +304,14 @@ impl BackendManager { loop { tokio::select! { msg = stdin_rx.recv() => { - let Some(data) = msg else { + let Some(data) = msg else { info!("Backend {} stdin channel closed. Exiting input loop.", backend_name); - break; + break; }; let now = Self::current_ms(); last_request_time = Some(now); info!("Backend {} received request from channel. Data len: {}. req_t={}", backend_name, data.len(), now); - + // LinesCodec expects data without the newline, it will add it for us. let write_res = tokio::time::timeout(Duration::from_secs(5), stdin_writer.send(data)).await; if let Err(_) = write_res { @@ -315,12 +330,12 @@ impl BackendManager { if let Some(req_t) = last_request_time { let last_out = last_output_time.load(Ordering::SeqCst); // Log tick status occasionally or at least for debugging - info!("Watchdog tick for {}: last_out={}, req_t={}, now={}, delta={}", + debug!("Watchdog tick for {}: last_out={}, req_t={}, now={}, delta={}", backend_name, last_out, req_t, now, now as i64 - req_t as i64); - + // If no output has been received since the last request and it's been more than 15 seconds if last_out < req_t && (now - req_t) > 15000 { - error!("Backend {} seems to be hung (no output for 15s after request). last_out={}, req_t={}, now={}. Forcing restart.", + error!("Backend {} seems to be hung (no output for 15s after request). last_out={}, req_t={}, now={}. Forcing restart.", backend_name, last_out, req_t, now); let _ = child_process.kill().await; break; diff --git a/PIMELauncher/src/main.rs b/PIMELauncher/src/main.rs index 12186bf11..1ce5ca8ae 100644 --- a/PIMELauncher/src/main.rs +++ b/PIMELauncher/src/main.rs @@ -117,6 +117,7 @@ async fn main() { // Writing to a non-existent stdout in a GUI subsystem app can cause hangs. tracing_subscriber::fmt() .with_ansi(false) + .with_max_level(tracing::Level::WARN) .with_writer(std::io::sink) .init(); } diff --git a/PIMELauncher/src/protocol.rs b/PIMELauncher/src/protocol.rs index 7b2d1d5b8..eeef7b56d 100644 --- a/PIMELauncher/src/protocol.rs +++ b/PIMELauncher/src/protocol.rs @@ -26,15 +26,9 @@ pub fn parse_client_handshake(first_line: &str) -> Result { /// Expects the format `PIME_MSG||`. /// Returns `Some((client_id, payload))` if valid. pub fn parse_backend_output(line: &str) -> Option<(String, String)> { - if line.starts_with("PIME_MSG|") { - let parts: Vec<&str> = line.splitn(3, '|').collect(); - if parts.len() == 3 { - let client_id = parts[1].to_string(); - let payload = parts[2].to_string(); - return Some((client_id, payload)); - } - } - None + let rest = line.strip_prefix("PIME_MSG|")?; + let sep = rest.find('|')?; + Some((rest[..sep].to_string(), rest[sep + 1..].to_string())) } /// Formats a message to be sent to a backend process. diff --git a/PIMETextService/PIMEClient.cpp b/PIMETextService/PIMEClient.cpp index 902b63436..f9a08e549 100644 --- a/PIMETextService/PIMEClient.cpp +++ b/PIMETextService/PIMEClient.cpp @@ -369,7 +369,7 @@ void Client::updateCandidateList(json& msg, Ime::EditSession* session) { candidates.emplace_back(utf8ToUtf16(candidate.get().c_str())); } textService_->updateCandidates(session); - if (!showCandidatesVal.get()) { + if (showCandidatesVal.is_boolean() && !showCandidatesVal.get()) { textService_->hideCandidates(); } } @@ -412,7 +412,7 @@ bool Client::filterKeyDown(Ime::KeyEvent& keyEvent) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -424,7 +424,7 @@ bool Client::onKeyDown(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret, session)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -436,7 +436,7 @@ bool Client::filterKeyUp(Ime::KeyEvent& keyEvent) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -448,7 +448,7 @@ bool Client::onKeyUp(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret, session)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -462,7 +462,7 @@ bool Client::onPreservedKey(const GUID& guid) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } } return false; @@ -476,7 +476,7 @@ bool Client::onCommand(UINT id, Ime::TextService::CommandType type) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -634,7 +634,10 @@ json Client::createRpcRequest(const char* methodName) { } bool Client::callPipeIO(bool isRead, void *buffer, DWORD size, DWORD *rlen, int timeoutMs) { - if (!ioEvent_) { + if (!ioEvent_ || ioEvent_ == INVALID_HANDLE_VALUE) { + ioEvent_ = CreateEvent(NULL, TRUE, FALSE, NULL); + } + if (!ioEvent_ || ioEvent_ == INVALID_HANDLE_VALUE) { return false; } @@ -677,7 +680,7 @@ bool Client::callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std: return false; } - char buf[1024]; + char buf[8192]; DWORD rlen = 0; while (true) { // Check if we already have a full line in the buffer @@ -771,9 +774,9 @@ bool Client::waitForRpcConnection() { } wstring serverPipeName = getPipeName(L"Launcher"); - for (int attempt = 0; pipe_ == INVALID_HANDLE_VALUE && attempt < 5; ++attempt) { + for (int attempt = 0; pipe_ == INVALID_HANDLE_VALUE && attempt < 3; ++attempt) { // try to connect to the server - pipe_ = connectPipe(serverPipeName.c_str(), 30000); + pipe_ = connectPipe(serverPipeName.c_str(), 3000); } if (pipe_ != INVALID_HANDLE_VALUE) { @@ -818,9 +821,9 @@ void Client::closeRpcConnection() { CloseHandle(pipe_); pipe_ = INVALID_HANDLE_VALUE; } - if (ioEvent_ != INVALID_HANDLE_VALUE) { + if (ioEvent_ && ioEvent_ != INVALID_HANDLE_VALUE) { CloseHandle(ioEvent_); - ioEvent_ = INVALID_HANDLE_VALUE; + ioEvent_ = NULL; } readBuffer_.clear(); } diff --git a/python/cinbase/__init__.py b/python/cinbase/__init__.py index 69c985ef1..a579fc9fb 100644 --- a/python/cinbase/__init__.py +++ b/python/cinbase/__init__.py @@ -311,7 +311,7 @@ def filterKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if cbTS.lastKeyDownTime == 0.0: cbTS.lastKeyDownTime = time.time() - if CinTable.loading: + if CinTable.loading or not getattr(cbTS, 'cin', None): return True # 使用者開始輸入,還沒送出前的編輯區內容稱 composition string @@ -425,7 +425,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): charStr = chr(charCode) charStrLow = charStr.lower() - if CinTable.loading: + if CinTable.loading or not getattr(cbTS, 'cin', None): if not cbTS.client.isUiLess: messagestr = '正在載入輸入法碼表,請稍候...' cbTS.isShowMessage = True @@ -3278,18 +3278,22 @@ def __init__(self, cbTS, PhraseData): def run(self): self.PhraseData.loading = True - cfg = self.cbTS.cfg - datadirs = (cfg.getConfigDir(), cfg.getDataDir()) + try: + cfg = self.cbTS.cfg + datadirs = (cfg.getConfigDir(), cfg.getDataDir()) - if hasattr(self.PhraseData.phrase, '__del__'): - self.PhraseData.phrase.__del__() + if hasattr(self.PhraseData.phrase, '__del__'): + self.PhraseData.phrase.__del__() - self.PhraseData.phrase = None + self.PhraseData.phrase = None - phrasePath = cfg.findFile(datadirs, "phrase.json") - with io.open(phrasePath, 'r', encoding='utf8') as fs: - self.PhraseData.phrase = phrase(fs) - self.PhraseData.loading = False + phrasePath = cfg.findFile(datadirs, "phrase.json") + with io.open(phrasePath, 'r', encoding='utf8') as fs: + self.PhraseData.phrase = phrase(fs) + except Exception: + pass + finally: + self.PhraseData.loading = False class LoadCinTable(threading.Thread): @@ -3303,42 +3307,47 @@ def run(self): self.cbTS.debug.setStartTimer("LoadCinTable") self.CinTable.loading = True - if self.cbTS.cfg.selCinType >= len(self.cbTS.cinFileList): - self.cbTS.cfg.selCinType = 0 - selCinFile = self.cbTS.cinFileList[self.cbTS.cfg.selCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - - if self.cbTS.reLoadCinTable or not hasattr(self.cbTS, 'cin'): - self.cbTS.reLoadCinTable = False - - if hasattr(self.cbTS, 'cin'): - self.cbTS.cin.__del__() - if hasattr(self.CinTable.cin, '__del__'): - self.CinTable.cin.__del__() - - self.cbTS.cin = None - self.CinTable.cin = None - - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.cbTS.cin = Cin(fs, self.cbTS.imeDirName, self.cbTS.ignorePrivateUseArea) - self.CinTable.cin = self.cbTS.cin - self.CinTable.curCinType = self.cbTS.cfg.selCinType - - if not hasattr(self.cbTS, 'extendtable'): - if self.cbTS.cfg.userExtendTable: - datadirs = (self.cbTS.cfg.getConfigDir(), self.cbTS.cfg.getDataDir()) - extendtablePath = self.cbTS.cfg.findFile(datadirs, "extendtable.dat") - with io.open(extendtablePath, encoding='utf-8') as fs: - self.cbTS.extendtable = extendtable(fs) - else: - self.cbTS.extendtable = {} - self.cbTS.cin.updateCinTable(self.cbTS.cfg.userExtendTable, self.cbTS.cfg.priorityExtendTable, self.cbTS.extendtable, self.cbTS.cfg.ignorePrivateUseArea) - self.CinTable.userExtendTable = self.cbTS.cfg.userExtendTable - self.CinTable.priorityExtendTable = self.cbTS.cfg.priorityExtendTable - self.CinTable.ignorePrivateUseArea = self.cbTS.cfg.ignorePrivateUseArea - self.CinTable.loading = False - - if DEBUG_MODE: + selCinFile = None + try: + if self.cbTS.cfg.selCinType >= len(self.cbTS.cinFileList): + self.cbTS.cfg.selCinType = 0 + selCinFile = self.cbTS.cinFileList[self.cbTS.cfg.selCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) + + if self.cbTS.reLoadCinTable or not hasattr(self.cbTS, 'cin'): + self.cbTS.reLoadCinTable = False + + if hasattr(self.cbTS, 'cin'): + self.cbTS.cin.__del__() + if hasattr(self.CinTable.cin, '__del__'): + self.CinTable.cin.__del__() + + self.cbTS.cin = None + self.CinTable.cin = None + + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.cbTS.cin = Cin(fs, self.cbTS.imeDirName, self.cbTS.ignorePrivateUseArea) + self.CinTable.cin = self.cbTS.cin + self.CinTable.curCinType = self.cbTS.cfg.selCinType + + if not hasattr(self.cbTS, 'extendtable'): + if self.cbTS.cfg.userExtendTable: + datadirs = (self.cbTS.cfg.getConfigDir(), self.cbTS.cfg.getDataDir()) + extendtablePath = self.cbTS.cfg.findFile(datadirs, "extendtable.dat") + with io.open(extendtablePath, encoding='utf-8') as fs: + self.cbTS.extendtable = extendtable(fs) + else: + self.cbTS.extendtable = {} + self.cbTS.cin.updateCinTable(self.cbTS.cfg.userExtendTable, self.cbTS.cfg.priorityExtendTable, self.cbTS.extendtable, self.cbTS.cfg.ignorePrivateUseArea) + self.CinTable.userExtendTable = self.cbTS.cfg.userExtendTable + self.CinTable.priorityExtendTable = self.cbTS.cfg.priorityExtendTable + self.CinTable.ignorePrivateUseArea = self.cbTS.cfg.ignorePrivateUseArea + except Exception: + pass + finally: + self.CinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [C]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadCinTable") + " 秒" @@ -3364,25 +3373,30 @@ def run(self): self.cbTS.debug.setStartTimer("LoadRCinTable") self.RCinTable.loading = True - selCinFile = self.rcinFileList[self.cbTS.cfg.selRCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) + selCinFile = None + try: + selCinFile = self.rcinFileList[self.cbTS.cfg.selRCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - if self.RCinTable.cin is not None and hasattr(self.RCinTable.cin, '__del__'): - self.RCinTable.cin.__del__() + if self.RCinTable.cin is not None and hasattr(self.RCinTable.cin, '__del__'): + self.RCinTable.cin.__del__() - self.RCinTable.cin = None + self.RCinTable.cin = None - if os.path.exists(jsonPath): - self.cbTS.RCinFileNotExist = False - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.RCinTable.cin = RCin(fs, self.cbTS.imeDirName) - else: - self.cbTS.RCinFileNotExist = True - - self.RCinTable.curCinType = self.cbTS.cfg.selRCinType - self.RCinTable.loading = False + if os.path.exists(jsonPath): + self.cbTS.RCinFileNotExist = False + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.RCinTable.cin = RCin(fs, self.cbTS.imeDirName) + else: + self.cbTS.RCinFileNotExist = True - if DEBUG_MODE: + self.RCinTable.curCinType = self.cbTS.cfg.selRCinType + except Exception: + pass + finally: + self.RCinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadRCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [R]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」反查碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadRCinTable") + " 秒" @@ -3398,19 +3412,24 @@ def run(self): self.cbTS.debug.setStartTimer("LoadHCinTable") self.HCinTable.loading = True - selCinFile = CinBase.hcinFileList[self.cbTS.cfg.selHCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - - if self.HCinTable.cin is not None and hasattr(self.HCinTable.cin, '__del__'): - self.HCinTable.cin.__del__() + selCinFile = None + try: + selCinFile = CinBase.hcinFileList[self.cbTS.cfg.selHCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - self.HCinTable.cin = None + if self.HCinTable.cin is not None and hasattr(self.HCinTable.cin, '__del__'): + self.HCinTable.cin.__del__() - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.HCinTable.cin = HCin(fs, self.cbTS.imeDirName) - self.HCinTable.curCinType = self.cbTS.cfg.selHCinType - self.HCinTable.loading = False + self.HCinTable.cin = None - if DEBUG_MODE: + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.HCinTable.cin = HCin(fs, self.cbTS.imeDirName) + self.HCinTable.curCinType = self.cbTS.cfg.selHCinType + except Exception: + pass + finally: + self.HCinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadHCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [H]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」同音字碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadHCinTable") + " 秒" diff --git a/python/cinbase/config.py b/python/cinbase/config.py index 3b3bc0ac0..a8d30db4c 100644 --- a/python/cinbase/config.py +++ b/python/cinbase/config.py @@ -103,21 +103,33 @@ def getLastTime(self): return self._lastTime def load(self): + # Layer 1: apply shipped defaults so new keys reach all users regardless of + # whether they already have an APPDATA config from a previous install. + default_config = os.path.join(self.getDefaultConfigDir(), "config.json") + try: + if os.path.exists(default_config) and os.stat(default_config).st_size > 0: + with open(default_config, "r") as f: + self.__dict__.update(json.load(f)) + except Exception: + pass + + # Layer 2: overlay with the user's personal config (APPDATA or legacy home-dir path). filename = self.getConfigFile() try: if not os.path.exists(filename) or os.stat(filename).st_size == 0: filename = os.path.join(os.path.expanduser("~"), "PIME", self.imeDirName, "config.json") if not os.path.exists(filename) or os.stat(filename).st_size == 0: - filename = os.path.join(self.getDefaultConfigDir(), "config.json") + filename = None else: src_dir = os.path.join(os.path.expanduser("~"), "PIME", self.imeDirName) dst_dir = self.getConfigDir() self.copytree(src_dir, dst_dir) filename = self.getConfigFile() - with open(filename, "r") as f: - self.__dict__.update(json.load(f)) + if filename: + with open(filename, "r") as f: + self.__dict__.update(json.load(f)) except Exception: self.save() self.update() @@ -129,8 +141,7 @@ def save(self): filename = self.getConfigFile() try: with open(filename, "w") as f: - jsondata = {key: value for key, value in self.__dict__.items() if not key in self.ignoreSaveList} - js = json.dump(jsondata, f, sort_keys=True, indent=4) + json.dump(self.toJson(), f, sort_keys=True, indent=4) self.update() except Exception: pass # FIXME: handle I/O errors? diff --git a/python/cinbase/configtool.py b/python/cinbase/configtool.py index fc66dfee3..74bd0ef1d 100644 --- a/python/cinbase/configtool.py +++ b/python/cinbase/configtool.py @@ -62,6 +62,14 @@ def prepare(self): # called before every request self.application.reset_timeout() # reset the quit server timeout +class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): + + def set_extra_headers(self, path): + self.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.set_header("Pragma", "no-cache") + self.set_header("Expires", "0") + + class KeepAliveHandler(BaseHandler): @tornado.web.authenticated @@ -98,36 +106,33 @@ def post(self): # save config os.makedirs(config_dir, exist_ok=True) # write the config to files config = data.get("config", None) - if config: + if config is not None: self.save_file("config.json", json.dumps(config, sort_keys=True, indent=4)) symbols = data.get("symbols", None) - if symbols: + if symbols is not None: self.save_file("symbols.dat", symbols) swkb = data.get("swkb", None) - if swkb: + if swkb is not None: self.save_file("swkb.dat", swkb) - self.write('{"return":true}') fsymbols = data.get("fsymbols", None) - if fsymbols: + if fsymbols is not None: self.save_file("fsymbols.dat", fsymbols) - self.write('{"return":true}') phrase = data.get("phrase", None) - if phrase: + if phrase is not None: self.save_file("userphrase.dat", phrase) - self.write('{"return":true}') flangs = data.get("flangs", None) - if flangs: + if flangs is not None: self.save_file("flangs.dat", flangs) - self.write('{"return":true}') extendtable = data.get("extendtable", None) - if extendtable: + if extendtable is not None: self.save_file("extendtable.dat", extendtable) + self.write('{"return":true}') def load_config(self): @@ -169,10 +174,18 @@ def load_data(self, name): return "" def save_file(self, filename, data): + target = os.path.join(config_dir, filename) + tmp_target = target + ".tmp" try: - with open(os.path.join(config_dir, filename), "w", encoding="UTF-8") as f: + with open(tmp_target, "w", encoding="UTF-8") as f: f.write(data) + os.replace(tmp_target, target) except Exception: + try: + if os.path.exists(tmp_target): + os.remove(tmp_target) + except Exception: + pass pass @@ -185,7 +198,7 @@ def post(self, page_name): self.set_cookie(COOKIE_ID, token) if page_name != "user_phrase_editor": page_name = "config" - self.redirect("/{}.html".format(page_name)) + self.redirect("/{}.html?v={}".format(page_name, self.settings["access_token"][:8])) @@ -200,11 +213,11 @@ def __init__(self): "debug": True } handlers = [ - (r"/(.*\.html|config.js)", tornado.web.StaticFileHandler, {"path": current_ime_config_dir}), - (r"/(.*\.htm)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "config")}), - (r"/((css|fonts|images|js)/.*)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "config")}), - (r"/(icon.ico)", tornado.web.StaticFileHandler, {"path": current_ime_dir}), - (r"/(version.txt)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "../../")}), + (r"/(.*\.html|config.js)", NoCacheStaticFileHandler, {"path": current_ime_config_dir}), + (r"/(.*\.htm)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "config")}), + (r"/((css|fonts|images|js)/.*)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "config")}), + (r"/(icon.ico)", NoCacheStaticFileHandler, {"path": current_ime_dir}), + (r"/(version.txt)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "../../")}), (r"/config", ConfigHandler), # main configuration handler (r"/keep_alive", KeepAliveHandler), # keep the api server alive (r"/login/(.*)", LoginHandler) # authentication diff --git a/python/input_methods/chewing/config_tool.html b/python/input_methods/chewing/config_tool.html index 3c6331278..7958ed8af 100644 --- a/python/input_methods/chewing/config_tool.html +++ b/python/input_methods/chewing/config_tool.html @@ -3,6 +3,7 @@ + 設定新酷音輸入法 diff --git a/python/input_methods/chewing/config_tool.py b/python/input_methods/chewing/config_tool.py index 1acfd6e46..249aec90a 100644 --- a/python/input_methods/chewing/config_tool.py +++ b/python/input_methods/chewing/config_tool.py @@ -59,6 +59,14 @@ def prepare(self): # called before every request self.application.reset_timeout() # reset the quit server timeout +class NoCacheStaticFileHandler(tornado.web.StaticFileHandler): + + def set_extra_headers(self, path): + self.set_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.set_header("Pragma", "no-cache") + self.set_header("Expires", "0") + + class KeepAliveHandler(BaseHandler): @tornado.web.authenticated @@ -89,13 +97,13 @@ def post(self): # save config os.makedirs(config_dir, exist_ok=True) # write the config to files config = data.get("config", None) - if config: + if config is not None: self.save_file("config.json", json.dumps(config, indent=2)) symbols = data.get("symbols", None) - if symbols: + if symbols is not None: self.save_file("symbols.dat", symbols) swkb = data.get("swkb", None) - if swkb: + if swkb is not None: self.save_file("swkb.dat", swkb) self.write('{"return":true}') @@ -230,7 +238,7 @@ def post(self, page_name): self.set_cookie(COOKIE_ID, token) if page_name != "user_phrase_editor": page_name = "config_tool" - self.redirect("/{}.html".format(page_name)) + self.redirect("/{}.html?v={}".format(page_name, token[:8])) class ConfigApp(tornado.web.Application): @@ -244,9 +252,9 @@ def __init__(self): "debug": True } handlers = [ - (r"/(.*\.html)", tornado.web.StaticFileHandler, {"path": current_dir}), - (r"/((css|images|js|fonts)/.*)", tornado.web.StaticFileHandler, {"path": current_dir}), - (r"/(version.txt)", tornado.web.StaticFileHandler, {"path": os.path.join(current_dir, "../../../")}), + (r"/(.*\.html)", NoCacheStaticFileHandler, {"path": current_dir}), + (r"/((css|images|js|fonts)/.*)", NoCacheStaticFileHandler, {"path": current_dir}), + (r"/(version.txt)", NoCacheStaticFileHandler, {"path": os.path.join(current_dir, "../../../")}), (r"/config", ConfigHandler), # main configuration handler (r"/user_phrases", UserPhraseHandler), # user phrase editor (r"/user_phrase_file", UserPhraseFileHandler), # export user phrase diff --git a/python/input_methods/chewing/js/config.js b/python/input_methods/chewing/js/config.js index 73981caa1..51e59c44a 100644 --- a/python/input_methods/chewing/js/config.js +++ b/python/input_methods/chewing/js/config.js @@ -27,6 +27,71 @@ $(function () { ); } + function bindTabsFallback() { + $('.nav-tabs a[data-toggle="tab"]').on("click", function (event) { + var target = $(this).attr("href"); + if (!target || target.charAt(0) !== "#") { + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + + var nav = $(this).closest(".nav-tabs"); + nav.find(".nav-link").removeClass("active").attr("aria-selected", "false"); + $(this).addClass("active").attr("aria-selected", "true"); + + $(".tab-content .tab-pane").removeClass("active show in"); + $(target).addClass("active show in"); + }); + } + + function installNativeTabsFallback() { + var links = document.querySelectorAll(".nav-tabs a[data-toggle='tab']"); + for (var i = 0; i < links.length; ++i) { + links[i].onclick = function (event) { + event = event || window.event; + var target = this.getAttribute("href"); + if (!target || target.charAt(0) !== "#") { + return true; + } + + if (event.preventDefault) { + event.preventDefault(); + } + event.returnValue = false; + event.cancelBubble = true; + if (event.stopPropagation) { + event.stopPropagation(); + } + + var navLinks = document.querySelectorAll(".nav-tabs .nav-link"); + for (var navIndex = 0; navIndex < navLinks.length; ++navIndex) { + navLinks[navIndex].className = navLinks[navIndex].className.replace(/\s*active/g, ""); + navLinks[navIndex].setAttribute("aria-selected", "false"); + } + + this.className += this.className.indexOf("active") === -1 ? " active" : ""; + this.setAttribute("aria-selected", "true"); + + var panes = document.querySelectorAll(".tab-content .tab-pane"); + for (var paneIndex = 0; paneIndex < panes.length; ++paneIndex) { + panes[paneIndex].className = panes[paneIndex].className + .replace(/\s*active/g, "") + .replace(/\s*show/g, "") + .replace(/\s*in/g, ""); + } + + var pane = document.getElementById(target.substr(1)); + if (pane) { + pane.className += " active show in"; + } + + return false; + } + } + } + function saveConfig(callbackFunc) { // Check easy symbols format let ez_symbols_array = $("#ez_symbols").val().split("\n"); @@ -319,5 +384,8 @@ $(function () { $("#test_input_text").val("").select(); }); + installNativeTabsFallback(); + bindTabsFallback(); + return false; }); diff --git a/python/server.py b/python/server.py index c64175b09..c3214782b 100644 --- a/python/server.py +++ b/python/server.py @@ -29,6 +29,18 @@ from serviceManager import textServiceMgr +def append_error_log(message): + try: + log_dir = os.path.join(os.path.expandvars("%LOCALAPPDATA%"), "PIME", "Logs") + os.makedirs(log_dir, mode=0o700, exist_ok=True) + with open(os.path.join(log_dir, "python_backend.log"), "a", encoding="utf-8") as log_file: + log_file.write(message) + if not message.endswith("\n"): + log_file.write("\n") + except Exception: + pass + + class Client(object): def __init__(self, server): self.server = server @@ -39,7 +51,7 @@ def init(self, msg): self.isWindows8Above = msg["isWindows8Above"] self.isMetroApp = msg["isMetroApp"] self.isUiLess = msg["isUiLess"] - self.isUiLess = msg["isConsole"] + self.isConsole = msg["isConsole"] # create the text service self.service = textServiceMgr.createService(self, self.guid) return (self.service is not None) @@ -77,14 +89,19 @@ def run(self): # parse PIME requests (one request per line): # request format: "|\n" # response format: "PIME_MSG||\n" - client_id, msg_text = line.split('|', maxsplit=1) + parts = line.split('|', maxsplit=1) + if len(parts) != 2: + print("ERROR: malformed request:", line, file=sys.stderr) + append_error_log("ERROR: malformed request: {0}\n".format(line)) + continue + client_id, msg_text = parts msg = json.loads(msg_text) client = self.clients.get(client_id) if not client: # create a Client instance for the client client = Client(self) self.clients[client_id] = client - print("new client:", client_id) + print("new client:", client_id, file=sys.stderr) if msg.get("method") == "close": # special handling for closing a client self.remove_client(client_id) else: @@ -92,23 +109,24 @@ def run(self): # Send the response to the client via stdout # one response per line in the format "PIME_MSG||" reply_line = '|'.join(["PIME_MSG", client_id, json.dumps(ret, ensure_ascii=False)]) - print(reply_line) + print(reply_line, flush=True) except EOFError: # stop the server break except Exception as e: - print("ERROR:", e, line) + print("ERROR:", e, line, file=sys.stderr) # print the exception traceback for ease of debugging traceback.print_exc() + append_error_log("ERROR: {0}\nREQUEST: {1}\n{2}\n".format(e, line, traceback.format_exc())) # generate an empty output containing {success: False} to prevent the client from being blocked reply_line = '|'.join(["PIME_MSG", client_id, '{"success":false}']) - print(reply_line) - # Just terminate the python server process if any unknown error happens. - # The python server will be restarted later by PIMELauncher. - sys.exit(1) + print(reply_line, flush=True) + # Keep the backend alive after one bad request; tearing down an + # active TSF session can destabilize the foreground application. + continue def remove_client(self, client_id): - print("client disconnected:", client_id) + print("client disconnected:", client_id, file=sys.stderr) try: del self.clients[client_id] except KeyError: diff --git a/tests/test_backend_resilience.py b/tests/test_backend_resilience.py new file mode 100644 index 000000000..15d34c455 --- /dev/null +++ b/tests/test_backend_resilience.py @@ -0,0 +1,65 @@ +import contextlib +import importlib +import io +import os +import sys +import unittest +from unittest import mock + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir)) +PYTHON_DIR = os.path.join(ROOT, "python") +if PYTHON_DIR not in sys.path: + sys.path.insert(0, PYTHON_DIR) + + +class ServerResilienceTests(unittest.TestCase): + def import_server(self): + with contextlib.redirect_stdout(io.StringIO()): + return importlib.import_module("server") + + def input_then_eof(self, lines): + iterator = iter(lines) + + def fake_input(): + try: + return next(iterator) + except StopIteration: + raise EOFError + + return fake_input + + def test_server_continues_after_client_exception(self): + server_mod = self.import_server() + server = server_mod.Server() + + class FailingClient: + def handleRequest(self, msg): + raise RuntimeError("boom") + + server.clients["client-1"] = FailingClient() + + with mock.patch("builtins.input", side_effect=self.input_then_eof(['client-1|{"method":"onKeyDown"}'])), \ + mock.patch.object(server_mod, "append_error_log"), \ + contextlib.redirect_stdout(io.StringIO()) as stdout, \ + contextlib.redirect_stderr(io.StringIO()): + server.run() + + self.assertIn('PIME_MSG|client-1|{"success":false}', stdout.getvalue()) + + def test_server_ignores_malformed_request_line(self): + server_mod = self.import_server() + server = server_mod.Server() + + with mock.patch("builtins.input", side_effect=self.input_then_eof(["malformed-request"])), \ + mock.patch.object(server_mod, "append_error_log") as append_error_log, \ + contextlib.redirect_stdout(io.StringIO()) as stdout, \ + contextlib.redirect_stderr(io.StringIO()): + server.run() + + self.assertEqual(stdout.getvalue(), "") + append_error_log.assert_called_once() + + +if __name__ == "__main__": + unittest.main()