-
Notifications
You must be signed in to change notification settings - Fork 0
Add LINE version detection and ChatWindow mode support #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,11 +23,155 @@ | |
| import comtypes | ||
| import re | ||
| import time | ||
| import configparser | ||
| from ._virtualWindow import VirtualWindow | ||
| import addonHandler | ||
|
|
||
| addonHandler.initTranslation() | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # LINE installation info — version detection and window type classification | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _getLineDataDir(): | ||
| """Return the LINE data directory path, or None if not found.""" | ||
| localAppData = os.environ.get("LOCALAPPDATA", "") | ||
| if not localAppData: | ||
| return None | ||
| lineDataDir = os.path.join(localAppData, "LINE", "Data") | ||
| if os.path.isdir(lineDataDir): | ||
| return lineDataDir | ||
| return None | ||
|
|
||
|
|
||
| def _readLineVersion(): | ||
| """Read the installed LINE version from LINE.ini. | ||
|
|
||
| Returns the version string (e.g. '26.1.0.3865') or None. | ||
| """ | ||
| dataDir = _getLineDataDir() | ||
| if not dataDir: | ||
| return None | ||
| iniPath = os.path.join(dataDir, "LINE.ini") | ||
| if not os.path.isfile(iniPath): | ||
| return None | ||
| try: | ||
| parser = configparser.ConfigParser() | ||
| parser.read(iniPath, encoding="utf-8") | ||
| return parser.get("global", "last_updated_version", fallback=None) | ||
| except Exception: | ||
| return None | ||
|
|
||
|
|
||
| def _readLineLanguage(): | ||
| """Read the LINE UI language from installLang.ini. | ||
|
|
||
| Returns the language code (e.g. 'zh-TW') or None. | ||
| """ | ||
| dataDir = _getLineDataDir() | ||
| if not dataDir: | ||
| return None | ||
| iniPath = os.path.join(dataDir, "installLang.ini") | ||
| if not os.path.isfile(iniPath): | ||
| return None | ||
| try: | ||
| parser = configparser.ConfigParser() | ||
| parser.read(iniPath, encoding="utf-8") | ||
| return parser.get("General", "installLang", fallback=None) | ||
| except Exception: | ||
| return None | ||
|
|
||
|
|
||
| # Window type classification — | ||
| # LINE has two main window modes: | ||
| # "AllInOneWindow" — sidebar (chat list) + chat area in one window | ||
| # "ChatWindow" — standalone chat window (no sidebar) | ||
| # In ChatWindow mode, all list items are message items (no sidebar heuristic needed). | ||
|
|
||
| # Cache to avoid repeated window classification within the same focus cycle. | ||
| _windowTypeCache = { | ||
| "hwnd": None, | ||
| "type": None, # "allinone", "chat", or "unknown" | ||
| "expiresAt": 0.0, | ||
| } | ||
| _WINDOW_TYPE_CACHE_TTL = 2.0 # seconds | ||
|
|
||
|
|
||
| def _classifyLineWindow(hwnd=None): | ||
| """Classify the current LINE window as 'allinone', 'chat', or 'unknown'. | ||
|
|
||
| Uses window dimensions as heuristic: | ||
| - AllInOneWindow is wider (has sidebar ~250-350px + chat area) | ||
| - ChatWindow is narrower (chat area only, typically < 500px wide) | ||
| Also checks UIA tree for sidebar list presence as a secondary signal. | ||
| """ | ||
| global _windowTypeCache | ||
|
|
||
| if hwnd is None: | ||
| hwnd = ctypes.windll.user32.GetForegroundWindow() | ||
| if not hwnd: | ||
| return "unknown" | ||
|
|
||
| now = time.monotonic() | ||
| cache = _windowTypeCache | ||
| if cache["hwnd"] == int(hwnd) and cache["expiresAt"] > now: | ||
| return cache["type"] | ||
|
|
||
| windowType = "unknown" | ||
| try: | ||
| rect = ctypes.wintypes.RECT() | ||
| ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)) | ||
| winWidth = rect.right - rect.left | ||
|
|
||
| # AllInOneWindow typically has sidebar (~250-350px) + chat area (~400px+) | ||
| # so total width >= ~600px. ChatWindow is standalone chat, narrower. | ||
| # However, users can resize. Use a soft threshold + child window count. | ||
| if winWidth >= 600: | ||
| # Likely AllInOneWindow, but could be a wide ChatWindow. | ||
| # Check for the presence of multiple pane children (sidebar + chat) | ||
| # via quick UIA check. | ||
| try: | ||
| handler = UIAHandler.handler | ||
| if handler: | ||
| walker = handler.clientObject.RawViewWalker | ||
| rootCond = handler.clientObject.CreatePropertyCondition( | ||
| 30003, 50033 # ControlType == Pane | ||
| ) | ||
| rootEl = handler.clientObject.ElementFromHandle(hwnd) | ||
| if rootEl: | ||
| panes = rootEl.FindAll(2, rootCond) # TreeScope.Children=2 | ||
| paneCount = panes.Length if panes else 0 | ||
| if paneCount >= 2: | ||
| windowType = "allinone" | ||
| else: | ||
| # Single pane or no panes — could be ChatWindow | ||
| # at larger size | ||
| windowType = "allinone" if winWidth >= 700 else "chat" | ||
| except Exception: | ||
| # Fallback: wide window is likely AllInOneWindow | ||
| windowType = "allinone" | ||
|
Comment on lines
+135
to
+153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
當 對於寬度 ≥ 600px 的視窗,開發者的意圖是回退到 建議明確處理 handler = UIAHandler.handler
if handler:
rootCond = handler.clientObject.CreatePropertyCondition(
30003, 50033 # ControlType == Pane
)
rootEl = handler.clientObject.ElementFromHandle(hwnd)
if rootEl:
panes = rootEl.FindAll(2, rootCond)
paneCount = panes.Length if panes else 0
if paneCount >= 2:
windowType = "allinone"
else:
windowType = "allinone" if winWidth >= 700 else "chat"
else:
windowType = "allinone" if winWidth >= 700 else "chat"
else:
windowType = "allinone"Prompt To Fix With AIThis is a comment left during a code review.
Path: addon/appModules/line.py
Line: 135-153
Comment:
**`handler` 或 `rootEl` 為 `None` 時寬視窗被誤分類為 `"unknown"`**
當 `UIAHandler.handler` 為 `None`(UIA 尚未就緒)或 `ElementFromHandle` 返回 `None` 時,對應的 `if` 分支直接被跳過,不會觸發 `except Exception`,因此 `windowType` 維持初始值 `"unknown"`。
對於寬度 ≥ 600px 的視窗,開發者的意圖是回退到 `"allinone"`(如 `except` 分支的注釋所示)。但 `handler is None` 或 `rootEl is None` 這兩個路徑繞過了 `except`,直接讓 `windowType = "unknown"` 被快取。後續 `_isChatWindowMode()` 回傳 `False`,導致 `_queryAndSpeakUIAFocus` 中 ChatWindow 的訊息項目回退到位置像素判斷,`isMessageItem` 永遠為 `False`,copy-first 讀取功能失效。
建議明確處理 `None` 路徑:
```python
handler = UIAHandler.handler
if handler:
rootCond = handler.clientObject.CreatePropertyCondition(
30003, 50033 # ControlType == Pane
)
rootEl = handler.clientObject.ElementFromHandle(hwnd)
if rootEl:
panes = rootEl.FindAll(2, rootCond)
paneCount = panes.Length if panes else 0
if paneCount >= 2:
windowType = "allinone"
else:
windowType = "allinone" if winWidth >= 700 else "chat"
else:
windowType = "allinone" if winWidth >= 700 else "chat"
else:
windowType = "allinone"
```
How can I resolve this? If you propose a fix, please make it concise. |
||
| else: | ||
| windowType = "chat" | ||
|
|
||
| except Exception: | ||
| log.debug("_classifyLineWindow failed", exc_info=True) | ||
|
|
||
| cache["hwnd"] = int(hwnd) | ||
| cache["type"] = windowType | ||
| cache["expiresAt"] = now + _WINDOW_TYPE_CACHE_TTL | ||
| log.debug(f"LINE: window type classified as '{windowType}' (hwnd={hwnd})") | ||
| return windowType | ||
|
|
||
|
|
||
| def _isChatWindowMode(hwnd=None): | ||
| """Return True if the current LINE window is a standalone ChatWindow. | ||
|
|
||
| In ChatWindow mode, there is no sidebar — all list items are message items. | ||
| """ | ||
| return _classifyLineWindow(hwnd) == "chat" | ||
|
|
||
|
|
||
| # Sound file to play after a message is successfully sent | ||
| _SEND_SOUND_PATH = os.path.join( | ||
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), | ||
|
|
@@ -1868,6 +2012,10 @@ def _isInChatListContext(handler): | |
| if ct == 50007: # ListItem - focus is on a list item already | ||
| listEl, walker = _findListElement(handler, rawElement) | ||
| if listEl: | ||
| # In standalone ChatWindow mode there is no sidebar — | ||
| # all lists are message lists, not chat lists. | ||
| if _isChatWindowMode(): | ||
| return False, None, None, -1 | ||
| # Check if this list is in the left sidebar (chat list area) | ||
| # vs the right side (message list area) | ||
| try: | ||
|
|
@@ -1941,6 +2089,7 @@ def _findChatListFromWindow(handler): | |
|
|
||
| Fallback when _findChatListFromCache fails (stale COM ref). | ||
| Finds List elements and picks the one in the left sidebar area. | ||
| In standalone ChatWindow mode there is no sidebar, so returns (None, None). | ||
| Returns (listElement, items) or (None, None). | ||
| """ | ||
| global _chatListSearchField | ||
|
|
@@ -1949,6 +2098,10 @@ def _findChatListFromWindow(handler): | |
| if not hwnd: | ||
| return None, None | ||
|
|
||
| # Standalone ChatWindow has no sidebar — no chat list to find | ||
| if _isChatWindowMode(hwnd): | ||
| return None, None | ||
|
|
||
| # Get window rect to identify sidebar area | ||
| wndRect = ctypes.wintypes.RECT() | ||
| ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(wndRect)) | ||
|
|
@@ -2208,24 +2361,28 @@ def _queryAndSpeakUIAFocus(): | |
| # Only use copy-first for message list items. | ||
| if ct == 50007: # ListItem | ||
| isMessageItem = False | ||
| try: | ||
| elRect = targetElement.CurrentBoundingRectangle | ||
| elLeft = int(elRect.left) | ||
| # Get the LINE window rect | ||
| lineHwnd = ctypes.windll.user32.GetForegroundWindow() | ||
| import ctypes.wintypes as _wt | ||
| wr = _wt.RECT() | ||
| ctypes.windll.user32.GetWindowRect( | ||
| lineHwnd, ctypes.byref(wr) | ||
| ) | ||
| winWidth = int(wr.right - wr.left) | ||
| winLeft = int(wr.left) | ||
| # Message list items are in the right portion | ||
| # (element left edge > 35% of window width from left) | ||
| if winWidth > 0 and (elLeft - winLeft) > winWidth * 0.35: | ||
| isMessageItem = True | ||
| except Exception: | ||
| pass | ||
| # In standalone ChatWindow mode, all list items are message items | ||
| if _isChatWindowMode(): | ||
| isMessageItem = True | ||
| else: | ||
| try: | ||
| elRect = targetElement.CurrentBoundingRectangle | ||
| elLeft = int(elRect.left) | ||
| # Get the LINE window rect | ||
| lineHwnd = ctypes.windll.user32.GetForegroundWindow() | ||
| import ctypes.wintypes as _wt | ||
| wr = _wt.RECT() | ||
| ctypes.windll.user32.GetWindowRect( | ||
| lineHwnd, ctypes.byref(wr) | ||
| ) | ||
| winWidth = int(wr.right - wr.left) | ||
| winLeft = int(wr.left) | ||
| # Message list items are in the right portion | ||
| # (element left edge > 35% of window width from left) | ||
| if winWidth > 0 and (elLeft - winLeft) > winWidth * 0.35: | ||
| isMessageItem = True | ||
| except Exception: | ||
| pass | ||
| if isMessageItem: | ||
| log.info( | ||
| f"LINE UIA focus: ct={ct} (message ListItem), " | ||
|
|
@@ -3321,9 +3478,14 @@ class AppModule(appModuleHandler.AppModule): | |
| def __init__(self, *args, **kwargs): | ||
| super().__init__(*args, **kwargs) | ||
| VirtualWindow.initialize() | ||
| # Read and cache LINE installation info | ||
| self._lineVersion = _readLineVersion() | ||
| self._lineLanguage = _readLineLanguage() | ||
| log.info( | ||
| f"LINE AppModule loaded for process: {self.processID}, " | ||
| f"exe: {self.appName}" | ||
| f"exe: {self.appName}, " | ||
| f"lineVersion: {self._lineVersion}, " | ||
| f"lineLanguage: {self._lineLanguage}" | ||
| ) | ||
|
|
||
| def chooseNVDAObjectOverlayClasses(self, obj, clsList): | ||
|
|
@@ -5820,6 +5982,38 @@ def script_readChatRoomName(self, gesture): | |
| log.warning(f"LINE readChatRoomName error: {e}", exc_info=True) | ||
| ui.message(_("讀取聊天室名稱錯誤: {error}").format(error=e)) | ||
|
|
||
| @script( | ||
| # Translators: Description of a script to report LINE version and window type | ||
| description=_("報告 LINE 版本與視窗類型"), | ||
| gesture="kb:NVDA+shift+v", | ||
| category="LINE Desktop", | ||
| ) | ||
| def script_reportLineInfo(self, gesture): | ||
| """Report LINE version, language, and current window type.""" | ||
| parts = [] | ||
| ver = self._lineVersion | ||
| if ver: | ||
| # Translators: Reported LINE version | ||
| parts.append(_("LINE 版本: {version}").format(version=ver)) | ||
| else: | ||
| parts.append(_("LINE 版本: 未知")) | ||
| lang = self._lineLanguage | ||
| if lang: | ||
| parts.append(_("語言: {language}").format(language=lang)) | ||
|
keyang556 marked this conversation as resolved.
|
||
| winType = _classifyLineWindow() | ||
| typeNames = { | ||
| "allinone": _("主視窗(含側邊欄)"), | ||
| "chat": _("獨立聊天視窗"), | ||
| "unknown": _("未知視窗"), | ||
| } | ||
| # Translators: Reported LINE window type | ||
| parts.append(_("視窗類型: {type}").format( | ||
| type=typeNames.get(winType, winType) | ||
| )) | ||
| msg = ", ".join(parts) | ||
| ui.message(msg) | ||
| log.info(f"LINE info: {msg}") | ||
|
|
||
| def _pollFileDialog(self): | ||
| """Poll to detect when the file dialog closes, then resume addon. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
configparser.ConfigParser預設對區段名稱(section)不做大小寫折疊(只對選項名稱做str.lower)。如果 LINE.ini 實際上使用[Global](首字大寫,Windows INI 慣例),parser.get("global", ...)將找不到該區段並靜默回傳None,導致版本號永遠顯示為「未知」。建議在解析前統一降低區段名稱的大小寫,或遍歷所有區段找到正確的名稱:
Prompt To Fix With AI