popafox 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
popafox-0.6.1/PKG-INFO ADDED
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: popafox
3
+ Version: 0.6.1
4
+ Summary: Utility for patching your firefox, hide webdriver detecting
5
+ Requires-Dist: configparser>=7.2.0
6
+ Requires-Dist: jinja2>=3.1.6
7
+ Requires-Dist: loguru>=0.7.3
8
+ Requires-Dist: pillow>=13.0.0
9
+ Requires-Dist: psutil>=7.2.2
10
+ Requires-Dist: pydantic>=2.14.1
11
+ Requires-Dist: pywin32>=312 ; sys_platform == 'win32' and extra == 'windows'
12
+ Requires-Python: >=3.14
13
+ Project-URL: Homepage, https://gitflic.ru/project/kurilka/popafox
14
+ Project-URL: Github, https://github.com/TheWhatis/popafox
15
+ Provides-Extra: windows
16
+ Description-Content-Type: text/markdown
17
+
File without changes
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "popafox"
3
+ version = "0.6.1"
4
+ description = "Utility for patching your firefox, hide webdriver detecting"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "configparser>=7.2.0",
9
+ "jinja2>=3.1.6",
10
+ "loguru>=0.7.3",
11
+ "pillow>=13.0.0",
12
+ "psutil>=7.2.2",
13
+ "pydantic>=2.14.1",
14
+ ]
15
+
16
+ [project.scripts]
17
+ popafox = "popafox:main"
18
+
19
+ [project.urls]
20
+ Homepage = "https://gitflic.ru/project/kurilka/popafox"
21
+ Github = "https://github.com/TheWhatis/popafox"
22
+
23
+ [project.optional-dependencies]
24
+ windows = ["pywin32>=312 ; sys_platform == 'win32'"]
25
+
26
+ [build-system]
27
+ requires = ["uv_build>=0.12.10,<0.13.0"]
28
+ build-backend = "uv_build"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "basedpyright>=1.40.2",
33
+ "epc>=0.0.5",
34
+ "six>=1.17.0",
35
+ "twine>=7.0.0",
36
+ "watchdog>=6.0.0",
37
+ ]
38
+
39
+ [tool.uv.sources.clipik]
40
+ workspace = true
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "popafox"
3
+ version = "0.6.1"
4
+ description = "Utility for patching your firefox, hide webdriver detecting"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "configparser>=7.2.0",
9
+ "jinja2>=3.1.6",
10
+ "loguru>=0.7.3",
11
+ "pillow>=13.0.0",
12
+ "psutil>=7.2.2",
13
+ "pydantic>=2.14.1",
14
+ ]
15
+
16
+ [project.scripts]
17
+ popafox = "popafox:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.12.10,<0.13.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "basedpyright>=1.40.2",
26
+ "epc>=0.0.5",
27
+ "six>=1.17.0",
28
+ "twine>=7.0.0",
29
+ "watchdog>=6.0.0",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://gitflic.ru/project/kurilka/popafox"
34
+ Github = "https://github.com/TheWhatis/popafox"
35
+
36
+ [project.optional-dependencies]
37
+ windows = [
38
+ "pywin32>=312 ; sys_platform == 'win32'",
39
+ ]
40
+
41
+ [tool.uv.sources]
42
+ clipik = { workspace = true }
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == '__main__':
5
+ main()
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == '__main__':
5
+ main()
@@ -0,0 +1,163 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+ import signal
5
+ from loguru import logger
6
+ from pathlib import Path
7
+ from .patch import patch
8
+ from .model import MainConfig, Config
9
+ from .container import Container
10
+ from .config import write_fresh_config, read_config_file
11
+ from .logger import initialize_logger
12
+
13
+
14
+ def _add_default_options(parser: argparse.ArgumentParser):
15
+ parser.add_argument(
16
+ '--version',
17
+ action='version',
18
+ version=f"{Container.program} {Container.version}"
19
+ )
20
+
21
+ parser.add_argument(
22
+ '--config',
23
+ dest='config',
24
+ type=Path,
25
+ default=None,
26
+ metavar='PATH',
27
+ help='Force choice config file',
28
+ )
29
+
30
+ parser.add_argument(
31
+ '--log-level',
32
+ dest='log_level',
33
+ type=str,
34
+ default=None,
35
+ metavar='LEVEL',
36
+ help='Logging level, default [INFO]'
37
+ )
38
+
39
+ parser.add_argument(
40
+ '--log-dir',
41
+ dest='log_dir',
42
+ type=Path,
43
+ default=None,
44
+ metavar='PATH',
45
+ help='Force choice log directory',
46
+ )
47
+
48
+ parser.add_argument(
49
+ '--src',
50
+ dest='src',
51
+ type=Path,
52
+ default=None,
53
+ metavar='PATH',
54
+ help='Source firefox directory'
55
+ )
56
+
57
+ parser.add_argument(
58
+ '--dst',
59
+ dest='dst',
60
+ type=Path,
61
+ default=None,
62
+ metavar='PATH',
63
+ help='Destination firefox directory (patched)'
64
+ )
65
+
66
+ parser.add_argument(
67
+ '--no-confirm',
68
+ dest='no_confirm',
69
+ action='store_true',
70
+ help='Skip confirmation prompts'
71
+ )
72
+
73
+ parser.add_argument(
74
+ '-y', '--yes',
75
+ dest='assume_yes',
76
+ action='store_true',
77
+ help='Assume yes'
78
+ )
79
+
80
+
81
+ def _build_parser() -> argparse.ArgumentParser:
82
+ parser = argparse.ArgumentParser(
83
+ prog=Container.program,
84
+ description='Synchronize clipboard by network',
85
+ )
86
+
87
+ _add_default_options(parser)
88
+
89
+ return parser
90
+
91
+
92
+ def _initialize_loop(container: Container, log_prefix: str):
93
+ initialize_logger(container.config, log_prefix)
94
+
95
+ if container.fresh_config:
96
+ logger.info('Config has been freshed [{}]', container.fresh_config_path)
97
+
98
+ logger.info('Initialized config')
99
+ for key, value in container.config.model_dump().items():
100
+ logger.info('Config [{}]=[{}]', key, value)
101
+
102
+
103
+ def _main(container: Container):
104
+ _initialize_loop(container, 'main')
105
+
106
+ try:
107
+ patch(
108
+ container.config.src,
109
+ container.config.dst,
110
+ container.config.no_confirm,
111
+ container.config.assume_yes,
112
+ )
113
+ except KeyboardInterrupt:
114
+ print("Bye-bye!!!", file=sys.stderr)
115
+ sys.exit(130)
116
+
117
+
118
+ def main():
119
+ parser = _build_parser()
120
+ args = parser.parse_args()
121
+
122
+ def _shutdown_handler(signum, frame):
123
+ raise SystemExit(128 + signum)
124
+
125
+ signal.signal(signal.SIGINT, _shutdown_handler)
126
+ signal.signal(signal.SIGTERM, _shutdown_handler)
127
+
128
+ if os.name == 'nt': # Windows
129
+ config_path = Path(os.getenv('APPDATA', '~')) / 'popafox'
130
+ elif sys.platform == 'darwin': # MacOS
131
+ config_path = Path('~/Library/Application Support/Popafox').expanduser()
132
+ else: # Linux / Unix
133
+ config_path = Path('~/.config/popafox').expanduser()
134
+
135
+ config = MainConfig
136
+ config_path = config_path / 'config.json'
137
+ fresh_config_path = config_path
138
+ fresh_config: Config = write_fresh_config(fresh_config_path, config)
139
+
140
+ overrides = {
141
+ key: value
142
+ for key, value in vars(args).items()
143
+ if value is not None
144
+ }
145
+
146
+ config_path: Path = overrides.get('config', config_path)
147
+ config: Config = read_config_file(config_path, config, overrides)
148
+
149
+ try:
150
+ container = Container(
151
+ config=config,
152
+ fresh_config=fresh_config,
153
+ fresh_config_path=fresh_config_path,
154
+ config_path=config_path,
155
+ )
156
+ except Exception as e:
157
+ initialize_logger(config, args.command)
158
+ logger.critical('Error [{}] container initialization', e)
159
+ return
160
+
161
+ _main(container)
162
+
163
+ # parser.error('Command not passed, write [mux, server], for details use --help')
@@ -0,0 +1,102 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ from pathlib import Path
5
+ from .model import Config, MainConfig
6
+
7
+
8
+ def _build_def_config(config: type[Config]) -> Config:
9
+ log_level = os.getenv('POPAFOX_LOG_LEVEL', default='INFO')
10
+ log_dir = os.getenv('POPAFOX_LOG_DIR', default=None)
11
+
12
+ if log_dir: # Env POPAFOX_LOG_DIR
13
+ log_dir = Path(log_dir).expanduser()
14
+ elif os.name == 'nt': # Windows
15
+ log_dir = Path(os.getenv('APPDATA', '~')) / 'popafox'
16
+ elif sys.platform == 'darwin':
17
+ log_dir = Path('~/Library/Appliction Support/Popafox').expanduser()
18
+ else: # Linux / Unix
19
+ log_dir = Path('~/.local/popafox').expanduser()
20
+
21
+ src = os.getenv('POPAFOX_SRC', default=None)
22
+
23
+ if src:
24
+ src = Path(src).expanduser()
25
+ elif os.name == 'nt': # Windows
26
+ src = Path('C:/Program Files/Firefox')
27
+ elif sys.platform == 'darwin': # MacOS
28
+ raise RuntimeError('MacOS does not supports')
29
+ else: # Linux / Unix
30
+ src = Path('/usr/lib/firefox')
31
+
32
+ dst = os.getenv('POPAFOX_DST', default=None)
33
+
34
+ if dst:
35
+ dst = Path(dst).expanduser()
36
+ elif os.name == 'nt': # Windows
37
+ dst = Path(os.getenv('APPDATA', '~')) / 'Firefox Undetected'
38
+ elif sys.platform == 'darwin': # MacOS
39
+ dst = Path('~/Library/Application Support/Firefox Undetected').expanduser()
40
+ else: # Linux / Unix
41
+ dst = Path('~/.local/firefox_undetected').expanduser()
42
+
43
+ return config(
44
+ log_dir=log_dir,
45
+ log_level=log_level,
46
+ src=src,
47
+ dst=dst,
48
+ no_confirm=True if os.getenv('POPAFOX_NO_CONFIRM', default='False') in ('True', 't', '1', 1) else False,
49
+ assume_yes=True if os.getenv('POPAFOX_ASSUME_YES', default='False') in ('True', 't', '1', 1) else False,
50
+ )
51
+
52
+
53
+ def write_fresh_config(path: Path, config: type[Config]) -> bool:
54
+ """Создает файл конфига с нуля, если его нет."""
55
+ if path.exists():
56
+ return False
57
+
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+
60
+ config: Config = _build_def_config(config)
61
+ payload = config.model_dump(
62
+ mode='json',
63
+ include=set([
64
+ 'log_level',
65
+ 'log_dir',
66
+ 'src',
67
+ 'dst',
68
+ 'no_confirm',
69
+ 'assume_yes',
70
+ ]),
71
+ )
72
+
73
+ path.write_text(json.dumps(payload, indent=2) + '\n', encoding='utf-8')
74
+ return True
75
+
76
+
77
+ def read_config_file(
78
+ path: Path,
79
+ config: type[Config],
80
+ overrides: dict[str, object] = {},
81
+ ) -> Config:
82
+ if not path.exists():
83
+ write_fresh_config(path, config)
84
+
85
+ raw = path.read_text(encoding='utf-8')
86
+
87
+ def_config = _build_def_config(config)
88
+ merged = {**json.loads(raw), **(overrides if overrides else {})}
89
+
90
+ for key in config.model_fields:
91
+ if key in merged and merged[key]:
92
+ continue
93
+
94
+ merged[key] = getattr(def_config, key)
95
+
96
+ config = config(**merged)
97
+
98
+ config.src = config.src.expanduser()
99
+ config.dst = config.dst.expanduser()
100
+ config.log_dir = config.log_dir.expanduser()
101
+
102
+ return config
@@ -0,0 +1 @@
1
+ SEARCH_WEBDRIVER_BINARY = b"webdriver"
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+ from importlib.metadata import version, PackageNotFoundError
3
+ from pathlib import Path
4
+ import tomllib
5
+ from .model import Config
6
+
7
+
8
+ def _resolve_version() -> str:
9
+ try:
10
+ return version('fofan')
11
+ except PackageNotFoundError:
12
+ pass
13
+
14
+ # fallback: запуск из исходников
15
+ pyproject = Path(__file__).resolve().parent.parent / 'pyproject.toml'
16
+ try:
17
+ with pyproject.open('rb') as f:
18
+ data = tomllib.load(f)
19
+ return data['project']['version']
20
+ except (OSError, KeyError):
21
+ return 'unknown'
22
+
23
+
24
+ class Container:
25
+ program: str = 'fofan'
26
+ version: str = _resolve_version()
27
+
28
+ config: Config
29
+ fresh_config: bool
30
+ fresh_config_path: Path
31
+ config_path: Path
32
+
33
+ def __init__(
34
+ self,
35
+ config: Config,
36
+ fresh_config: bool,
37
+ fresh_config_path: Path,
38
+ config_path: Path,
39
+ ):
40
+ self.config = config
41
+ self.fresh_config = fresh_config
42
+ self.fresh_config_path = fresh_config_path
43
+ self.config_path = config_path
@@ -0,0 +1,149 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+ from loguru import logger
7
+
8
+
9
+ def _ask_via_tkinter(prompt: str) -> bool | None:
10
+ try:
11
+ import tkinter as tk
12
+ from tkinter import ttk
13
+ except ImportError:
14
+ return None
15
+
16
+ root = tk.Tk()
17
+ root.withdraw()
18
+
19
+ dialog = tk.Toplevel(root)
20
+ dialog.title("Confirmation")
21
+ dialog.geometry("400x150")
22
+ dialog.resizable(False, False)
23
+ dialog.attributes('-topmost', True)
24
+ dialog.focus_force()
25
+
26
+ frame = ttk.Frame(dialog, padding=16)
27
+ frame.pack(fill='both', expand=True)
28
+ ttk.Label(frame, text=prompt, wraplength=360).pack(pady=(0, 16))
29
+
30
+ result = {'answer': False}
31
+
32
+ # root.quit() закрывает mainloop, после чего мы уничтожаем окна
33
+ def _yes():
34
+ result['answer'] = True; root.quit()
35
+
36
+ def _no():
37
+ result['answer'] = False; root.quit()
38
+
39
+ btn_frame = ttk.Frame(frame)
40
+ btn_frame.pack(fill='x')
41
+ ttk.Button(btn_frame, text="Да", command=_yes, width=10).pack(side='right', padx=(8, 0))
42
+ ttk.Button(btn_frame, text="Нет", command=_no, width=10).pack(side='right')
43
+
44
+ dialog.protocol("WM_DELETE_WINDOW", _no)
45
+ dialog.bind('<Return>', lambda e: _yes())
46
+ dialog.bind('<Escape>', lambda e: _no())
47
+
48
+ # Ловим Ctrl+C прямо внутри окна Tkinter
49
+ dialog.bind('<Control-c>', lambda e: _no())
50
+
51
+ try:
52
+ root.mainloop()
53
+ except KeyboardInterrupt:
54
+ result['answer'] = False
55
+ raise
56
+
57
+ # Уничтожаем всё аккуратно
58
+ try:
59
+ root.destroy()
60
+ except Exception:
61
+ pass
62
+
63
+ return result['answer']
64
+
65
+
66
+ def _ask_via_linux_dialog(prompt: str) -> bool | None:
67
+ """Пробует zenity или kdialog (работают и в Xorg, и в Wayland)."""
68
+ # zenity: возвращает 0 при Yes, 1 при No
69
+ zenity = shutil.which("zenity")
70
+
71
+ if zenity:
72
+ try:
73
+ result = subprocess.run(
74
+ [zenity, "--question", "--ok-label=Да", "--cancel-label=Нет", f"--text={prompt}"],
75
+ capture_output=True,
76
+ )
77
+ return result.returncode == 0
78
+ except Exception as e:
79
+ logger.error('Zenity error [{}]', e)
80
+ pass
81
+ else:
82
+ logger.info('Zenity does not exists for confirmation')
83
+
84
+ # kdialog: возвращает 0 при Yes, 1 при No
85
+ kdialog = shutil.which("kdialog")
86
+ if kdialog:
87
+ try:
88
+ result = subprocess.run(
89
+ [kdialog, "--yesno", prompt],
90
+ capture_output=True,
91
+ )
92
+ return result.returncode == 0
93
+ except Exception as e:
94
+ logger.error('Kdialog error [{}]', e)
95
+ pass
96
+ else:
97
+ logger.info('Kdialog does not exists for confirmation')
98
+
99
+ return None
100
+
101
+
102
+ def _ask_via_terminal(prompt: str) -> bool:
103
+ """Последний fallback — обычный ввод в терминале."""
104
+ try:
105
+ answer = input(f"{prompt} [y/N] ").strip().lower()
106
+ except EOFError as e:
107
+ logger.error('Fallback confirmation error [{}]', e)
108
+ return False
109
+
110
+ return answer in ("y", "yes", "д", "да")
111
+
112
+
113
+ def ask_confirmation(prompt: str = "Продолжить?") -> bool:
114
+ """
115
+ Показывает подтверждение в максимально подходящем для окружения виде:
116
+ - Windows / macOS -> нативное окно tkinter
117
+ - Linux + Wayland -> zenity/kdialog (Wayland-совместимые)
118
+ - Linux + Xorg -> zenity/kdialog (X11)
119
+ - иначе -> терминал (input)
120
+ """
121
+ platform = sys.platform
122
+
123
+ if sys.platform == 'darwin':
124
+ result = _ask_via_tkinter(prompt)
125
+ if result is not None:
126
+ return result
127
+ return _ask_via_terminal(prompt)
128
+ elif platform.startswith("linux"):
129
+ is_wayland = bool(os.environ.get("WAYLAND_DISPLAY"))
130
+ is_xorg = bool(os.environ.get("DISPLAY"))
131
+
132
+ if is_wayland or is_xorg:
133
+ # если системного диалога нет — пробуем tkinter
134
+ result = _ask_via_tkinter(prompt)
135
+ if result is not None:
136
+ return result
137
+
138
+ # zenity/kdialog умеют работать в обоих окружениях
139
+ result = _ask_via_linux_dialog(prompt)
140
+ if result is not None:
141
+ return result
142
+
143
+ # нет графического окружения или диалоговых утилит
144
+ return _ask_via_terminal(prompt)
145
+ else:
146
+ result = _ask_via_tkinter(prompt)
147
+ if result is not None:
148
+ return result
149
+ return _ask_via_terminal(prompt)
@@ -0,0 +1,103 @@
1
+ import sys
2
+ import gzip
3
+ import shutil
4
+ import datetime
5
+ from pathlib import Path
6
+ from loguru import logger
7
+ from .model import Config
8
+
9
+
10
+ class _Rotator:
11
+ """Ротация по размеру ИЛИ по времени. Что сработает раньше - то и ротирует."""
12
+
13
+ def __init__(self, *, size: int, at: datetime.time):
14
+ self._size_limit = size
15
+ now = datetime.datetime.now()
16
+ self._time_limit = now.replace(
17
+ hour=at.hour, minute=at.minute, second=at.second, microsecond=0
18
+ )
19
+ # Если сейчас уже позже целевого времени - цель на завтра,
20
+ # чтобы не ротировать сразу же при старте.
21
+ if now >= self._time_limit:
22
+ self._time_limit += datetime.timedelta(days=1)
23
+
24
+ def should_rotate(self, message, file) -> bool:
25
+ # Условие 1: размер
26
+ file.seek(0, 2)
27
+
28
+ if file.tell() + len(message) > self._size_limit:
29
+ return True
30
+
31
+ # Условие 2: время
32
+ excess = message.record['time'].timestamp() - self._time_limit.timestamp()
33
+ if excess >= 0:
34
+ elapsed_days = datetime.timedelta(seconds=excess).days
35
+ self._time_limit += datetime.timedelta(days=elapsed_days + 1)
36
+ return True
37
+
38
+ return False
39
+
40
+
41
+ def _compress_all_logs_except_active(log_dir: Path, active_name: str) -> None:
42
+ """Сжимает ВСЕ .log в директории, кроме активного файла."""
43
+ for log_file in sorted(log_dir.glob('*.log')):
44
+ if log_file.name == active_name:
45
+ continue
46
+
47
+ gz_path = log_file.with_suffix('.log.gz')
48
+
49
+ # Если .gz уже есть - исходник удаляем, он лишний
50
+ if gz_path.exists():
51
+ log_file.unlink()
52
+ continue
53
+
54
+ try:
55
+ with open(log_file, 'rb') as f_in, gzip.open(gz_path, 'wb') as f_out:
56
+ shutil.copyfileobj(f_in, f_out)
57
+ log_file.unlink()
58
+ except Exception as e:
59
+ # Не валим сервер из-за одного битого файла
60
+ logger.error('Failed to compress [{}]: [{}]', log_file, e)
61
+
62
+
63
+ def initialize_logger(config: Config, prefix: str) -> None:
64
+ global logger
65
+
66
+ log_dir = config.log_dir / prefix
67
+ log_dir.mkdir(parents=True, exist_ok=True)
68
+
69
+ today = datetime.date.today().strftime('%Y-%m-%d')
70
+ active_name = f"{today}.log"
71
+
72
+ # Сначала сжимаем всё старое, потом открываем логгер.
73
+ # Порядок важен: если делать наоборот, Loguru займёт активный
74
+ # файл, и он попадёт в сжатие.
75
+ _compress_all_logs_except_active(log_dir, active_name)
76
+
77
+ logger.remove()
78
+
79
+ filepath = str(log_dir / '{time:YYYY-MM-DD}.log')
80
+ rotator = _Rotator(size=10 * 1024 * 1024, at=datetime.time(0, 0, 0))
81
+
82
+ logger.add(
83
+ filepath,
84
+ level=config.log_level,
85
+ encoding='utf-8',
86
+ rotation=rotator.should_rotate,
87
+ compression='gz'
88
+ )
89
+
90
+ logger.add(
91
+ sys.stdout,
92
+ level=config.log_level,
93
+ colorize=True
94
+ )
95
+
96
+ logger.add(
97
+ sys.stderr,
98
+ level='ERROR',
99
+ colorize=True,
100
+ )
101
+
102
+ logger.info('Log level: [{}]',config.log_level)
103
+ logger.info('Log file is [{}]', filepath)
@@ -0,0 +1,18 @@
1
+ from pathlib import Path
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class BaseConfig(BaseModel):
6
+ log_dir: Path
7
+ log_level: str
8
+ src: Path
9
+ dst: Path
10
+ no_confirm: bool
11
+ assume_yes: bool
12
+
13
+
14
+ class MainConfig(BaseConfig):
15
+ pass
16
+
17
+
18
+ Config = MainConfig