beamboard 0.0.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.
- beamboard-0.0.1/.gitignore +9 -0
- beamboard-0.0.1/PKG-INFO +119 -0
- beamboard-0.0.1/README.md +105 -0
- beamboard-0.0.1/beamboard/__init__.py +3 -0
- beamboard-0.0.1/beamboard/__main__.py +3 -0
- beamboard-0.0.1/beamboard/cli.py +243 -0
- beamboard-0.0.1/beamboard/server.py +219 -0
- beamboard-0.0.1/pyproject.toml +34 -0
beamboard-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: beamboard
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Beam your clipboard between devices: the pbd server plus the pb client
|
|
5
|
+
Author-email: Jonas Eschmann <jonas.eschmann@gmail.com>
|
|
6
|
+
Keywords: clipboard,pasteboard,pbcopy,pbpaste,self-hosted,sync
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Utilities
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# beamboard — pasteboard sync
|
|
16
|
+
|
|
17
|
+
Self-hosted clipboard sync for devices on a Tailnet (or any private network).
|
|
18
|
+
Three small programs (the two Python ones ship as one package, `beamboard`),
|
|
19
|
+
one tiny HTTP+JSON protocol:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
macOS app ──┐ ┌── TrueNAS docker-compose
|
|
23
|
+
(menubar, ├── HTTP over Tailnet ─┤ pbd + SQLite
|
|
24
|
+
pasteboard │ └── history capped at N clips
|
|
25
|
+
sync) │
|
|
26
|
+
pb CLI ───┘ (copy / paste / history / watch)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The server is the single source of truth; every clip gets a monotonically
|
|
30
|
+
increasing `seq`. Clients are stateless and reconcile against `seq`.
|
|
31
|
+
Everything is plain text, stdlib-only Python, and dependency-free Swift.
|
|
32
|
+
|
|
33
|
+
## Server (TrueNAS / docker-compose)
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
cd server
|
|
37
|
+
docker compose up -d --build
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Point the volume in `docker-compose.yml` at a dataset
|
|
41
|
+
(e.g. `/mnt/tank/apps/pb:/data`, writable by uid 1000) for persistent history.
|
|
42
|
+
|
|
43
|
+
Or run it anywhere with Python: `pipx install beamboard` (from a checkout:
|
|
44
|
+
`pipx install .`), then `PB_DATA=./data pbd` (alias: `pb serve`). No dependencies.
|
|
45
|
+
|
|
46
|
+
| Env | Default | |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `PB_PORT` | `8737` | listen port |
|
|
49
|
+
| `PB_DATA` | `~/.local/share/pb` (`/data` in Docker) | directory for `pb.db` |
|
|
50
|
+
| `PB_TOKEN` | *(unset)* | optional shared bearer token; unset = no auth (trust the Tailnet) |
|
|
51
|
+
| `PB_HISTORY` | `100` | clips to keep |
|
|
52
|
+
| `PB_MAX_BYTES` | `1048576` | max clip size |
|
|
53
|
+
|
|
54
|
+
## CLI client (Linux or anywhere)
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
pipx install beamboard # from a checkout: pipx install . (or: make install)
|
|
58
|
+
pb config url http://truenas:8737 # saved to ~/.config/pb/config.json
|
|
59
|
+
# pb config token ... # if the server sets PB_TOKEN
|
|
60
|
+
|
|
61
|
+
echo "text" | pb copy # copy to the shared pasteboard
|
|
62
|
+
pb paste > file.txt # paste from it (byte-verbatim)
|
|
63
|
+
pb history [n] [--json] # recent clips
|
|
64
|
+
pb watch # print each new clip as it arrives (pipeable)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The same stdlib-only package (`beamboard`) provides the `pbd` server and the
|
|
68
|
+
`pb` command, also installed as `beamboard` (`beamboard/server.py` and `cli.py`).
|
|
69
|
+
`pb copy`/`pb paste` behave like macOS `pbcopy`/`pbpaste`; a symlink or alias
|
|
70
|
+
named `pbcopy`/`pbpaste` pointing at `pb` restores the old spelling.
|
|
71
|
+
`pb config` shows the effective settings; `PB_URL`, `PB_TOKEN` and `PB_DEVICE`
|
|
72
|
+
override the config file (device defaults to the hostname). `make check` builds
|
|
73
|
+
the sdist and wheel and validates them for PyPI.
|
|
74
|
+
|
|
75
|
+
## macOS menubar app
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
make -C mac app # builds BeamBoard.app (SwiftPM, no Xcode project)
|
|
79
|
+
make -C mac install # copies it to /Applications
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Open BeamBoard, click the clipboard icon in the menu bar, and set the server URL
|
|
83
|
+
(and token) in the settings. The app then:
|
|
84
|
+
|
|
85
|
+
- watches the system pasteboard (polls `changeCount` twice a second — the
|
|
86
|
+
pasteboard has no change notifications) and pushes new local clips,
|
|
87
|
+
- long-polls the server and writes clips from other devices into the
|
|
88
|
+
system pasteboard,
|
|
89
|
+
- shows the shared history in the menu bar; clicking an entry copies it.
|
|
90
|
+
|
|
91
|
+
It never overwrites your pasteboard at launch — syncing starts with the first
|
|
92
|
+
actual change on either side. Requires macOS 13+. The build is ad-hoc signed;
|
|
93
|
+
add it to Login Items for autostart.
|
|
94
|
+
|
|
95
|
+
## Protocol
|
|
96
|
+
|
|
97
|
+
Clip: `{seq: int, ts: float, mime: "text/plain", text: str, device: str}`
|
|
98
|
+
|
|
99
|
+
| Endpoint | Behavior |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `GET /clip` | latest clip, `204` if empty |
|
|
102
|
+
| `POST /clip` `{text, device}` | store new head, returns `{seq}`; identical to current head → deduped (returns existing seq) |
|
|
103
|
+
| `GET /history?limit=50` | recent clips, newest first |
|
|
104
|
+
| `GET /watch?since=SEQ&timeout=30` | long-poll: latest clip once head `seq > SEQ`, else `204` on timeout |
|
|
105
|
+
| `GET /healthz` | `200 ok` (never requires auth) |
|
|
106
|
+
|
|
107
|
+
With `PB_TOKEN` set, all other endpoints require `Authorization: Bearer <token>`.
|
|
108
|
+
Long-polling keeps every client a dumb HTTP GET loop — no streams, no state,
|
|
109
|
+
self-healing across server restarts.
|
|
110
|
+
|
|
111
|
+
Echo loops are broken by three cheap layers: the server dedups identical
|
|
112
|
+
consecutive text, the macOS app skips pasteboard changes it caused itself,
|
|
113
|
+
and the `device` field lets clients ignore their own clips from `/watch`.
|
|
114
|
+
|
|
115
|
+
## Security
|
|
116
|
+
|
|
117
|
+
There is no TLS and (by default) no auth: the design assumes the server is
|
|
118
|
+
only reachable over a trusted private network such as a Tailnet. Set
|
|
119
|
+
`PB_TOKEN` for a cheap second layer. Do not expose the port to the internet.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# beamboard — pasteboard sync
|
|
2
|
+
|
|
3
|
+
Self-hosted clipboard sync for devices on a Tailnet (or any private network).
|
|
4
|
+
Three small programs (the two Python ones ship as one package, `beamboard`),
|
|
5
|
+
one tiny HTTP+JSON protocol:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
macOS app ──┐ ┌── TrueNAS docker-compose
|
|
9
|
+
(menubar, ├── HTTP over Tailnet ─┤ pbd + SQLite
|
|
10
|
+
pasteboard │ └── history capped at N clips
|
|
11
|
+
sync) │
|
|
12
|
+
pb CLI ───┘ (copy / paste / history / watch)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The server is the single source of truth; every clip gets a monotonically
|
|
16
|
+
increasing `seq`. Clients are stateless and reconcile against `seq`.
|
|
17
|
+
Everything is plain text, stdlib-only Python, and dependency-free Swift.
|
|
18
|
+
|
|
19
|
+
## Server (TrueNAS / docker-compose)
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
cd server
|
|
23
|
+
docker compose up -d --build
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Point the volume in `docker-compose.yml` at a dataset
|
|
27
|
+
(e.g. `/mnt/tank/apps/pb:/data`, writable by uid 1000) for persistent history.
|
|
28
|
+
|
|
29
|
+
Or run it anywhere with Python: `pipx install beamboard` (from a checkout:
|
|
30
|
+
`pipx install .`), then `PB_DATA=./data pbd` (alias: `pb serve`). No dependencies.
|
|
31
|
+
|
|
32
|
+
| Env | Default | |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| `PB_PORT` | `8737` | listen port |
|
|
35
|
+
| `PB_DATA` | `~/.local/share/pb` (`/data` in Docker) | directory for `pb.db` |
|
|
36
|
+
| `PB_TOKEN` | *(unset)* | optional shared bearer token; unset = no auth (trust the Tailnet) |
|
|
37
|
+
| `PB_HISTORY` | `100` | clips to keep |
|
|
38
|
+
| `PB_MAX_BYTES` | `1048576` | max clip size |
|
|
39
|
+
|
|
40
|
+
## CLI client (Linux or anywhere)
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pipx install beamboard # from a checkout: pipx install . (or: make install)
|
|
44
|
+
pb config url http://truenas:8737 # saved to ~/.config/pb/config.json
|
|
45
|
+
# pb config token ... # if the server sets PB_TOKEN
|
|
46
|
+
|
|
47
|
+
echo "text" | pb copy # copy to the shared pasteboard
|
|
48
|
+
pb paste > file.txt # paste from it (byte-verbatim)
|
|
49
|
+
pb history [n] [--json] # recent clips
|
|
50
|
+
pb watch # print each new clip as it arrives (pipeable)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The same stdlib-only package (`beamboard`) provides the `pbd` server and the
|
|
54
|
+
`pb` command, also installed as `beamboard` (`beamboard/server.py` and `cli.py`).
|
|
55
|
+
`pb copy`/`pb paste` behave like macOS `pbcopy`/`pbpaste`; a symlink or alias
|
|
56
|
+
named `pbcopy`/`pbpaste` pointing at `pb` restores the old spelling.
|
|
57
|
+
`pb config` shows the effective settings; `PB_URL`, `PB_TOKEN` and `PB_DEVICE`
|
|
58
|
+
override the config file (device defaults to the hostname). `make check` builds
|
|
59
|
+
the sdist and wheel and validates them for PyPI.
|
|
60
|
+
|
|
61
|
+
## macOS menubar app
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
make -C mac app # builds BeamBoard.app (SwiftPM, no Xcode project)
|
|
65
|
+
make -C mac install # copies it to /Applications
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Open BeamBoard, click the clipboard icon in the menu bar, and set the server URL
|
|
69
|
+
(and token) in the settings. The app then:
|
|
70
|
+
|
|
71
|
+
- watches the system pasteboard (polls `changeCount` twice a second — the
|
|
72
|
+
pasteboard has no change notifications) and pushes new local clips,
|
|
73
|
+
- long-polls the server and writes clips from other devices into the
|
|
74
|
+
system pasteboard,
|
|
75
|
+
- shows the shared history in the menu bar; clicking an entry copies it.
|
|
76
|
+
|
|
77
|
+
It never overwrites your pasteboard at launch — syncing starts with the first
|
|
78
|
+
actual change on either side. Requires macOS 13+. The build is ad-hoc signed;
|
|
79
|
+
add it to Login Items for autostart.
|
|
80
|
+
|
|
81
|
+
## Protocol
|
|
82
|
+
|
|
83
|
+
Clip: `{seq: int, ts: float, mime: "text/plain", text: str, device: str}`
|
|
84
|
+
|
|
85
|
+
| Endpoint | Behavior |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `GET /clip` | latest clip, `204` if empty |
|
|
88
|
+
| `POST /clip` `{text, device}` | store new head, returns `{seq}`; identical to current head → deduped (returns existing seq) |
|
|
89
|
+
| `GET /history?limit=50` | recent clips, newest first |
|
|
90
|
+
| `GET /watch?since=SEQ&timeout=30` | long-poll: latest clip once head `seq > SEQ`, else `204` on timeout |
|
|
91
|
+
| `GET /healthz` | `200 ok` (never requires auth) |
|
|
92
|
+
|
|
93
|
+
With `PB_TOKEN` set, all other endpoints require `Authorization: Bearer <token>`.
|
|
94
|
+
Long-polling keeps every client a dumb HTTP GET loop — no streams, no state,
|
|
95
|
+
self-healing across server restarts.
|
|
96
|
+
|
|
97
|
+
Echo loops are broken by three cheap layers: the server dedups identical
|
|
98
|
+
consecutive text, the macOS app skips pasteboard changes it caused itself,
|
|
99
|
+
and the `device` field lets clients ignore their own clips from `/watch`.
|
|
100
|
+
|
|
101
|
+
## Security
|
|
102
|
+
|
|
103
|
+
There is no TLS and (by default) no auth: the design assumes the server is
|
|
104
|
+
only reachable over a trusted private network such as a Tailnet. Set
|
|
105
|
+
`PB_TOKEN` for a cheap second layer. Do not expose the port to the internet.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""pb -- pasteboard sync client (beamboard is a synonym). stdlib only.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
echo "text" | pb copy
|
|
5
|
+
pb paste > file.txt
|
|
6
|
+
pb history [n] [--json]
|
|
7
|
+
pb watch
|
|
8
|
+
pb config show effective settings and where they come from
|
|
9
|
+
pb config url http://truenas:8737 save the server URL (keys: url, token, device)
|
|
10
|
+
pb --version
|
|
11
|
+
pbd (or: pb serve)
|
|
12
|
+
|
|
13
|
+
Settings come from the environment, then the config file
|
|
14
|
+
(~/.config/pb/config.json, honoring $XDG_CONFIG_HOME; %APPDATA%\\pb on Windows):
|
|
15
|
+
PB_URL server base URL, e.g. http://truenas:8737 (required)
|
|
16
|
+
PB_TOKEN bearer token, if the server has PB_TOKEN set
|
|
17
|
+
PB_DEVICE device name reported to the server (default: hostname)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
|
|
30
|
+
KEYS = ("url", "token", "device")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PbError(Exception):
|
|
34
|
+
def __init__(self, msg, transient=False):
|
|
35
|
+
super().__init__(msg)
|
|
36
|
+
self.transient = transient # worth retrying (network), vs. fatal (4xx)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_path():
|
|
40
|
+
if os.name == "nt":
|
|
41
|
+
base = os.environ.get("APPDATA") or os.path.expanduser("~")
|
|
42
|
+
else:
|
|
43
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
44
|
+
return os.path.join(base, "pb", "config.json")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config():
|
|
48
|
+
try:
|
|
49
|
+
with open(config_path(), encoding="utf-8") as f:
|
|
50
|
+
cfg = json.load(f)
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
return {}
|
|
53
|
+
except (OSError, ValueError) as e:
|
|
54
|
+
raise PbError(f"cannot read {config_path()}: {e}") from e
|
|
55
|
+
return cfg if isinstance(cfg, dict) else {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_config(cfg):
|
|
59
|
+
path = config_path()
|
|
60
|
+
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
|
61
|
+
tmp = path + ".tmp"
|
|
62
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
63
|
+
json.dump(cfg, f, indent=2)
|
|
64
|
+
f.write("\n")
|
|
65
|
+
os.chmod(tmp, 0o600) # the file may hold the token
|
|
66
|
+
os.replace(tmp, path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_settings = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def settings():
|
|
73
|
+
"""Effective (url, token, device) and per-key source, env over config file."""
|
|
74
|
+
global _settings
|
|
75
|
+
if _settings is None:
|
|
76
|
+
cfg = load_config()
|
|
77
|
+
values, sources = {}, {}
|
|
78
|
+
for key in KEYS:
|
|
79
|
+
env = "PB_" + key.upper()
|
|
80
|
+
if os.environ.get(env):
|
|
81
|
+
values[key], sources[key] = os.environ[env], env
|
|
82
|
+
elif cfg.get(key):
|
|
83
|
+
values[key], sources[key] = str(cfg[key]), "config"
|
|
84
|
+
else:
|
|
85
|
+
values[key], sources[key] = "", "unset"
|
|
86
|
+
if not values["device"]:
|
|
87
|
+
values["device"], sources["device"] = socket.gethostname(), "hostname"
|
|
88
|
+
values["url"] = values["url"].rstrip("/")
|
|
89
|
+
_settings = values, sources
|
|
90
|
+
return _settings
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def request(method, path, body=None, timeout=10):
|
|
94
|
+
"""Returns (status, parsed-json-or-None). Raises PbError on failure."""
|
|
95
|
+
values, _ = settings()
|
|
96
|
+
url, token = values["url"], values["token"]
|
|
97
|
+
if not url:
|
|
98
|
+
raise PbError("server URL not set: run `pb config url http://host:8737` or export PB_URL")
|
|
99
|
+
headers = {"Content-Type": "application/json"}
|
|
100
|
+
if token:
|
|
101
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
102
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
103
|
+
req = urllib.request.Request(url + path, data=data, headers=headers, method=method)
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
106
|
+
raw = resp.read()
|
|
107
|
+
return resp.status, json.loads(raw) if raw else None
|
|
108
|
+
except urllib.error.HTTPError as e:
|
|
109
|
+
detail = e.read().decode(errors="replace").strip()
|
|
110
|
+
raise PbError(f"server returned {e.code}: {detail or e.reason}") from e
|
|
111
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
|
|
112
|
+
reason = getattr(e, "reason", e)
|
|
113
|
+
raise PbError(f"cannot reach {url}: {reason}", transient=True) from e
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cmd_config(args):
|
|
117
|
+
if not args:
|
|
118
|
+
values, sources = settings()
|
|
119
|
+
path = config_path()
|
|
120
|
+
print(f"config: {path}" + ("" if os.path.exists(path) else " (not created yet)"))
|
|
121
|
+
for key in KEYS:
|
|
122
|
+
shown = values[key] or "(unset)"
|
|
123
|
+
if key == "token" and values[key]:
|
|
124
|
+
shown = "*" * 8
|
|
125
|
+
print(f"{key + ':':8}{shown} [{sources[key]}]")
|
|
126
|
+
return
|
|
127
|
+
key = args[0]
|
|
128
|
+
if key not in KEYS or len(args) > 2:
|
|
129
|
+
raise PbError("usage: pb config [url|token|device] [VALUE] (empty VALUE unsets)")
|
|
130
|
+
cfg = load_config()
|
|
131
|
+
if len(args) == 1:
|
|
132
|
+
print(cfg.get(key, ""))
|
|
133
|
+
return
|
|
134
|
+
value = args[1].strip()
|
|
135
|
+
if key == "url" and value:
|
|
136
|
+
value = value.rstrip("/")
|
|
137
|
+
if not value.startswith(("http://", "https://")):
|
|
138
|
+
raise PbError("url must start with http:// or https://")
|
|
139
|
+
if value:
|
|
140
|
+
cfg[key] = value
|
|
141
|
+
else:
|
|
142
|
+
cfg.pop(key, None)
|
|
143
|
+
save_config(cfg)
|
|
144
|
+
print(f"{key} {'saved to' if value else 'removed from'} {config_path()}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_copy():
|
|
148
|
+
text = sys.stdin.buffer.read().decode("utf-8", "replace")
|
|
149
|
+
request("POST", "/clip", {"text": text, "device": settings()[0]["device"]})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_paste():
|
|
153
|
+
status, clip = request("GET", "/clip")
|
|
154
|
+
if status == 204:
|
|
155
|
+
return # empty history: no output, exit 0 (matches macOS pbpaste)
|
|
156
|
+
sys.stdout.buffer.write(clip["text"].encode())
|
|
157
|
+
sys.stdout.buffer.flush()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_history(args):
|
|
161
|
+
as_json = "--json" in args
|
|
162
|
+
args = [a for a in args if a != "--json"]
|
|
163
|
+
limit = int(args[0]) if args else 20
|
|
164
|
+
_, resp = request("GET", f"/history?limit={limit}")
|
|
165
|
+
if as_json:
|
|
166
|
+
json.dump(resp, sys.stdout, ensure_ascii=False, indent=2)
|
|
167
|
+
print()
|
|
168
|
+
return
|
|
169
|
+
for clip in resp["clips"]:
|
|
170
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(clip["ts"]))
|
|
171
|
+
preview = " ".join(clip["text"].split())
|
|
172
|
+
if len(preview) > 80:
|
|
173
|
+
preview = preview[:79] + "…"
|
|
174
|
+
print(f"{clip['seq']}\t{ts}\t{clip['device']}\t{preview}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_watch():
|
|
178
|
+
"""Print each new clip as it arrives. Composable; survives server restarts."""
|
|
179
|
+
since = 0
|
|
180
|
+
primed = False
|
|
181
|
+
while True:
|
|
182
|
+
try:
|
|
183
|
+
if not primed:
|
|
184
|
+
status, clip = request("GET", "/clip")
|
|
185
|
+
since = clip["seq"] if status == 200 else 0
|
|
186
|
+
primed = True
|
|
187
|
+
_, resp = request("GET", f"/watch?since={since}&timeout=30", timeout=45)
|
|
188
|
+
except PbError as e:
|
|
189
|
+
if not e.transient:
|
|
190
|
+
raise
|
|
191
|
+
time.sleep(2) # server unreachable: retry quietly
|
|
192
|
+
continue
|
|
193
|
+
if resp is None:
|
|
194
|
+
continue # long-poll timeout, ask again
|
|
195
|
+
since = resp["seq"]
|
|
196
|
+
print(resp["text"], flush=True)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run(cmd, args):
|
|
200
|
+
try:
|
|
201
|
+
if cmd == "copy":
|
|
202
|
+
cmd_copy()
|
|
203
|
+
elif cmd == "paste":
|
|
204
|
+
cmd_paste()
|
|
205
|
+
elif cmd == "history":
|
|
206
|
+
cmd_history(args)
|
|
207
|
+
elif cmd == "watch":
|
|
208
|
+
cmd_watch()
|
|
209
|
+
elif cmd == "config":
|
|
210
|
+
cmd_config(args)
|
|
211
|
+
elif cmd == "serve":
|
|
212
|
+
from .server import main as serve
|
|
213
|
+
serve()
|
|
214
|
+
elif cmd in ("version", "--version", "-V"):
|
|
215
|
+
print(f"pb {__version__}")
|
|
216
|
+
else:
|
|
217
|
+
print(__doc__.strip(), file=sys.stderr)
|
|
218
|
+
sys.exit(0 if cmd in ("help", "--help", "-h") else 2)
|
|
219
|
+
except PbError as e:
|
|
220
|
+
print(f"pb: {e}", file=sys.stderr)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
except KeyboardInterrupt:
|
|
223
|
+
sys.exit(130)
|
|
224
|
+
except BrokenPipeError:
|
|
225
|
+
sys.exit(0)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv=None):
|
|
229
|
+
args = sys.argv[1:] if argv is None else list(argv)
|
|
230
|
+
prog = os.path.basename(sys.argv[0])
|
|
231
|
+
if prog == "pbcopy":
|
|
232
|
+
cmd = "copy"
|
|
233
|
+
elif prog == "pbpaste":
|
|
234
|
+
cmd = "paste"
|
|
235
|
+
elif args:
|
|
236
|
+
cmd, args = args[0], args[1:]
|
|
237
|
+
else:
|
|
238
|
+
cmd = "help"
|
|
239
|
+
run(cmd, args)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pbd -- minimal pasteboard sync server.
|
|
3
|
+
|
|
4
|
+
Stdlib only. HTTP+JSON, SQLite persistence, long-poll watch.
|
|
5
|
+
|
|
6
|
+
Env:
|
|
7
|
+
PB_PORT listen port (default 8737)
|
|
8
|
+
PB_DATA data directory for pb.db (default ~/.local/share/pb; /data in Docker)
|
|
9
|
+
PB_TOKEN optional shared bearer token (default: no auth)
|
|
10
|
+
PB_HISTORY number of clips to keep (default 100)
|
|
11
|
+
PB_MAX_BYTES max request body size (default 1 MiB)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sqlite3
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from urllib.parse import parse_qs, urlparse
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
|
|
26
|
+
PORT = int(os.environ.get("PB_PORT", "8737"))
|
|
27
|
+
DATA = os.environ.get("PB_DATA") or os.path.join(os.path.expanduser("~"), ".local", "share", "pb")
|
|
28
|
+
TOKEN = os.environ.get("PB_TOKEN", "")
|
|
29
|
+
HISTORY = int(os.environ.get("PB_HISTORY", "100"))
|
|
30
|
+
MAX_BYTES = int(os.environ.get("PB_MAX_BYTES", str(1024 * 1024)))
|
|
31
|
+
|
|
32
|
+
WATCH_TIMEOUT_MAX = 60.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Store:
|
|
36
|
+
"""All access serialized under one lock; watchers wait on the condition."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path):
|
|
39
|
+
self.cond = threading.Condition()
|
|
40
|
+
self.db = sqlite3.connect(path, check_same_thread=False)
|
|
41
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
42
|
+
self.db.execute(
|
|
43
|
+
"CREATE TABLE IF NOT EXISTS clips("
|
|
44
|
+
" seq INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
45
|
+
" ts REAL NOT NULL,"
|
|
46
|
+
" mime TEXT NOT NULL,"
|
|
47
|
+
" text TEXT NOT NULL,"
|
|
48
|
+
" device TEXT NOT NULL)"
|
|
49
|
+
)
|
|
50
|
+
self.db.commit()
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _clip(row):
|
|
54
|
+
if row is None:
|
|
55
|
+
return None
|
|
56
|
+
seq, ts, mime, text, device = row
|
|
57
|
+
return {"seq": seq, "ts": ts, "mime": mime, "text": text, "device": device}
|
|
58
|
+
|
|
59
|
+
def _latest(self):
|
|
60
|
+
row = self.db.execute(
|
|
61
|
+
"SELECT seq, ts, mime, text, device FROM clips ORDER BY seq DESC LIMIT 1"
|
|
62
|
+
).fetchone()
|
|
63
|
+
return self._clip(row)
|
|
64
|
+
|
|
65
|
+
def latest(self):
|
|
66
|
+
with self.cond:
|
|
67
|
+
return self._latest()
|
|
68
|
+
|
|
69
|
+
def add(self, text, device, mime="text/plain"):
|
|
70
|
+
with self.cond:
|
|
71
|
+
head = self._latest()
|
|
72
|
+
if head is not None and head["text"] == text:
|
|
73
|
+
return head # dedup: identical to current head, no new entry
|
|
74
|
+
self.db.execute(
|
|
75
|
+
"INSERT INTO clips(ts, mime, text, device) VALUES(?, ?, ?, ?)",
|
|
76
|
+
(time.time(), mime, text, device),
|
|
77
|
+
)
|
|
78
|
+
self.db.execute(
|
|
79
|
+
"DELETE FROM clips WHERE seq NOT IN"
|
|
80
|
+
" (SELECT seq FROM clips ORDER BY seq DESC LIMIT ?)",
|
|
81
|
+
(HISTORY,),
|
|
82
|
+
)
|
|
83
|
+
self.db.commit()
|
|
84
|
+
self.cond.notify_all()
|
|
85
|
+
return self._latest()
|
|
86
|
+
|
|
87
|
+
def history(self, limit):
|
|
88
|
+
with self.cond:
|
|
89
|
+
rows = self.db.execute(
|
|
90
|
+
"SELECT seq, ts, mime, text, device FROM clips"
|
|
91
|
+
" ORDER BY seq DESC LIMIT ?",
|
|
92
|
+
(limit,),
|
|
93
|
+
).fetchall()
|
|
94
|
+
return [self._clip(r) for r in rows]
|
|
95
|
+
|
|
96
|
+
def watch(self, since, timeout):
|
|
97
|
+
"""Block until a clip with seq > since exists, or timeout. Returns clip or None."""
|
|
98
|
+
deadline = time.monotonic() + timeout
|
|
99
|
+
with self.cond:
|
|
100
|
+
while True:
|
|
101
|
+
head = self._latest()
|
|
102
|
+
if head is not None and head["seq"] > since:
|
|
103
|
+
return head
|
|
104
|
+
remaining = deadline - time.monotonic()
|
|
105
|
+
if remaining <= 0:
|
|
106
|
+
return None
|
|
107
|
+
self.cond.wait(remaining)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Handler(BaseHTTPRequestHandler):
|
|
111
|
+
protocol_version = "HTTP/1.1"
|
|
112
|
+
server_version = f"pbd/{__version__}"
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def store(self):
|
|
116
|
+
return self.server.store
|
|
117
|
+
|
|
118
|
+
def log_request(self, code="-", size="-"):
|
|
119
|
+
# Long-poll timeouts and healthchecks are routine; don't spam the log.
|
|
120
|
+
# path is unset when the request line failed to parse (e.g. a TLS hello).
|
|
121
|
+
if str(code) == "204" or getattr(self, "path", "") == "/healthz":
|
|
122
|
+
return
|
|
123
|
+
super().log_request(code, size)
|
|
124
|
+
|
|
125
|
+
def send(self, status, obj=None, text=None):
|
|
126
|
+
body = b""
|
|
127
|
+
ctype = "application/json"
|
|
128
|
+
if obj is not None:
|
|
129
|
+
body = json.dumps(obj, ensure_ascii=False).encode()
|
|
130
|
+
elif text is not None:
|
|
131
|
+
body = text.encode()
|
|
132
|
+
ctype = "text/plain; charset=utf-8"
|
|
133
|
+
self.send_response(status)
|
|
134
|
+
if body:
|
|
135
|
+
self.send_header("Content-Type", ctype)
|
|
136
|
+
self.send_header("Content-Length", str(len(body)))
|
|
137
|
+
self.end_headers()
|
|
138
|
+
if body:
|
|
139
|
+
self.wfile.write(body)
|
|
140
|
+
|
|
141
|
+
def authed(self):
|
|
142
|
+
if not TOKEN:
|
|
143
|
+
return True
|
|
144
|
+
got = self.headers.get("Authorization", "")
|
|
145
|
+
if hmac.compare_digest(got, f"Bearer {TOKEN}"):
|
|
146
|
+
return True
|
|
147
|
+
self.send(401, {"error": "unauthorized"})
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def qint(qs, key, default, lo, hi):
|
|
152
|
+
try:
|
|
153
|
+
return max(lo, min(hi, int(qs.get(key, [default])[0])))
|
|
154
|
+
except ValueError:
|
|
155
|
+
return default
|
|
156
|
+
|
|
157
|
+
def do_GET(self):
|
|
158
|
+
url = urlparse(self.path)
|
|
159
|
+
qs = parse_qs(url.query)
|
|
160
|
+
if url.path == "/healthz":
|
|
161
|
+
return self.send(200, text="ok\n")
|
|
162
|
+
if not self.authed():
|
|
163
|
+
return
|
|
164
|
+
if url.path == "/clip":
|
|
165
|
+
clip = self.store.latest()
|
|
166
|
+
return self.send(200, clip) if clip else self.send(204)
|
|
167
|
+
if url.path == "/history":
|
|
168
|
+
limit = self.qint(qs, "limit", 50, 1, HISTORY)
|
|
169
|
+
return self.send(200, {"clips": self.store.history(limit)})
|
|
170
|
+
if url.path == "/watch":
|
|
171
|
+
since = self.qint(qs, "since", 0, 0, 2**62)
|
|
172
|
+
timeout = self.qint(qs, "timeout", 30, 1, int(WATCH_TIMEOUT_MAX))
|
|
173
|
+
clip = self.store.watch(since, float(timeout))
|
|
174
|
+
return self.send(200, clip) if clip else self.send(204)
|
|
175
|
+
self.send(404, {"error": "not found"})
|
|
176
|
+
|
|
177
|
+
def do_POST(self):
|
|
178
|
+
if not self.authed():
|
|
179
|
+
return
|
|
180
|
+
if urlparse(self.path).path != "/clip":
|
|
181
|
+
return self.send(404, {"error": "not found"})
|
|
182
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
183
|
+
if length > MAX_BYTES:
|
|
184
|
+
# Drain the body (bounded) so the client reads our 413 instead of EPIPE.
|
|
185
|
+
remaining = min(length, 64 * 1024 * 1024)
|
|
186
|
+
while remaining > 0:
|
|
187
|
+
chunk = self.rfile.read(min(65536, remaining))
|
|
188
|
+
if not chunk:
|
|
189
|
+
break
|
|
190
|
+
remaining -= len(chunk)
|
|
191
|
+
self.close_connection = True
|
|
192
|
+
return self.send(413, {"error": f"body exceeds {MAX_BYTES} bytes"})
|
|
193
|
+
try:
|
|
194
|
+
req = json.loads(self.rfile.read(length))
|
|
195
|
+
text = req["text"]
|
|
196
|
+
if not isinstance(text, str):
|
|
197
|
+
raise ValueError("text must be a string")
|
|
198
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
199
|
+
return self.send(400, {"error": f"bad request: {e}"})
|
|
200
|
+
device = str(req.get("device", ""))
|
|
201
|
+
mime = str(req.get("mime", "text/plain"))
|
|
202
|
+
clip = self.store.add(text, device, mime)
|
|
203
|
+
self.send(200, {"seq": clip["seq"]})
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def main():
|
|
207
|
+
os.makedirs(DATA, exist_ok=True)
|
|
208
|
+
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
|
209
|
+
server.store = Store(os.path.join(DATA, "pb.db"))
|
|
210
|
+
print(f"pbd listening on :{PORT} (data={DATA}, history={HISTORY},"
|
|
211
|
+
f" auth={'on' if TOKEN else 'off'})", file=sys.stderr)
|
|
212
|
+
try:
|
|
213
|
+
server.serve_forever()
|
|
214
|
+
except KeyboardInterrupt:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "beamboard"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Beam your clipboard between devices: the pbd server plus the pb client"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
authors = [{ name = "Jonas Eschmann", email = "jonas.eschmann@gmail.com" }]
|
|
12
|
+
keywords = ["clipboard", "pasteboard", "sync", "pbcopy", "pbpaste", "self-hosted"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Utilities",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
pb = "beamboard.cli:main"
|
|
24
|
+
beamboard = "beamboard.cli:main"
|
|
25
|
+
pbd = "beamboard.server:main"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.version]
|
|
28
|
+
path = "beamboard/__init__.py"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["beamboard"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = ["beamboard", "README.md", "pyproject.toml"]
|