Skip to content

Latest commit

 

History

History
431 lines (328 loc) · 27.8 KB

REFACTORING.md

File metadata and controls

431 lines (328 loc) · 27.8 KB

GhostWindow — аудит і план рефакторингу

Дата: 2026-10-24 Статус: основний OOP-рефакторинг реалізовано (див. §0); ручний smoke і тести — попереду Обсяг: Python-частина (~2 236 рядків) + GhostWindow.cpp (520 рядків, не чіпаємо)


0. Стан реалізації

Зроблено:

  • Нова структура: core/ (Win32 + домен, без Qt-віджетів) і ui/ (Qt).
  • Класи: Process, Window, Injector, StealthService, WindowEntry/EntryStore, WindowProbe/ProbeResult, TaskRunner, WindowTableModel, HotkeyManager/HotkeySpec, MainWindow.
  • threading.Thread замінено на TaskRunner (QThreadPool, дедуплікація за ключем).
  • Важкий probe винесено з GUI-потоку; оновлення store/model коалесовані.
  • Retry-політика: pending-прапор, бекоф 5/30/300 с, FAILED після 3 невдач, ручний retry.
  • Статус — через динамічну властивість kind + ui/style.qss, без setStyleSheet на кожне оновлення.
  • Усі ctypes-декларації — в core/winapi.py; версія DLL перевіряється (EXPECTED_DLL_VERSION).
  • UI-рядки — в strings.py; логування — в ghost_debug.log (applog.py).
  • ruff check . (типовий набір правил) і python -m compileall проходять.

Не зроблено / наступні кроки:

  • Ручний smoke-чеклист §9.2 у реальній інтерактивній сесії Windows.
  • pyproject.toml (ruff/pyright), pytest, CI.
  • ui/main_window.py зараз ~990 рядків — логіку вже розділено на сервіси, але саму в'юху можна додатково рознести на MainView + контролер.
  • Persistence (3.7) — окреме рішення, у межі цього рефакторингу не входило.

1. Мета

  1. Прибрати дефекти, які зараз замасковані дублюванням і except: pass.
  2. Розділити UI, бізнес-логіку та WinAPI-шар так, щоб зміна поведінки робилася в одному місці.
  3. Зменшити main_window.py з 948 до ≤ 350 рядків без втрати функціональності.
  4. Зробити проєкт придатним для тестів і статичного аналізу.

Критерій успіху: після кожного етапу застосунок запускається і проходить ручний smoke-чеклист (§9.2); поведінка UI не змінюється, крім виправлених дефектів.

2. Що НЕ входить у межі

  • Логіка GhostWindow.cpp (інжекція, subclassing, cursor lock у цільовому процесі) — тільки читаємо.
  • Нові фічі (окрім persistence у P3, як окремо позначеної).
  • Переписування UI/UX, зміна дизайну.
  • Нові залежності (крім dev-інструментів: ruff, pyright).

3. Поточний стан

Файл Рядків Роль Основна проблема
main_window.py 948 UI + контролер + модель + потоки god-object
cursor_lock.py 244 in-process hook для власного вікна дублює ctypes-декларації з winapi.py
constants.py 238 константи + 228 рядків QSS змішані відповідальності
helpers.py 238 9 різних задач немає меж
winapi.py 192 ctypes OK, але не єдине джерело
injection.py 142 інжекція / remote call циклічна залежність від helpers
ghostwindow.py 90 entry point тричі повторена реєстрація хоткеїв
picker.py 77 оверлей вибору OK
titlebar.py 44 кастомний заголовок прямий виклик методів батька
hotkey.py 23 native event filter OK

Метрики проблем (поточні):

Показник Значення
threading.Thread(...).start() 11
except Exception 22
з них except Exception: pass 18
перевірок find_module_base(pid, DLL_NAME) 16
дубльованих пошуків запису pid + hwnd 5
сигналів у GhostWindow 8
найдовший метод (_build_ui) 163 рядки
setStyleSheet() на кожне оновлення статусу 1 (кожні 2 с)

