Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 213 additions & 19 deletions addon/appModules/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +60 to +62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 INI 區段名稱大小寫可能不符

configparser.ConfigParser 預設對區段名稱(section)不做大小寫折疊(只對選項名稱做 str.lower)。如果 LINE.ini 實際上使用 [Global](首字大寫,Windows INI 慣例),parser.get("global", ...) 將找不到該區段並靜默回傳 None,導致版本號永遠顯示為「未知」。

建議在解析前統一降低區段名稱的大小寫,或遍歷所有區段找到正確的名稱:

Suggested change
parser = configparser.ConfigParser()
parser.read(iniPath, encoding="utf-8")
return parser.get("global", "last_updated_version", fallback=None)
parser = configparser.ConfigParser()
parser.read(iniPath, encoding="utf-8")
# Try both common casings for the section name
for section in ("global", "Global", "GLOBAL"):
if parser.has_section(section):
return parser.get(section, "last_updated_version", fallback=None)
return None
Prompt To Fix With AI
This is a comment left during a code review.
Path: addon/appModules/line.py
Line: 60-62

Comment:
**INI 區段名稱大小寫可能不符**

`configparser.ConfigParser` 預設對**區段名稱(section)不做大小寫折疊**(只對選項名稱做 `str.lower`)。如果 LINE.ini 實際上使用 `[Global]`(首字大寫,Windows INI 慣例),`parser.get("global", ...)` 將找不到該區段並靜默回傳 `None`,導致版本號永遠顯示為「未知」。

建議在解析前統一降低區段名稱的大小寫,或遍歷所有區段找到正確的名稱:

```suggestion
		parser = configparser.ConfigParser()
		parser.read(iniPath, encoding="utf-8")
		# Try both common casings for the section name
		for section in ("global", "Global", "GLOBAL"):
			if parser.has_section(section):
				return parser.get(section, "last_updated_version", fallback=None)
		return None
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 handlerrootElNone 時寬視窗被誤分類為 "unknown"

UIAHandler.handlerNone(UIA 尚未就緒)或 ElementFromHandle 返回 None 時,對應的 if 分支直接被跳過,不會觸發 except Exception,因此 windowType 維持初始值 "unknown"

對於寬度 ≥ 600px 的視窗,開發者的意圖是回退到 "allinone"(如 except 分支的注釋所示)。但 handler is NonerootEl is None 這兩個路徑繞過了 except,直接讓 windowType = "unknown" 被快取。後續 _isChatWindowMode() 回傳 False,導致 _queryAndSpeakUIAFocus 中 ChatWindow 的訊息項目回退到位置像素判斷,isMessageItem 永遠為 False,copy-first 讀取功能失效。

建議明確處理 None 路徑:

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 AI
This 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.

Fix in Claude Code Fix in Codex

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__))),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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), "
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Comment thread
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.

Expand Down
Loading