clipik 0.1.0__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.
- clipik-0.1.0/PKG-INFO +85 -0
- clipik-0.1.0/README.md +70 -0
- clipik-0.1.0/pyproject.toml +28 -0
- clipik-0.1.0/pyproject.toml.orig +30 -0
- clipik-0.1.0/src/clipik/__init__.py +5 -0
- clipik-0.1.0/src/clipik/__main__.py +5 -0
- clipik-0.1.0/src/clipik/cli.py +60 -0
- clipik-0.1.0/src/clipik/clipboard/wayland.py +144 -0
- clipik-0.1.0/src/clipik/clipboard/x11.py +114 -0
- clipik-0.1.0/src/clipik/exception.py +2 -0
- clipik-0.1.0/src/clipik/logger.py +35 -0
- clipik-0.1.0/src/clipik/model.py +42 -0
- clipik-0.1.0/src/clipik/utils.py +13 -0
- clipik-0.1.0/src/clipik/variables.py +34 -0
- clipik-0.1.0/src/clipik/websocket.py +147 -0
- clipik-0.1.0/src/clipik/zeroconf.py +120 -0
clipik-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: clipik
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronize clipboard buffer by network
|
|
5
|
+
Requires-Dist: asyncio>=4.0.0
|
|
6
|
+
Requires-Dist: loguru>=0.7.3
|
|
7
|
+
Requires-Dist: pydantic>=2.13.5
|
|
8
|
+
Requires-Dist: typing>=3.10.0.0
|
|
9
|
+
Requires-Dist: websockets>=17.1
|
|
10
|
+
Requires-Dist: zeroconf>=0.151.3
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Project-URL: Homepage, https://gitflic.ru/project/kurilka/clipik
|
|
13
|
+
Project-URL: Github, https://github.com/TheWhatis/clipik
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
title: CLIPIK
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
[](https://pypi.org/project/clipik)
|
|
21
|
+
[](https://scrutinizer-ci.com/g/TheWhatis/clipik)
|
|
22
|
+
[](https://pypi.org/project/clipik)
|
|
23
|
+
|
|
24
|
+
CLIPIK - сервис для реализации общего буфера обмена по локальной сети
|
|
25
|
+
|
|
26
|
+
# Быстрый старт
|
|
27
|
+
|
|
28
|
+
``` bash
|
|
29
|
+
pip install clipik
|
|
30
|
+
clipik
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
или
|
|
34
|
+
|
|
35
|
+
``` bash
|
|
36
|
+
git clone https://gitflic.ru/project/kurilka/clipik
|
|
37
|
+
cd clipik
|
|
38
|
+
uv pip install -e .
|
|
39
|
+
clipik
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
# Команды/Работа/Переменные окружения
|
|
43
|
+
|
|
44
|
+
На данный момент никаких аргументов не принимает, одновременно сервер и
|
|
45
|
+
клиент запускается по команде `clipik`,
|
|
46
|
+
|
|
47
|
+
Имеются только переменные окружения, за счет которых можно менять
|
|
48
|
+
свойства:
|
|
49
|
+
|
|
50
|
+
- `CLIPIK_LOG_LEVEL=INFO` - Уровель логирования: INFO, DEBUG, ERROR,
|
|
51
|
+
CRITICAL …
|
|
52
|
+
- `WAYLAND_DISPLAY` - Если оно установлено, будет использовать wayland
|
|
53
|
+
clipboard
|
|
54
|
+
- `CLIPIK_PORT=8765` - На каком порту поднять websocket (передача
|
|
55
|
+
буфера)
|
|
56
|
+
- `CLIPIK_HANDSHAKE_TIMEOUT=7` - Сколько ждать HANDSHAKE при подключении
|
|
57
|
+
|
|
58
|
+
# Обязательные утилиты (зависимости)
|
|
59
|
+
|
|
60
|
+
## Linux
|
|
61
|
+
|
|
62
|
+
### X11 (xorg)
|
|
63
|
+
|
|
64
|
+
- `xclip`
|
|
65
|
+
- `clipnotify`
|
|
66
|
+
|
|
67
|
+
### Wayland
|
|
68
|
+
|
|
69
|
+
- `wl-paste`
|
|
70
|
+
- `wl-copy`
|
|
71
|
+
|
|
72
|
+
## MacOs (пока не поддерживается)
|
|
73
|
+
|
|
74
|
+
## Windows (пока не поддерживается)
|
|
75
|
+
|
|
76
|
+
# <span class="todo TODO">TODO</span>
|
|
77
|
+
|
|
78
|
+
- [ ] Возможность регестрировать сервисы для подключения к удаленным
|
|
79
|
+
сетями
|
|
80
|
+
- [ ] Регистрацию при подключении (ввод pin-кода/разрешенные хосты в
|
|
81
|
+
конфиге)
|
|
82
|
+
- [ ] Поддержка всех типов данных, в том числе при копировании из
|
|
83
|
+
файловых менеджеров
|
|
84
|
+
- [ ] Поддержка MacOS
|
|
85
|
+
- [ ] Поддержка Windows
|
clipik-0.1.0/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: CLIPIK
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/clipik)
|
|
6
|
+
[](https://scrutinizer-ci.com/g/TheWhatis/clipik)
|
|
7
|
+
[](https://pypi.org/project/clipik)
|
|
8
|
+
|
|
9
|
+
CLIPIK - сервис для реализации общего буфера обмена по локальной сети
|
|
10
|
+
|
|
11
|
+
# Быстрый старт
|
|
12
|
+
|
|
13
|
+
``` bash
|
|
14
|
+
pip install clipik
|
|
15
|
+
clipik
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
или
|
|
19
|
+
|
|
20
|
+
``` bash
|
|
21
|
+
git clone https://gitflic.ru/project/kurilka/clipik
|
|
22
|
+
cd clipik
|
|
23
|
+
uv pip install -e .
|
|
24
|
+
clipik
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
# Команды/Работа/Переменные окружения
|
|
28
|
+
|
|
29
|
+
На данный момент никаких аргументов не принимает, одновременно сервер и
|
|
30
|
+
клиент запускается по команде `clipik`,
|
|
31
|
+
|
|
32
|
+
Имеются только переменные окружения, за счет которых можно менять
|
|
33
|
+
свойства:
|
|
34
|
+
|
|
35
|
+
- `CLIPIK_LOG_LEVEL=INFO` - Уровель логирования: INFO, DEBUG, ERROR,
|
|
36
|
+
CRITICAL …
|
|
37
|
+
- `WAYLAND_DISPLAY` - Если оно установлено, будет использовать wayland
|
|
38
|
+
clipboard
|
|
39
|
+
- `CLIPIK_PORT=8765` - На каком порту поднять websocket (передача
|
|
40
|
+
буфера)
|
|
41
|
+
- `CLIPIK_HANDSHAKE_TIMEOUT=7` - Сколько ждать HANDSHAKE при подключении
|
|
42
|
+
|
|
43
|
+
# Обязательные утилиты (зависимости)
|
|
44
|
+
|
|
45
|
+
## Linux
|
|
46
|
+
|
|
47
|
+
### X11 (xorg)
|
|
48
|
+
|
|
49
|
+
- `xclip`
|
|
50
|
+
- `clipnotify`
|
|
51
|
+
|
|
52
|
+
### Wayland
|
|
53
|
+
|
|
54
|
+
- `wl-paste`
|
|
55
|
+
- `wl-copy`
|
|
56
|
+
|
|
57
|
+
## MacOs (пока не поддерживается)
|
|
58
|
+
|
|
59
|
+
## Windows (пока не поддерживается)
|
|
60
|
+
|
|
61
|
+
# <span class="todo TODO">TODO</span>
|
|
62
|
+
|
|
63
|
+
- [ ] Возможность регестрировать сервисы для подключения к удаленным
|
|
64
|
+
сетями
|
|
65
|
+
- [ ] Регистрацию при подключении (ввод pin-кода/разрешенные хосты в
|
|
66
|
+
конфиге)
|
|
67
|
+
- [ ] Поддержка всех типов данных, в том числе при копировании из
|
|
68
|
+
файловых менеджеров
|
|
69
|
+
- [ ] Поддержка MacOS
|
|
70
|
+
- [ ] Поддержка Windows
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "clipik"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Synchronize clipboard buffer by network"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"asyncio>=4.0.0",
|
|
9
|
+
"loguru>=0.7.3",
|
|
10
|
+
"pydantic>=2.13.5",
|
|
11
|
+
"typing>=3.10.0.0",
|
|
12
|
+
"websockets>=17.1",
|
|
13
|
+
"zeroconf>=0.151.3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
clipik = "clipik:main"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://gitflic.ru/project/kurilka/clipik"
|
|
21
|
+
Github = "https://github.com/TheWhatis/clipik"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.12.10,<0.13.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["basedpyright>=1.40.0"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "clipik"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Synchronize clipboard buffer by network"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"asyncio>=4.0.0",
|
|
9
|
+
"loguru>=0.7.3",
|
|
10
|
+
"pydantic>=2.13.5",
|
|
11
|
+
"typing>=3.10.0.0",
|
|
12
|
+
"websockets>=17.1",
|
|
13
|
+
"zeroconf>=0.151.3",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
clipik = "clipik: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.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://gitflic.ru/project/kurilka/clipik"
|
|
30
|
+
Github = "https://github.com/TheWhatis/clipik"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from clipik.utils import get_skipped_required_utils
|
|
3
|
+
from clipik.zeroconf import register_service, unregister_service, discover_services
|
|
4
|
+
from clipik.variables import GRAPHIC_PROTOCOL
|
|
5
|
+
from clipik.logger import logger
|
|
6
|
+
from clipik.websocket import broadcast_local, connect_to_server, start_websocket_server
|
|
7
|
+
from clipik.model import NewServiceEvent, LoseServiceEvent
|
|
8
|
+
from clipik.exception import InitializationError
|
|
9
|
+
|
|
10
|
+
if GRAPHIC_PROTOCOL == 'x11':
|
|
11
|
+
from clipik.clipboard.x11 import set_clipboard, listen_clipboard, is_duplicate_clipboard
|
|
12
|
+
else:
|
|
13
|
+
from clipik.clipboard.wayland import set_clipboard, listen_clipboard, is_duplicate_clipboard
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_SERVER_TASKS: dict[str, asyncio.Task] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _discover():
|
|
20
|
+
async for event in discover_services():
|
|
21
|
+
if event.event == 'new_service':
|
|
22
|
+
_SERVER_TASKS[event.name] = asyncio.create_task(
|
|
23
|
+
connect_to_server(
|
|
24
|
+
host=event.host,
|
|
25
|
+
port=event.port,
|
|
26
|
+
set_clipboard=set_clipboard,
|
|
27
|
+
is_duplicate_clipboard=is_duplicate_clipboard
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
if event.event == 'lose_service' and event.name in _SERVER_TASKS:
|
|
33
|
+
task = _SERVER_TASKS[event.name]
|
|
34
|
+
del _SERVER_TASKS[event.name]
|
|
35
|
+
task.cancel()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _main():
|
|
39
|
+
await asyncio.gather(
|
|
40
|
+
start_websocket_server(set_clipboard, is_duplicate_clipboard),
|
|
41
|
+
broadcast_local(listen_clipboard),
|
|
42
|
+
_discover(),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
skipped_utils: list[str] = get_skipped_required_utils()
|
|
48
|
+
|
|
49
|
+
if skipped_utils:
|
|
50
|
+
skipped_utils_str: str = ', '.join(skipped_utils)
|
|
51
|
+
raise InitializationError(f'Utils [{skipped_utils_str}] is required, install it')
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
register_service()
|
|
55
|
+
asyncio.run(_main())
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.critical('Error while asyncio.run: [{}]', e)
|
|
58
|
+
raise e
|
|
59
|
+
finally:
|
|
60
|
+
unregister_service()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from asyncio.subprocess import Process
|
|
3
|
+
import base64
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from clipik.model import ClipboardContent
|
|
6
|
+
from clipik.logger import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _get_types() -> list[str]:
|
|
10
|
+
proc = await asyncio.create_subprocess_exec(
|
|
11
|
+
'wl-paste', '--list-types',
|
|
12
|
+
stdout=asyncio.subprocess.PIPE,
|
|
13
|
+
stderr=asyncio.subprocess.PIPE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
stdout, stderr = await proc.communicate()
|
|
17
|
+
|
|
18
|
+
if stderr:
|
|
19
|
+
logger.warning('wayland: wl-paste --list-types stderr: [{}]', stderr.decode)
|
|
20
|
+
|
|
21
|
+
return stdout.decode(errors='replace').split()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _read_data(mime: str) -> bytes:
|
|
25
|
+
proc = await asyncio.create_subprocess_exec(
|
|
26
|
+
'wl-paste', '--type', mime,
|
|
27
|
+
stdout=asyncio.subprocess.PIPE,
|
|
28
|
+
stderr=asyncio.subprocess.PIPE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
stdout, stderr = await proc.communicate()
|
|
32
|
+
|
|
33
|
+
if stderr:
|
|
34
|
+
logger.warning('wayland: wl-paste --type () stderr: [{}]', mime, stderr.decode())
|
|
35
|
+
|
|
36
|
+
return stdout
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _write_data(mime, str, data: bytes):
|
|
40
|
+
try:
|
|
41
|
+
proc = await asyncio.create_subprocess_exec(
|
|
42
|
+
'wl-copy', '--type', mime,
|
|
43
|
+
stdin=asyncio.subprocess.PIPE,
|
|
44
|
+
stderr=asyncio.subprocess.PIPE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_, stderr = await proc.communicate(data)
|
|
48
|
+
|
|
49
|
+
if stderr:
|
|
50
|
+
logger.warning('wayland: wl-copy --type {} stderr: [{}]', mime, stderr.decode())
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error('wayland: failed to write data', e)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _process_watch() -> Process:
|
|
56
|
+
while True:
|
|
57
|
+
try:
|
|
58
|
+
return await asyncio.create_subprocess_exec(
|
|
59
|
+
'wl-paste', '--watch', 'sh', '-c', 'base64 -w0; echo',
|
|
60
|
+
stdout=asyncio.subprocess.PIPE,
|
|
61
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
62
|
+
)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error('wayland: failed to start wl-paste --watch: [{}]', e)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def listen_clipboard() -> AsyncGenerator[ClipboardContent, None]:
|
|
69
|
+
logger.debug('Started listen_clipboard wayland')
|
|
70
|
+
process = await _process_watch()
|
|
71
|
+
|
|
72
|
+
while True:
|
|
73
|
+
try:
|
|
74
|
+
try:
|
|
75
|
+
line = await process.stdout.readline()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error('wayland: readline failed: [{}]', e)
|
|
78
|
+
continue
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.error('wayland: wl-paste --watch read error: [{}]', e)
|
|
81
|
+
|
|
82
|
+
logger.debug('wayland: wl-paste watch line: [{}]', line)
|
|
83
|
+
if not line and process.returncode is not None:
|
|
84
|
+
logger.error('wayland: wl-paste --watch exited with code [{}]', process.returncode)
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
types: list[str] = await _get_types()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
logger.error('wayland: failed to get types [{}]', e)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
logger.debug('Get types for clipboard: [{}]', types)
|
|
94
|
+
if not types:
|
|
95
|
+
logger.warning('wayland: no types available')
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
preferred: list[str] = [
|
|
99
|
+
'image/png',
|
|
100
|
+
'image/jpeg',
|
|
101
|
+
'image/bmp',
|
|
102
|
+
'text/plain',
|
|
103
|
+
'text/html',
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
chosen: str = next((p for p in preferred if p in types), types[0])
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
raw: bytes = await _read_data(chosen)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.error('wayland: failed to read [{}]: [{}]', chosen, e)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
yield ClipboardContent(
|
|
115
|
+
mime=chosen,
|
|
116
|
+
data=base64.b64encode(raw).decode()
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def set_clipboard(content: ClipboardContent):
|
|
121
|
+
if not content.data:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
raw: bytes = content.data
|
|
125
|
+
|
|
126
|
+
if isinstance(content.data, str):
|
|
127
|
+
raw = base64.b64decode(content.data)
|
|
128
|
+
|
|
129
|
+
await _write_data(content.mime, raw)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def is_duplicate_clipboard(content: ClipboardContent) -> bool:
|
|
133
|
+
"""Проверяет, что переданное содержимое уже находится в буфере обмена."""
|
|
134
|
+
if not content.data:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
types: list[str] = await _get_types()
|
|
138
|
+
if content.mime not in types:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
raw: bytes = await _read_data(content.mime)
|
|
142
|
+
current_b64: str = base64.b64encode(raw).decode()
|
|
143
|
+
|
|
144
|
+
return current_b64 == content.data
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from clipik.model import ClipboardContent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def listen_clipboard() -> AsyncGenerator[ClipboardContent, None]:
|
|
9
|
+
while True:
|
|
10
|
+
try:
|
|
11
|
+
await asyncio.to_thread(subprocess.run, ['clipnotify'], check=True)
|
|
12
|
+
except subprocess.CalledProcessError as e:
|
|
13
|
+
logger.error('x11: clipnotify failed: [{}]', e)
|
|
14
|
+
await asyncio.sleep(0.1)
|
|
15
|
+
continue
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
result = await asyncio.to_thread(
|
|
19
|
+
subprocess.run,
|
|
20
|
+
['xclip', '-o', '-selection', 'clipboard', '-t', 'targetse'],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
targets: list[str] = result.stdout.strip().split()
|
|
26
|
+
except Exception as e:
|
|
27
|
+
logger.error('x11: failed to get targets: [{}]', e)
|
|
28
|
+
await asyncio.sleep(0.1)
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
if not targets:
|
|
32
|
+
yield ClipboardContent(mime='text/plain', data='')
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
preferred: list[str] = [
|
|
36
|
+
'mage/png'
|
|
37
|
+
'mage/jpeg'
|
|
38
|
+
'mage/bmp'
|
|
39
|
+
'ext/plain'
|
|
40
|
+
'TF8_STRING'
|
|
41
|
+
'TRING'
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
chosen: str = next((p for p in preferred if p in targets), targets[0])
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
raw: bytes = await asyncio.to_thread(
|
|
48
|
+
lambda: subprocess.run(
|
|
49
|
+
['xclip', '-o', '-selection', 'clipboard', '-t', chosen],
|
|
50
|
+
capture_output=True
|
|
51
|
+
).stdout
|
|
52
|
+
)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error('x11: failed to read clipboard: [{}]', e)
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
yield ClipboardContent(
|
|
58
|
+
mime=chosen,
|
|
59
|
+
data=base64.b64encode(raw).decode(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def set_clipboard(content: ClipboardContent):
|
|
64
|
+
if not content.data:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
raw: bytes = content.data
|
|
69
|
+
|
|
70
|
+
if isinstance(content.data, str):
|
|
71
|
+
raw = base64.b64decode(content.data)
|
|
72
|
+
|
|
73
|
+
await asyncio.to_thread(
|
|
74
|
+
subprocess.run,
|
|
75
|
+
['xclip', '-selection', 'clipboard', '-t', content.mime],
|
|
76
|
+
input=raw,
|
|
77
|
+
)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error('x11: failed to set clipboard: [{}]', e)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def is_duplicate_clipboard(content: ClipboardContent) -> bool:
|
|
83
|
+
"""Проверяет, что переданное содержимое уже находится в буфере обмена."""
|
|
84
|
+
if not content.data:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = await asyncio.to_thread(
|
|
89
|
+
subprocess.run,
|
|
90
|
+
['xclip', '-o', '-selection', 'clipboard', '-t', 'TARGETS'],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
)
|
|
94
|
+
targets: list[str] = result.stdout.strip().split()
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.error('x11: failed to get targets: [{}]', e)
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
if content.mime not in targets:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
raw: bytes = await asyncio.to_thread(
|
|
104
|
+
lambda: subprocess.run(
|
|
105
|
+
['xclip', '-o', '-selection', 'clipboard', '-t', content.mime],
|
|
106
|
+
capture_output=True
|
|
107
|
+
).stdout
|
|
108
|
+
)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error('x11: failed to read clipboard: [{}]', e)
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
current_b64: str = base64.b64encode(raw).decode()
|
|
114
|
+
return current_b64 == content.data
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_level = os.getenv('CLIPIK_LOG_LEVEL', 'INFO')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if os.name == 'nt': # Windows
|
|
12
|
+
_base_dir = Path(os.getenv('APPDATA', '~'))
|
|
13
|
+
elif sys.platform == 'darwin': # macOS
|
|
14
|
+
_base_dir = Path.home() / 'Library' / 'Application Support'
|
|
15
|
+
else: # Linux / Unix
|
|
16
|
+
xdg = os.getenv('XDG_DATA_HOME')
|
|
17
|
+
|
|
18
|
+
if xdg:
|
|
19
|
+
_base_dir = Path(xdg) / 'clipik'
|
|
20
|
+
else:
|
|
21
|
+
_base_dir = Path.home() / '.local' / 'share'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_base_dir = _base_dir / 'clipik'
|
|
25
|
+
_base_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
_filename = datetime.now().strftime('%Y-%m-%d') + '.log'
|
|
28
|
+
_filepath = _base_dir / _filename
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger.remove()
|
|
32
|
+
logger.add(str(_filepath), level=_level, encoding='utf-8', rotation='10mb', compression='gz')
|
|
33
|
+
logger.add(sys.stdout, level=_level, colorize=True)
|
|
34
|
+
|
|
35
|
+
logger.info('ENV LOG_LEVEL: [{}]', _level)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from clipik.variables import VERSION, PROTOCOL
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClipboardContent(BaseModel):
|
|
7
|
+
mime: str
|
|
8
|
+
data: str | bytes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HandshakeEvent(BaseModel):
|
|
12
|
+
event: Literal['handshake'] = 'handshake'
|
|
13
|
+
protocol: str = PROTOCOL
|
|
14
|
+
version: str = VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HandshakeAckEvent(BaseModel):
|
|
18
|
+
event: Literal['handshake_ack'] = 'handshake_ack'
|
|
19
|
+
protocol: str = PROTOCOL
|
|
20
|
+
version: str = VERSION
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClipboardEvent(BaseModel):
|
|
24
|
+
event: Literal['clipboard'] = 'clipboard'
|
|
25
|
+
mime: str
|
|
26
|
+
data: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NewServiceEvent(BaseModel):
|
|
30
|
+
event: Literal['new_service'] = 'new_service'
|
|
31
|
+
name: str
|
|
32
|
+
ip: str
|
|
33
|
+
host: str
|
|
34
|
+
port: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LoseServiceEvent(BaseModel):
|
|
38
|
+
event: Literal['lose_service'] = 'lose_service'
|
|
39
|
+
name: str
|
|
40
|
+
ip: str
|
|
41
|
+
host: str
|
|
42
|
+
port: int
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import os
|
|
3
|
+
from clipik.variables import REQUIRED_UTILS
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_skipped_required_utils() -> list[str]:
|
|
7
|
+
skipped_utils: list[str] = []
|
|
8
|
+
|
|
9
|
+
for util in REQUIRED_UTILS:
|
|
10
|
+
if shutil.which(util) is None:
|
|
11
|
+
skipped_utils.append(util)
|
|
12
|
+
|
|
13
|
+
return skipped_utils
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from clipik.logger import logger
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
PROTOCOL = 'CLIPIK'
|
|
6
|
+
VERSION = '0.0.1'
|
|
7
|
+
GRAPHIC_PROTOCOL = 'x11'
|
|
8
|
+
|
|
9
|
+
if os.getenv('WAYLAND_DISPLAY'):
|
|
10
|
+
GRAPHIC_PROTOCOL = 'wayland'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger.info('GRAPHIC_PROTOCOL is [{}]', GRAPHIC_PROTOCOL)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
PEER_PORT = os.getenv('CLIPIK_PORT', default=8765)
|
|
17
|
+
PEER_PORT = int(PEER_PORT)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger.info('PORT: [{}]', PEER_PORT)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
REQUIRED_UTILS: list[str] = []
|
|
24
|
+
|
|
25
|
+
if GRAPHIC_PROTOCOL == 'wayland':
|
|
26
|
+
REQUIRED_UTILS = REQUIRED_UTILS + [
|
|
27
|
+
'wl-paste',
|
|
28
|
+
'wl-copy',
|
|
29
|
+
]
|
|
30
|
+
else:
|
|
31
|
+
REQUIRED_UTILS = REQUIRED_UTILS + [
|
|
32
|
+
'xclip',
|
|
33
|
+
'clipnotify'
|
|
34
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import asyncio
|
|
3
|
+
from collections.abc import Callable, Awaitable, AsyncGenerator
|
|
4
|
+
import websockets
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
from websockets.asyncio.server import ServerConnection
|
|
7
|
+
from clipik.logger import logger
|
|
8
|
+
from clipik.variables import PROTOCOL, PEER_PORT
|
|
9
|
+
from clipik.model import (
|
|
10
|
+
ClipboardContent,
|
|
11
|
+
HandshakeEvent,
|
|
12
|
+
HandshakeAckEvent,
|
|
13
|
+
ClipboardEvent,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SetClipboardFn: TypeAlias = Callable[[ClipboardContent], Awaitable[None]]
|
|
18
|
+
ListenClipboardFn: TypeAlias = Callable[[], AsyncGenerator[ClipboardContent, None]]
|
|
19
|
+
IsDuplicateClipboardFn: TypeAlias = Callable[[ClipboardContent], Awaitable[bool]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_HANDSHAKE_TIMEOUT = os.getenv('CLIPIK_HANDSHAKE_TIMEOUT', default=7)
|
|
23
|
+
_HANDSHAKE_TIMEOUT = int(_HANDSHAKE_TIMEOUT)
|
|
24
|
+
_CLIENTS = set()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _ws_handler(websocket: ServerConnection, set_clipboard: SetClipboardFn, is_duplicate_clipboard: IsDuplicateClipboardFn):
|
|
28
|
+
try:
|
|
29
|
+
msg = await asyncio.wait_for(websocket.recv(), timeout=_HANDSHAKE_TIMEOUT)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
event = HandshakeEvent.model_validate_json(raw)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
logger.warning('Invalid handshake [{}]', msg)
|
|
35
|
+
await websocket.close(code=1000, reason='Invalid handshake')
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if event.protocol != PROTOCOL:
|
|
39
|
+
logger.warning('Invalid handshake protocol [{}]', event.protocol)
|
|
40
|
+
await websocket.close(code=1000, reason='Invalid handshake')
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
await websocket.send(HandshakeAckEvent().model_dump_json())
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.warning('Handshake failed: [{}]', e)
|
|
46
|
+
|
|
47
|
+
_CLIENTS.add(websocket)
|
|
48
|
+
peer = websocket.remote_address[0]
|
|
49
|
+
logger.info('Client connected: [{}]', peer)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
async for msg in websocket:
|
|
53
|
+
try:
|
|
54
|
+
event = ClipboardEvent.model_validate_json(raw)
|
|
55
|
+
|
|
56
|
+
content = ClipboardContent(
|
|
57
|
+
mime=payload.get('mime', default='text/plain'),
|
|
58
|
+
data=payload.get('data', default=''),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
logger.debug('Seting clipboard from [{}]: [{}]', peer, content.mime)
|
|
62
|
+
if await is_duplicate_clipboard(content):
|
|
63
|
+
logger.debug('Clipboard from [{}] is duplicate: [{}]', peer, content.mime)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
await set_clipboard(content)
|
|
67
|
+
logger.debug('Set clipboard from [{}]: [{}]', peer, content.mime)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error('Error listen peer [{}]: [{}]', peer, e)
|
|
70
|
+
except websockets.exceptions.ConnectionClosed:
|
|
71
|
+
logger.info('Client disconnected: [{}]', peer)
|
|
72
|
+
finally:
|
|
73
|
+
clients.remove(websocket)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def start_websocket_server(
|
|
77
|
+
set_clipboard: SetClipboardFn,
|
|
78
|
+
is_duplicate_clipboard: IsDuplicateClipboardFn
|
|
79
|
+
):
|
|
80
|
+
async def handler(websocket: ServerConnection):
|
|
81
|
+
await _ws_handler(websocket, set_clipboard, is_duplicate_clipboard)
|
|
82
|
+
|
|
83
|
+
async with websockets.serve(handler, '0.0.0.0', PEER_PORT):
|
|
84
|
+
logger.info('Websocket server started on [{}]', PEER_PORT)
|
|
85
|
+
await asyncio.Future()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def broadcast_local(listen_clipboard: ListenClipboardFn):
|
|
89
|
+
async for content in listen_clipboard():
|
|
90
|
+
if not content.data:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
event = ClipboardEvent(
|
|
94
|
+
mime=content.mime,
|
|
95
|
+
data=content.data if isinstance(content.data, str) else content.data,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
payload = event.model_dump_json()
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
if _CLIENTS:
|
|
102
|
+
await asyncio.gather(
|
|
103
|
+
*[c.send(json.dumps(payload)) for c in _CLIENTS],
|
|
104
|
+
return_exceptions=True
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
logger.debug('Broadcasted to [{}] clients', len(_CLIENTS))
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error('Error broadcasting to [{}] clients, mime [{}]', len(_CLIENTS), content.mime)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def connect_to_server(host: str, port: int, set_clipboard: SetClipboardFn, is_duplicate_clipboard: IsDuplicateClipboardFn):
|
|
113
|
+
url = f"ws://{host}:{port}"
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
async with websockets.connect(url) as ws:
|
|
117
|
+
await ws.send(HandshakeEvent().model_dump_json())
|
|
118
|
+
|
|
119
|
+
raw = await asyncio.wait_for(wc.recv(), timeout=_HANDSHAKE_TIMEOUT)
|
|
120
|
+
ack = HandshakeAckEvent.model_validate_json(raw)
|
|
121
|
+
|
|
122
|
+
if ack.protocol != PROTOCOL:
|
|
123
|
+
logger.warning('Invalid protocol [{}] for [{}]', ack.protocol, host)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
logger.info('Connected to server [{}]', url)
|
|
127
|
+
|
|
128
|
+
async for raw in ws:
|
|
129
|
+
try:
|
|
130
|
+
event = ClipboardEvent.model_validate_json(raw)
|
|
131
|
+
|
|
132
|
+
content = ClipboardContent(
|
|
133
|
+
mime=event.mime,
|
|
134
|
+
data=event.data,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
logger.debug('Seting clipboard from server [{}], mime [{}]', url, mime)
|
|
138
|
+
if await is_duplicate_clipboard(content):
|
|
139
|
+
logger.debug('Clipboard from server [{}] is duplicate, mime [{}]', url, mime)
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
await set_clipboard(content)
|
|
143
|
+
logger.debug('Set clipboard from server [{}], mime [{}]', url, mime)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error('Error from server [{}]: [{}]', url, e)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.warning('Failed to conntect [{}]. Error: [{}]', url, e)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from clipik.model import LoseServiceEvent, NewServiceEvent
|
|
6
|
+
from zeroconf import ServiceInfo, Zeroconf, ServiceListener
|
|
7
|
+
from clipik.variables import VERSION, PEER_PORT
|
|
8
|
+
from clipik.logger import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_local_ip():
|
|
12
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
13
|
+
s.connect(('8.8.8.8', 80))
|
|
14
|
+
ip = s.getsockname()[0]
|
|
15
|
+
s.close()
|
|
16
|
+
return ip
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SERVICE_NAME = f"clipik-{socket.gethostname()}"
|
|
21
|
+
SERVICE_TYPE = '_clipik._tcp.local.'
|
|
22
|
+
LOCAL_IP = _get_local_ip()
|
|
23
|
+
ZEROCONF = Zeroconf(interfaces=[LOCAL_IP])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
INFO = ServiceInfo(
|
|
27
|
+
SERVICE_TYPE,
|
|
28
|
+
f"{SERVICE_NAME}.{SERVICE_TYPE}",
|
|
29
|
+
addresses=[socket.inet_aton(LOCAL_IP)],
|
|
30
|
+
port=PEER_PORT,
|
|
31
|
+
properties={'version': VERSION},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _ServiceListener(ServiceListener):
|
|
37
|
+
def __init__(self, queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
|
|
38
|
+
self.queue = queue
|
|
39
|
+
self.loop = loop
|
|
40
|
+
|
|
41
|
+
def start(self):
|
|
42
|
+
ZEROCONF.add_service_listener(SERVICE_TYPE, self)
|
|
43
|
+
|
|
44
|
+
def stop(self):
|
|
45
|
+
ZEROCONF.remove_service_listener(self)
|
|
46
|
+
ZEROCONF.close()
|
|
47
|
+
|
|
48
|
+
def add_service(self, zc, type_, name):
|
|
49
|
+
info = zc.get_service_info(type_, name)
|
|
50
|
+
|
|
51
|
+
if info and info.addresses:
|
|
52
|
+
ip = socket.inet_ntoa(info.addresses[0])
|
|
53
|
+
|
|
54
|
+
if ip.startswith('127.'):
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
if ip == LOCAL_IP:
|
|
58
|
+
logger.debug('Local service registered: [{}] at [{}:{}]', name, ip, info.port)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
asyncio.run_coroutine_threadsafe(
|
|
62
|
+
self.queue.put(NewServiceEvent(
|
|
63
|
+
name=name,
|
|
64
|
+
ip=ip,
|
|
65
|
+
host=ip,
|
|
66
|
+
port=info.port,
|
|
67
|
+
)),
|
|
68
|
+
self.loop,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
logger.debug('New service [{}] at [{}:{}]', name, ip, info.port)
|
|
72
|
+
|
|
73
|
+
def remove_service(self, zc, type_, name):
|
|
74
|
+
info = zc.get_service_info(type_, name)
|
|
75
|
+
|
|
76
|
+
if info and info.addresses:
|
|
77
|
+
ip = socket.inet_ntoa(info.addresses[0])
|
|
78
|
+
|
|
79
|
+
if ip.startswith('127.'):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
asyncio.create_task(
|
|
83
|
+
self.queue.put(LoseServiceEvent(
|
|
84
|
+
name=name,
|
|
85
|
+
ip=ip,
|
|
86
|
+
host=ip,
|
|
87
|
+
port=info.port,
|
|
88
|
+
)),
|
|
89
|
+
self.loop
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
logger.debug('Lose service [{}]', name)
|
|
93
|
+
|
|
94
|
+
def update_service(self, zc, type_, name):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def register_service():
|
|
99
|
+
ZEROCONF.register_service(INFO)
|
|
100
|
+
logger.info('Registered service: [{}] at [{}:{}]', SERVICE_NAME, LOCAL_IP, PEER_PORT)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def unregister_service():
|
|
104
|
+
ZEROCONF.unregister_all_services()
|
|
105
|
+
ZEROCONF.close()
|
|
106
|
+
logger.info('Unregistered service')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def discover_services() -> AsyncGenerator[NewServiceEvent | LoseServiceEvent, None]:
|
|
110
|
+
loop = asyncio.get_running_loop()
|
|
111
|
+
queue = asyncio.Queue()
|
|
112
|
+
listener = _ServiceListener(queue, loop)
|
|
113
|
+
listener.start()
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
while True:
|
|
117
|
+
event = await queue.get()
|
|
118
|
+
yield event
|
|
119
|
+
finally:
|
|
120
|
+
listener.stop()
|