4. Дефекти

Severity: S1 — ламає роботу, S2 — помітно дратує / деградує, S3 — борг.

D1. Потоковий шторм у вотчдозі (S1)

  • Де: main_window.py:414-432 (_auto_refresh), :446-453 (_hide_new_async).
  • Симптом: якщо StealthHide для вікна стабільно падає (UWP, антічит, SetWindowDisplayAffinity не підтримується), кожні 2 секунди створюється новий потік на те саме HWND. Нескінченно, без видимого ефекту, з накопиченням навантаження.
  • Причина: newly_hidden формується за known_hwnds + stealth_is_active(). Невдала спроба не додає hwnd ні в self.injected, ні в pending-множину, а stealth_is_active() лишається False.
  • Фікс: pending-set + експоненційний бекоф (5 с → 30 с → 5 хв), після N невдач — стан failed у таблиці й припинення спроб до ручного Refresh. Задача 1.3.

D2. Важкий WinAPI-скан на GUI-потоці (S1)

  • Де: main_window.py:385-423 (кожні 2 с), :659-673 (refresh_list).
  • Симптом: підвисання інтерфейсу при великій кількості вікон/процесів.
  • Причина: find_module_base = CreateToolhelp32Snapshot на кожен запис, enum_process_windows = повний EnumWindows на кожен прихований PID, OpenProcess на кожен запис — усе синхронно в GUI-потоці.
  • Фікс: фоновий Watcher (QtCore-only) + застосування готового снапшота в GUI-потоці. Задача 1.2.

D3. O(N²) перебудова таблиці (S1)

  • Де: _register_injected :335-352 викликає refresh_list() на кожне вікно; refresh_list :659-706 сканує всі записи.
  • Симптом: приховування 10 вікон браузера → 10 сигналів → 10 повних сканів і 10 повних перебудов QTableWidget; блимання, злітає скрол.
  • Фікс: коалесинг (QTimer.singleShot(0) / dirty-flag) + QAbstractTableModel з точковим dataChanged. Задачі 1.2, 2.4.

D4. setStyleSheet на кожне оновлення статусу (S2)

  • Де: _set_status main_window.py:317-326.
  • Симптом: повний style re-polish віджета кожні 2 с і на кожну подію.
  • Фікс: dynamic property kind + правило в QSS + unpolish/polish. Задача 1.5.

D5. Ковтання помилок (S2)

  • Де: 18 місць, зокрема _auto_refresh:434, _rehide_async:443, _hide_new_async:452, _install_cursor_lock:247, _force_foreground_now:278, hotkey.py:20-22 (traceback у stdout, а консоль прихована hide_console()).
  • Симптом: вотчдоз «тихо вмирає», діагностика неможлива. У репо вже є порожній ghost_debug.log, тобто логування планувалося.
  • Фікс: logging у файл + logger.exception, вузькі except (OSError, ValueError). Задача 1.4.

