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 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
+ [![Latest Stable](http://img.shields.io/pypi/v/clipik.svg)](https://pypi.org/project/clipik)
21
+ [![Scrutinizer](http://img.shields.io/scrutinizer/g/TheWhatis/clipik.svg)](https://scrutinizer-ci.com/g/TheWhatis/clipik)
22
+ [![License](http://img.shields.io/pypi/l/edges/clipik.svg?refresh=true)](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
+ [![Latest Stable](http://img.shields.io/pypi/v/clipik.svg)](https://pypi.org/project/clipik)
6
+ [![Scrutinizer](http://img.shields.io/scrutinizer/g/TheWhatis/clipik.svg)](https://scrutinizer-ci.com/g/TheWhatis/clipik)
7
+ [![License](http://img.shields.io/pypi/l/edges/clipik.svg?refresh=true)](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,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,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,2 @@
1
+ class InitializationError(Exception):
2
+ pass
@@ -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()