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.
@@ -0,0 +1,9 @@
1
+ .DS_Store
2
+ __pycache__/
3
+ server/data/
4
+ mac/.build/
5
+ mac/BeamBoard.app/
6
+ .venv/
7
+ dist/
8
+ build/
9
+ *.egg-info/
@@ -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,3 @@
1
+ """beamboard: self-hosted pasteboard sync (pbd server + pb client)."""
2
+
3
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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"]