Дата: 2026-10-24
Статус: основний OOP-рефакторинг реалізовано (див. §0); ручний smoke і тести — попереду
Обсяг: Python-частина (~2 236 рядків) + GhostWindow.cpp (520 рядків, не чіпаємо)
Зроблено:
- Нова структура:
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) — окреме рішення, у межі цього рефакторингу не входило.
- Прибрати дефекти, які зараз замасковані дублюванням і
except: pass. - Розділити UI, бізнес-логіку та WinAPI-шар так, щоб зміна поведінки робилася в одному місці.
- Зменшити
main_window.pyз 948 до ≤ 350 рядків без втрати функціональності. - Зробити проєкт придатним для тестів і статичного аналізу.
Критерій успіху: після кожного етапу застосунок запускається і проходить ручний smoke-чеклист (§9.2); поведінка UI не змінюється, крім виправлених дефектів.
- Логіка
GhostWindow.cpp(інжекція, subclassing, cursor lock у цільовому процесі) — тільки читаємо. - Нові фічі (окрім persistence у P3, як окремо позначеної).
- Переписування UI/UX, зміна дизайну.
- Нові залежності (крім dev-інструментів:
ruff,pyright).
| Файл | Рядків | Роль | Основна проблема |
|---|---|---|---|
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 с) |
Severity: S1 — ламає роботу, S2 — помітно дратує / деградує, S3 — борг.
- Де:
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.
- Де:
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.
- Де:
_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.
- Де:
_set_statusmain_window.py:317-326. - Симптом: повний style re-polish віджета кожні 2 с і на кожну подію.
- Фікс: dynamic property
kind+ правило в QSS +unpolish/polish. Задача 1.5.
- Де: 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.
- Де: весь життєвий цикл; єдиний вихід —
quit_app:908-948. - Симптом:
WDA_EXCLUDEFROMCAPTUREтримається на HWND до закриття вікна. Якщо GhostWindow вбити через Task Manager, цільові вікна назавжди лишаються невидимими для OBS, а GUI про них уже не знає (стан у пам'яті). - Фікс: P3 — JSON-сховище активних записів + re-attach при старті; окремо обговорити. Задача 3.7.
- Де:
_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.
- Де:
hide_target:538-543кидає помилку, якщо приховано 0 вікон;_hide_process:829у тій самій ситуації рапортує"Hidden 0 window(s)"зі статусомok. - Симптом: одна й та сама операція по-різному поводиться залежно від того, натиснули кнопку чи контекстне меню.
- Фікс: один
StealthService.hide(). Задача 2.2.
- Де:
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.
- Де: англійський UI + українські статуси/тултіпи:
:303,:312,:539,:627,:694,:898. - Фікс: обрати одну мову, винести рядки в
strings.py. Задача 3.3, рішення Q1.
- Де:
GhostWindow.cpp:358-361експортуєGhostVersion, але Python його ніколи не викликає. - Симптом: користувач зі старою
GhostWindow.dllотримує дивну поведінку pin/cursor-lock без жодного натяку на причину. - Фікс:
StealthService.ensure_injected()звіряєGhostVersionз очікуваною константою. Задача 2.7.
- Де:
winapi.py:95-192іcursor_lock.py:76-116; уcursor_lock.py:20-28вже описаний реальний зламаний кейс (POINT). - Фікс: усі
argtypes/restype— тільки вwinapi.py. Задача 3.1.
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.
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.
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: ...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.
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-потоці — жодних замків.
Правило: один комміт = одна задача, після кожної — застосунок запускається.
- 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, targetpy310) +pyrightbasic. Без нових runtime-залежностей. - 0.4 Зафіксувати smoke-чеклист §9.2 і прогнати його на поточному коді (baseline).
- 1.1
tasks.py. Замінити 11threading.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.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.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 (де лежить лог).
- Python ≥ 3.10 (без PEP 695-синтаксису, щоб не ламати заявлену сумісність).
- 4 пробіли, м'який ліміт 100 символів.
- Публічні функції — з анотаціями типів;
core/*без Qt. - Заборонено
except Exception: pass. Мінімум —logger.exception(...); краще вузький тип. - UI-рядки — тільки через
strings.py. - Коміти — Conventional Commits (як уже в історії:
refactor(stealth): ...). - Нових runtime-залежностей не додаємо.
| Ризик | Імовірність | Мітигація |
|---|---|---|
| Гонки даних після переносу в потоки | середня | 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) |
ruff check .pyrightpytest -qpython -m compileall .
| # | Крок | Очікування |
|---|---|---|
| 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 |
містить старт/операції/помилки, без трейсбеків «нормального» потоку |
| Дефект | Задача |
|---|---|
| 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 |
Кожна задача — окремий коміт. Нові модулі створюються поруч зі старими, старі виклики перемикаються поступово; у разі регресії достатньо git revert одного коміту. Гілку радимо вести окремо (refactor/p0-p1), щоб main лишався робочим.
- 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.