D6. Немає відновлення після краху (S1)

  • Де: весь життєвий цикл; єдиний вихід — quit_app :908-948.
  • Симптом: WDA_EXCLUDEFROMCAPTURE тримається на HWND до закриття вікна. Якщо GhostWindow вбити через Task Manager, цільові вікна назавжди лишаються невидимими для OBS, а GUI про них уже не знає (стан у пам'яті).
  • Фікс: P3 — JSON-сховище активних записів + re-attach при старті; окремо обговорити. Задача 3.7.

D7. Потрійне обчислення стану (S2)

  • Де: _auto_refresh:395-404, refresh_list:669-671, _mark_visible:354-359; поля state, visible, pinned, last_rehide оновлюються різними шляхами.
  • Симптом: фантомні re-hide, неправильний бейдж стану, «застряглий» pin.
  • Фікс: єдине джерело істини — EntryStore, оновлюється лише застосуванням ProbeResult. Задачі 1.2, 2.1.

D8. Розбіжність дубльованих шляхів (S2)

  • Де: hide_target:538-543 кидає помилку, якщо приховано 0 вікон; _hide_process:829 у тій самій ситуації рапортує "Hidden 0 window(s)" зі статусом ok.
  • Симптом: одна й та сама операція по-різному поводиться залежно від того, натиснули кнопку чи контекстне меню.
  • Фікс: один StealthService.hide(). Задача 2.2.

D9. Застарілий target (S2)

  • Де: target_hwnd/target_pid :49-51 встановлюються в _on_pick_done:473-475 і більше ніколи не скидаються; _auto_refresh:389-393 видаляє мертві записи зі списку, але не target.
  • Симптом: після закриття цільового вікна info_lbl, кнопки Hide/Show/Pin лишаються активними й ведуть у нікуди; повідомлення про помилку може бути українською («Жодне вікно не вдалося приховати»).
  • Фікс: _clear_target() + єдиний _update_target_ui(). Задача 1.6.

D10. Мова UI змішана (S3)

  • Де: англійський UI + українські статуси/тултіпи: :303, :312, :539, :627, :694, :898.
  • Фікс: обрати одну мову, винести рядки в strings.py. Задача 3.3, рішення Q1.

D11. Версія DLL не перевіряється (S2)

  • Де: GhostWindow.cpp:358-361 експортує GhostVersion, але Python його ніколи не викликає.
  • Симптом: користувач зі старою GhostWindow.dll отримує дивну поведінку pin/cursor-lock без жодного натяку на причину.
  • Фікс: StealthService.ensure_injected() звіряє GhostVersion з очікуваною константою. Задача 2.7.

D12. ctypes-декларації у двох місцях (S2)

  • Де: winapi.py:95-192 і cursor_lock.py:76-116; у cursor_lock.py:20-28 вже описаний реальний зламаний кейс (POINT).
  • Фікс: усі argtypes/restype — тільки в winapi.py. Задача 3.1.

D13. Мертвий код і циклічна залежність (S3)

  • helpers.enum_owned_windows (:166-181) не викликається; разом з ним мертві GW_OWNER (winapi.py:66), user32.GetWindow (:189-190).
  • WDA_NONE (winapi.py:30), user32.PeekMessageW (:182-184) не використовуються з Python.
  • injection.py ліниво імпортує helpers у :57, :99, :131 — ознака неправильного напрямку залежностей.
  • Фікс: задачі 3.1, 2.3.

5. Цільова архітектура

ghostwindow/
├── ghostwindow.py          # entry point: QApplication, hotkeys, launcher (~70)
├── strings.py              # усі UI-рядки (Q1)
├── style.qss               # перенесений STYLESHEET
├── tasks.py                # TaskRunner поверх QThreadPool (~70)
├── core/
│   ├── winapi.py           # ЄДИНЕ місце для ctypes + констант (~210)
│   ├── procs.py            # pid, назва, alive, find_module_base (~80)
│   ├── windows.py          # enum_*, title/root, pin, stealth_is_active (~120)
│   ├── injector.py         # inject / remote call / detach (~130)
│   ├── stealth.py          # StealthService: hide/show/pin/detach (~150)
│   ├── models.py           # WindowEntry, State, EntryStore (~90)
│   └── watcher.py          # фоновий probe → ProbeResult (~110)
└── ui/
    ├── main_window.py      # в'юха + склейка з сервісами (~300)
    ├── table_model.py      # QAbstractTableModel (~100)
    ├── titlebar.py         # + сигнал closeRequested
    ├── picker.py           # без змін
    └── cursor_lock.py      # без змін, крім винесених argtypes

Правила залежностей:

ui/*            → core/*, tasks, strings
core/stealth.py → core/{injector,windows,procs}, tasks
core/watcher.py → core/{procs,windows}         (лише QtCore)
core/injector.py→ core/{winapi,procs}
core/{procs,windows} → core/winapi

core/winapi.py, core/procs.py, core/windows.py, core/injector.py, core/stealth.py не імпортують PySide6. Це дозволяє тестувати їх без Qt.

5.1 Модель даних

class State(str, Enum):
    HIDDEN = "hidden"
    DETACHED = "detached"
    FAILED = "failed"          # D1: після вичерпання повторів

@dataclass(slots=True)
class WindowEntry:
    pid: int
    hwnd: int
    name: str
    title: str
    state: State = State.HIDDEN
    pinned: bool = False
    visible: bool = False
    last_rehide: float = 0.0
    failures: int = 0

class EntryStore:
    """Єдине джерело істини. Мутації — тільки з GUI-потоку."""
    def find(self, pid: int, hwnd: int) -> WindowEntry | None: ...
    def upsert(self, entry: WindowEntry) -> bool: ...
    def remove(self, pid: int, hwnd: int) -> bool: ...
    def prune(self, alive: frozenset[tuple[int, int]]) -> bool: ...
    def pids(self) -> set[int]: ...
    def __iter__(self) -> Iterator[WindowEntry]: ...
    def __len__(self) -> int: ...

5.2 Сервісний шар

class StealthError(RuntimeError):
    """Помилка з готовим для UI текстом (мапінг Win32-кодів)."""

class StealthService:
    def ensure_injected(self, pid: int) -> None: ...   # + перевірка GhostVersion (D11)
    def hide(self, pid: int, hwnds: Sequence[int]) -> int: ...
    def show(self, pid: int, hwnds: Sequence[int]) -> int: ...
    def pin(self, pid: int, hwnd: int, enabled: bool) -> bool: ...
    def detach(self, pid: int) -> bool: ...

Усі 16 перевірок find_module_base(pid, DLL_NAME) і всі try/except навколо call_export живуть тут. main_window більше не знає про DLL_NAME/DLL_PATH.

5.3 Виконавці та вотчдоз

class TaskRunner(QObject):
    """QThreadPool + сигнали. submit(key, fn) ігнорує дубль того самого key."""
    done: Signal(str, object)
    failed: Signal(str, str)
    def submit(self, key: str, fn: Callable[[], Any]) -> bool: ...

class Watcher(QThread):
    snapshot = Signal(object)          # ProbeResult
    def set_targets(self, pairs: list[tuple[int, int]]) -> None: ...  # під QMutex
    # інтервал 2–3 с, всередині — probe() з core/

@dataclass(frozen=True, slots=True)
class ProbeResult:
    alive: frozenset[tuple[int, int]]
    injected: dict[int, bool]
    pinned: dict[tuple[int, int], bool]
    unhidden: frozenset[tuple[int, int]]
    new_windows: tuple[tuple[int, int, str], ...]

Потік даних:

Watcher (worker) ──ProbeResult──► MainWindow._apply_snapshot (GUI)
                                      │
                                      ├─ EntryStore.prune/update
                                      ├─ TableModel.dataChanged
                                      └─ TaskRunner.submit("rehide:<pid>:<hwnd>", ...)

EntryStore і QAbstractTableModel редагуються тільки в GUI-потоці — жодних замків.


6. Покроковий план

Правило: один комміт = одна задача, після кожної — застосунок запускається.

Фаза 0 — база (~1–2 год)

  • 0.1 applog.py: logging у ghost_debug.log (RotatingFileHandler, 1 МБ × 3), рівень з GHOSTWINDOW_DEBUG.
  • 0.2 .gitignore: додати *.log; git rm --cached ghost_debug.log.
  • 0.3 pyproject.toml: ruff (line-length 100, target py310) + pyright basic. Без нових runtime-залежностей.
  • 0.4 Зафіксувати smoke-чеклист §9.2 і прогнати його на поточному коді (baseline).

Фаза 1 — P0, коректність (~0.5 дня)

  • 1.1 tasks.py. Замінити 11 threading.Thread на TaskRunner.submit(key, fn). Ключі: hide:<pid>:<hwnds>, show:<pid>:<hwnds>, pin:..., detach:<pid>:<hwnd>, probe, quit.
  • 1.2 Коалесинг + Watcher. Важкий probe — у WindowProbe, GUI лише застосовує ProbeResult; модель оновлюється одним set_entries.
  • 1.3 Retry-політика (D1). pending-set, бекоф 5/30/300 с, State.FAILED після 3 невдач, ручний retry через контекстне меню.
  • 1.4 Логування (D5). logger.exception/logger.warning замість pass; HotkeyFilter — у лог, не в stdout.
  • 1.5 Статус без setStyleSheet (D4). QLabel#Status[kind="err"] { ... } + setProperty + unpolish/polish.
  • 1.6 Target lifecycle (D9). _clear_target(), _update_target_ui(); скидання при зникненні HWND.

Acceptance фази 1:

  • grep -c "threading.Thread" *.py → 0 поза tasks.py.
  • grep -c "except Exception:\s*$" main_window.py → 0 без logger.
  • Приховування 10 вікон → у лог один refresh applied, не 10.
  • Вікно, яке не піддається SetWindowDisplayAffinity, дає ≤ 3 спроби й стан failed у таблиці.
  • Idle CPU не зріс; GUI не фризить під час probe.

Фаза 2 — P1, структура (~1 день)

  • 2.1 core/models.py. WindowEntry + EntryStore; прибрати всі магічні ключі словників і 5 дубльованих предикатів.
  • 2.2 core/stealth.py. StealthService; злити hide_target/_hide_process, show_target/_show_entry/_show_process/detach_selected, toggle_pin_target/_pin_entry, _rehide_async/_hide_new_async.
  • 2.3 Розділити helpers.py на core/procs.py + core/windows.py; вбити циклічний імпорт injection ↔ helpers.
  • 2.4 ui/table_model.py. QAbstractTableModel; вибір зберігається за (pid, hwnd), а не за індексом рядка.
  • 2.5 Рознести ui/main_window.py. _build_ui → _build_header/_build_primary_actions/_build_target_info/_build_options/_build_target_actions/_build_status/_build_table/_build_footer; логіку — у сервіси.
  • 2.6 Один канал статусу. Публічний MainWindow.set_status(str, kind).
  • 2.7 Версійна перевірка DLL (D11). GhostVersion у ensure_injected; константа EXPECTED_DLL_VERSION = 4.
  • 2.8 ghostwindow.py. Хоткеї — таблицею HotkeySpec + callback; публічний win.set_status(...).
  • 2.9 titlebar.py. Сигнал closeRequested замість прямого parent.hide_self.

Acceptance фази 2:

  • main_window.py ≤ 350 рядків; table_model.py ≤ 120. (main_window поки ~990 — див. §0)
  • call_export( згадується лише в core/injector.py і core/stealth.py.
  • find_module_base( — лише в core/procs.py + виклики з stealth.
  • Жодного e["pid"]-стилю доступу.
  • Таблиця не перебудовується при зміні лише state/pin (перевірити dataChanged у лог-дебазі).

Фаза 3 — P2, полірування (~0.5 дня)

  • 3.1 Усі ctypes — у winapi.py; видалити enum_owned_windows, GW_OWNER, GetWindow, WDA_NONE, PeekMessageW, рудиментарні # NEW.
  • 3.2 STYLESHEET → ui/style.qss, підключення з ghostwindow.py; прибрати інлайн-стиль з _on_pick_done.
  • 3.3 strings.py за рішенням Q1; прибрати змішування мов.
  • 3.4 Type hints на публічних API + docstrings для core/*.
  • 3.5 Тести: pytest для EntryStore, ProbeResult-apply, мапінгу Win32-помилок, TableModel (без реального WinAPI — monkeypatch).
  • 3.6 CI (GitHub Actions, windows-latest): ruff check, pyright, pytest.
  • 3.7 (опційно, окреме рішення) Persistence: %LOCALAPPDATA%\GhostWindow\state.json, re-attach при старті (D6).
  • 3.8 README: оновити структуру проєкту, вимоги, розділ Troubleshooting (де лежить лог).

7. Конвенції

  • Python ≥ 3.10 (без PEP 695-синтаксису, щоб не ламати заявлену сумісність).
  • 4 пробіли, м'який ліміт 100 символів.
  • Публічні функції — з анотаціями типів; core/* без Qt.
  • Заборонено except Exception: pass. Мінімум — logger.exception(...); краще вузький тип.
  • UI-рядки — тільки через strings.py.
  • Коміти — Conventional Commits (як уже в історії: refactor(stealth): ...).
  • Нових runtime-залежностей не додаємо.

8. Ризики

Ризик Імовірність Мітигація
Гонки даних після переносу в потоки середня single-writer: EntryStore/модель лише в GUI; worker віддає immutable ProbeResult
Qt-об'єкти вмирають раніше за QRunnable середня WorkerSignals як QObject, QThreadPool.waitForDone() на виході, aboutToQuit
Зміна поведінки pin/stealth через об'єднання шляхів середня звіряти з GhostWindow.cpp, smoke-матриця §9.2 після кожної задачі
Великий diff у main_window.py висока переносити інкрементально, спершу сервіси, в'юху — останньою
Зламати self-stealth власного вікна низька apply_stealth_to_hwnd лишається без змін, перевіряти «Hide GhostWindow» вручну
PySide6 на Python 3.14 низька перевірити wheels і підняти нижню межу в requirements.txt (зараз >=6.5.0)

9. Перевірка

9.1 Автоматична

  • ruff check .
  • pyright
  • pytest -q
  • python -m compileall .

9.2 Ручний smoke-чеклист (Windows, після кожної фази)

# Крок Очікування
1 python ghostwindow.py вікно з'являється, статус Ready
2 Pick → 5 с → вибір Notepad у info_lbl назва, PID, HWND
3 Hide зникає з таскбару/Alt+Tab, OBS не бачить
4 Show повертається в таскбар і в захоплення
5 Pin / Unpin (кнопка і Ctrl+Shift+F4) topmost перемикається, бейдж синхронний
6 Bring to front (Ctrl+Shift+F5) вікно активується
7 Hide all of process (браузер, ≥ 5 вікон) всі приховані, таблиця без блимання
8 Перезапуск цільового застосунку запис зникає з таблиці, target очищається
9 Вікно з невдалим SetWindowDisplayAffinity ≤ 3 спроби, стан failed, без шторму потоків
10 Restore (detach DLL) вікно відновлене, DLL вивантажена
11 Quit усі вікна відновлені, DLL вивантажені, процес завершився
12 ghost_debug.log містить старт/операції/помилки, без трейсбеків «нормального» потоку

10. Матриця «дефект → задача»

Дефект Задача
D1 потоковий шторм 1.3
D2 GUI-скан 1.2
D3 O(N²) таблиця 1.2, 2.4
D4 setStyleSheet 1.5
D5 ковтання помилок 0.1, 1.4
D6 немає recovery 3.7
D7 потрійний стан 1.2, 2.1
D8 розбіжність hide 2.2
D9 застарілий target 1.6
D10 мова 3.3
D11 версія DLL 2.7
D12 ctypes 3.1
D13 мертвий код 2.3, 3.1

11. Відкат

Кожна задача — окремий коміт. Нові модулі створюються поруч зі старими, старі виклики перемикаються поступово; у разі регресії достатньо git revert одного коміту. Гілку радимо вести окремо (refactor/p0-p1), щоб main лишався робочим.


12. Відкриті питання

  • Q1. Мова UI. Англійська (як README) чи українська? Рекомендація: англійська, пізніше — tr().
  • Q2. Пакетна структура (core/, ui/) чи плоскі модулі? Рекомендація: пакети, бо межа шарів інакше розмивається.
  • Q3. Чи потрібен persistence стану (D6)? Якщо так — окрема задача після P1.
  • Q4. Мінімальні версії. Чи можна підняти PySide6 до версії з wheels для Python 3.14 і зафіксувати requires-python?
  • Q5. CI. GitHub Actions доступний для репозиторію? Якщо ні — обмежимось локальним ruff/pytest.