@vibelord/lanes 1.1.1 → 1.3.0
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.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -3
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""lanes users — the per-user profile store users/<quote(login)>.json (cockpit v3 R1, LNS-74).
|
|
2
|
+
|
|
3
|
+
One JSON file per person under ~/.local/state/lanes/users/ (directory 0700, files 0600), shared by every device that
|
|
4
|
+
person owns (members.owner_for decides ownership, as devices/<device>.json records it):
|
|
5
|
+
|
|
6
|
+
{"version": 1, "login": "anna@example.com", "name": "Anna Berg",
|
|
7
|
+
"look": {"theme": "light", "color": "256", "glyphs": "ascii"},
|
|
8
|
+
"seen": {"bw-tools--lanes-zellij": 1791000000.25},
|
|
9
|
+
"workspaces": {"order": ["throttle", "bw-tools"], "hidden": []},
|
|
10
|
+
"lanes": {"pins": ["bw-tools--lanes-zellij"], "hidden": []},
|
|
11
|
+
"last_opened": {"lane": "bw-tools--lanes-zellij", "device": "anna-mbp", "at": 1791000000.25},
|
|
12
|
+
"notify": {"quiet_s": 120}, "updated_at": 1791000000.25, "updated_by_device": "anna-mbp"}
|
|
13
|
+
|
|
14
|
+
* The file name is urllib.parse.quote(login, safe='') + '.json' (a login carries '@', and may carry '/'); the
|
|
15
|
+
`login` inside the file is authoritative: a file whose login is not the one its name quotes is nobody's profile.
|
|
16
|
+
A login whose quoted name would not fit a file name (MAX_STEM) has no profile.
|
|
17
|
+
* Times are epoch seconds. `seen` is DM-LANES-CLIENT-SEEN's {lane id: when it was last opened} for the person (the
|
|
18
|
+
newest MAX_SEEN marks); `look` holds only the keys set, with bin/lanes LOOK_KEYS' values (`notify`/`dim` stay
|
|
19
|
+
per-device); `notify` holds `quiet_s` only when set; `last_opened` is null until a lane is opened. Lane ids are
|
|
20
|
+
L.IDENT (at most MAX_LANE_ID characters), workspace ids L.IDENT at most 64, devices members.device_name, `name`
|
|
21
|
+
one printable line (members._text: no control, format, surrogate, private-use or unassigned character).
|
|
22
|
+
* Readers are tolerant and never raise: an invalid entry is dropped on its own; unknown top-level keys (a newer
|
|
23
|
+
additive field) are kept and written back unchanged. A file that is not a JSON object, whose `version` is not 1
|
|
24
|
+
(absent = 1), or whose login does not match is not read (read() is None) and is never overwritten.
|
|
25
|
+
* Writers go through update(login, device, fn): under a flock on users/<stem>.lock waited for at most LOCK_S, the
|
|
26
|
+
stored profile is read, `fn` changes a copy, and the result is MERGED into the stored one: `seen` keeps the
|
|
27
|
+
later mark per lane, `last_opened` the later `at`, and every other field is last-writer-wins by `updated_at`
|
|
28
|
+
(stamped later than the stored one, so the writer's version wins them). A timeout, an unwritable store or an
|
|
29
|
+
unreadable file DROPS the write and returns False — it never raises into a sidebar; the next tick retries. An
|
|
30
|
+
update that changes nothing writes nothing (readers keyed on the file's mtime stay asleep).
|
|
31
|
+
* migrate(login) builds the profile from the person's devices: the look from the most recently attached (each look
|
|
32
|
+
key from the newest device that sets it; an existing profile's keys win), `seen` the union-max of their
|
|
33
|
+
clients/<device>.seen.json; then those devices' look keys leave devices/<device>.json so the profile wins. A key
|
|
34
|
+
a device file's `device_only` list names (`lanes attach --device-only`; device_only()) is that device's own and
|
|
35
|
+
is neither folded nor removed; the other look keys of a device file (unfolded()) are what migrates.
|
|
36
|
+
* Callers (cockpit v3 R2, LNS-75, bin/lanes): `lanes attach`'s theme/color/glyphs on a device with an owner
|
|
37
|
+
(login_for) go through update() and leave that device's file, unless `--device-only` keeps them there (and names
|
|
38
|
+
them in its `device_only` list); `lanes device claim` and `lanes keys adopt --login` / `lanes keys owner` migrate
|
|
39
|
+
the devices they name (with their windows) — a claim the member record agrees with included, which is how a
|
|
40
|
+
device owned since W8 folds the look it recorded before R2 (`lanes user show` names that look and the claim, R3
|
|
41
|
+
review fix DEF-1782); `lanes user list|show` read the store (device_logins, inspect) and write nothing. Since
|
|
42
|
+
cockpit v3 R3 (LNS-76) the sidebar of a device with an owner paints the profile's look beneath the device file's
|
|
43
|
+
`device_only` keys, re-reads the profile when its mtime moves (a stamp field), and writes `seen` + `last_opened`
|
|
44
|
+
through update() when a lane is opened there; `lanes next|prev` write them too. Since cockpit v3 R4 (LNS-77)
|
|
45
|
+
`workspaces{order, hidden}` arranges the view (arrange(): bin/lanes tab_specs for create_session and `lanes
|
|
46
|
+
upgrade-session`, which closes a hidden board's tab; the sidebar's phone owner board). Since R6 (LNS-79) `lanes
|
|
47
|
+
workspaces hide|show|order` and `lanes lane pin|unpin|hide|show` write `workspaces` and `lanes` through update(),
|
|
48
|
+
and `lanes here <device>` reads `last_opened`. Since R7 (LNS-80) the push notifier (notify.py) reads every profile
|
|
49
|
+
(load_all): one written by a desk device within the quiet window is presence, and `notify.quiet_s` is that
|
|
50
|
+
person's own window. `updated_by_device` names the device a change was made AT (a sidebar, `lanes attach`, `lanes
|
|
51
|
+
next|prev`, `lanes here`); the arranging verbs stamp `cli:<command>` and migrate() `cli:migration` instead
|
|
52
|
+
(writers that are no device, so an admin's verb over SSH is never somebody at a desk: W9 review F3).
|
|
53
|
+
"""
|
|
54
|
+
import copy
|
|
55
|
+
import fcntl
|
|
56
|
+
import json
|
|
57
|
+
import math
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
import time
|
|
61
|
+
import urllib.parse
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
import lanes_lib as L
|
|
65
|
+
import members
|
|
66
|
+
|
|
67
|
+
VERSION = 1
|
|
68
|
+
LOCK_S = 1.0 # seconds update() waits for users/<stem>.lock before it drops the write
|
|
69
|
+
FIELDS = ('version', 'login', 'name', 'look', 'seen', 'workspaces', 'lanes', 'last_opened', 'notify', 'updated_at',
|
|
70
|
+
'updated_by_device')
|
|
71
|
+
# bin/lanes LOOK_KEYS' theme/color/glyphs, value for value (test_users pins the equality).
|
|
72
|
+
LOOK = {'theme': ('dark', 'light', 'auto'), 'color': ('truecolor', '256', '16', 'none'),
|
|
73
|
+
'glyphs': ('unicode', 'ascii', 'emoji')}
|
|
74
|
+
MAX_STEM = 200 # '.' + stem + '.json.' + 8 (L.atomic_write's temp name) stays under NAME_MAX 255
|
|
75
|
+
MAX_LANE_ID = 256 # L.TRACE's cap: a longer lane id cannot be traced either
|
|
76
|
+
MAX_SEEN = 1024 # newest marks kept; an older mark only re-shows a Done lane until it is opened
|
|
77
|
+
MAX_LANES = 512 # entries per lanes.pins / lanes.hidden
|
|
78
|
+
MAX_WORKSPACES = 256 # entries per workspaces.order / workspaces.hidden
|
|
79
|
+
MAX_QUIET_S = 86400
|
|
80
|
+
SEEN_SUFFIX = '.seen.json' # clients/<device>.seen.json (DM-LANES-CLIENT-SEEN)
|
|
81
|
+
STAMPS = ('updated_at', 'updated_by_device')
|
|
82
|
+
# A profile writer that is no device (W9 review F3): `cli:<command>` — the CLI's arranging verbs (`cli:workspaces`,
|
|
83
|
+
# `cli:lane`) and migrate() (MIGRATION, for `lanes device claim` / `lanes keys adopt --login` / `lanes keys owner`).
|
|
84
|
+
# A device name (members.device_name, L.IDENT) cannot hold `:`, so a writer is never read as a device: the push
|
|
85
|
+
# notifier's desk presence (notify.presence_marks) counts only a write a device made (writer_device).
|
|
86
|
+
CLI_WRITER = re.compile(r'cli:[a-z][a-z-]{0,31}\Z')
|
|
87
|
+
MIGRATION = 'cli:migration'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def users_dir():
|
|
91
|
+
return L.STATE / 'users'
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------------------------------------------ identities
|
|
95
|
+
|
|
96
|
+
def stem(login):
|
|
97
|
+
"""The file stem of `login`'s profile: the login percent-quoted with nothing safe. Raises L.LanesError for a login
|
|
98
|
+
members.check_login refuses, or one whose stem is longer than MAX_STEM; never echoes the value."""
|
|
99
|
+
quoted = urllib.parse.quote(members.check_login(login), safe='')
|
|
100
|
+
if len(quoted) > MAX_STEM:
|
|
101
|
+
raise L.LanesError(f'login is too long for a profile file name (quoted, more than {MAX_STEM} characters)')
|
|
102
|
+
return quoted
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def login_of(file_stem):
|
|
106
|
+
"""The login a file stem names (the inverse of stem() for every stem it makes)."""
|
|
107
|
+
return urllib.parse.unquote(file_stem)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def path(login, directory=None):
|
|
111
|
+
return Path(directory or users_dir()) / f'{stem(login)}.json'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def lock_path(login, directory=None):
|
|
115
|
+
return Path(directory or users_dir()) / f'{stem(login)}.lock'
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_lane(value):
|
|
119
|
+
"""A lane id: L.IDENT, at most MAX_LANE_ID characters. Raises L.LanesError (never echoing the value)."""
|
|
120
|
+
if not isinstance(value, str) or len(value) > MAX_LANE_ID or not L.IDENT.match(value):
|
|
121
|
+
raise L.LanesError(f'a lane id is not an identifier of at most {MAX_LANE_ID} characters')
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_workspace(value):
|
|
126
|
+
"""A workspace id: L.IDENT, at most 64 characters (members' rule for board ids)."""
|
|
127
|
+
return members._ident(value, 'a workspace id', members.MAX_ID)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cli_writer(command):
|
|
131
|
+
"""The `updated_by_device` a CLI command stamps on a profile it writes: `cli:<command>` (CLI_WRITER)."""
|
|
132
|
+
return check_writer('cli:' + command)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_writer(value):
|
|
136
|
+
"""A valid `updated_by_device`: a device name (members.device_name) or a CLI writer (CLI_WRITER). Raises
|
|
137
|
+
L.LanesError, never echoing the value."""
|
|
138
|
+
if isinstance(value, str) and CLI_WRITER.match(value):
|
|
139
|
+
return value
|
|
140
|
+
try:
|
|
141
|
+
return members.device_name(value)
|
|
142
|
+
except L.LanesError:
|
|
143
|
+
raise L.LanesError('a profile writer is a device name or cli:<command>') from None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def writer_device(value):
|
|
147
|
+
"""The device an `updated_by_device` value names — None for a CLI writer, None, or anything invalid: what the
|
|
148
|
+
push notifier may count as somebody at that device (W9 review F3)."""
|
|
149
|
+
return value if _valid(members.device_name, value) else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _valid(check, value):
|
|
153
|
+
try:
|
|
154
|
+
check(value)
|
|
155
|
+
except L.LanesError:
|
|
156
|
+
return False
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _time(value):
|
|
161
|
+
"""An epoch-seconds value a profile may hold: a finite, non-negative int or float (never a bool)."""
|
|
162
|
+
return (isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
163
|
+
and value >= 0)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------------------------------------- shape
|
|
167
|
+
|
|
168
|
+
def empty(login):
|
|
169
|
+
"""A profile with nothing set (what a person without a file has), exactly the schema in its order."""
|
|
170
|
+
return {'version': VERSION, 'login': login, 'name': None, 'look': {}, 'seen': {},
|
|
171
|
+
'workspaces': {'order': [], 'hidden': []}, 'lanes': {'pins': [], 'hidden': []}, 'last_opened': None,
|
|
172
|
+
'notify': {}, 'updated_at': 0, 'updated_by_device': None}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ids(values, check, limit):
|
|
176
|
+
out, kept = [], set()
|
|
177
|
+
for value in values if isinstance(values, list) else ():
|
|
178
|
+
if isinstance(value, str) and value not in kept and _valid(check, value):
|
|
179
|
+
kept.add(value)
|
|
180
|
+
out.append(value)
|
|
181
|
+
return out[:limit]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _bounded(seen):
|
|
185
|
+
"""`seen` as stored: the newest MAX_SEEN marks, keys in order (stable bytes)."""
|
|
186
|
+
if len(seen) > MAX_SEEN:
|
|
187
|
+
seen = dict(sorted(seen.items(), key=lambda item: (-item[1], item[0]))[:MAX_SEEN])
|
|
188
|
+
return dict(sorted(seen.items()))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _last_opened(value):
|
|
192
|
+
if not isinstance(value, dict):
|
|
193
|
+
return None
|
|
194
|
+
lane, device, at = value.get('lane'), value.get('device'), value.get('at')
|
|
195
|
+
if not (_valid(check_lane, lane) and _valid(members.device_name, device) and _time(at)):
|
|
196
|
+
return None
|
|
197
|
+
return {'lane': lane, 'device': device, 'at': at}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def normalize(document, login=None):
|
|
201
|
+
"""`document` as a v1 profile: every valid field in schema order, each invalid entry dropped on its own, unknown
|
|
202
|
+
top-level keys kept after the schema. `login`, when given, is the profile's login whatever the document says.
|
|
203
|
+
None when `document` is not an object or the login is not valid. Never raises."""
|
|
204
|
+
if not isinstance(document, dict):
|
|
205
|
+
return None
|
|
206
|
+
if not _valid(members.check_login, document.get('login') if login is None else login):
|
|
207
|
+
return None
|
|
208
|
+
profile = empty(document.get('login') if login is None else login)
|
|
209
|
+
name = document.get('name')
|
|
210
|
+
if _valid(lambda value: members._text(value, 'name', members.MAX_NAME, blanks=True), name):
|
|
211
|
+
profile['name'] = name
|
|
212
|
+
look = document.get('look')
|
|
213
|
+
if isinstance(look, dict):
|
|
214
|
+
profile['look'] = {key: look[key] for key, allowed in LOOK.items() if isinstance(look.get(key), str)
|
|
215
|
+
and look[key] in allowed}
|
|
216
|
+
seen = document.get('seen')
|
|
217
|
+
if isinstance(seen, dict):
|
|
218
|
+
profile['seen'] = _bounded({lane: at for lane, at in seen.items() if _valid(check_lane, lane) and _time(at)})
|
|
219
|
+
for field, keys, check, limit in (('workspaces', ('order', 'hidden'), check_workspace, MAX_WORKSPACES),
|
|
220
|
+
('lanes', ('pins', 'hidden'), check_lane, MAX_LANES)):
|
|
221
|
+
value = document.get(field)
|
|
222
|
+
if isinstance(value, dict):
|
|
223
|
+
profile[field] = {key: _ids(value.get(key), check, limit) for key in keys}
|
|
224
|
+
profile['last_opened'] = _last_opened(document.get('last_opened'))
|
|
225
|
+
notify = document.get('notify')
|
|
226
|
+
quiet = notify.get('quiet_s') if isinstance(notify, dict) else None
|
|
227
|
+
if isinstance(quiet, int) and not isinstance(quiet, bool) and 0 <= quiet <= MAX_QUIET_S:
|
|
228
|
+
profile['notify'] = {'quiet_s': quiet}
|
|
229
|
+
if _time(document.get('updated_at')):
|
|
230
|
+
profile['updated_at'] = document['updated_at']
|
|
231
|
+
if _valid(check_writer, document.get('updated_by_device')):
|
|
232
|
+
profile['updated_by_device'] = document['updated_by_device']
|
|
233
|
+
for key, value in document.items():
|
|
234
|
+
if isinstance(key, str) and key not in FIELDS:
|
|
235
|
+
profile[key] = copy.deepcopy(value)
|
|
236
|
+
return profile
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def merge(current, incoming):
|
|
240
|
+
"""Two copies of one profile as one: `seen` the later mark per lane, `last_opened` the one with the later `at`,
|
|
241
|
+
every other field (identity, look, lists, notify, unknown keys, the stamps) from the copy with the later
|
|
242
|
+
`updated_at` — `incoming` on a tie. Either may be None (the other is returned, normalized). Pure."""
|
|
243
|
+
older, newer = (normalize(current) if current is not None else None,
|
|
244
|
+
normalize(incoming) if incoming is not None else None)
|
|
245
|
+
if older is None or newer is None:
|
|
246
|
+
return newer if older is None else older
|
|
247
|
+
if older['updated_at'] > newer['updated_at']:
|
|
248
|
+
winner = older
|
|
249
|
+
else:
|
|
250
|
+
winner = newer
|
|
251
|
+
merged = copy.deepcopy(winner)
|
|
252
|
+
seen = dict(older['seen'])
|
|
253
|
+
for lane, at in newer['seen'].items():
|
|
254
|
+
seen[lane] = max(at, seen.get(lane, at))
|
|
255
|
+
merged['seen'] = _bounded(seen)
|
|
256
|
+
first, second = older['last_opened'], newer['last_opened']
|
|
257
|
+
merged['last_opened'] = copy.deepcopy(second if second and (not first or second['at'] >= first['at']) else first)
|
|
258
|
+
return merged
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _encode(profile):
|
|
262
|
+
try:
|
|
263
|
+
return (json.dumps(profile, indent=2, ensure_ascii=False) + '\n').encode('utf-8')
|
|
264
|
+
except UnicodeEncodeError: # a lone surrogate in a kept unknown key: escape it rather than fail
|
|
265
|
+
return (json.dumps(profile, indent=2) + '\n').encode('ascii')
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _content(profile):
|
|
269
|
+
return {key: value for key, value in profile.items() if key not in STAMPS}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --------------------------------------------------------------------------------------------------------- reading
|
|
273
|
+
|
|
274
|
+
def _load(file, login):
|
|
275
|
+
"""(profile, None), (None, None) when there is no file, or (None, reason) for a file that exists but is not
|
|
276
|
+
`login`'s v1 profile (the reason never echoes a value)."""
|
|
277
|
+
try:
|
|
278
|
+
data = Path(file).read_bytes()
|
|
279
|
+
except FileNotFoundError:
|
|
280
|
+
return None, None
|
|
281
|
+
except OSError as error:
|
|
282
|
+
return None, error.strerror or 'cannot be read'
|
|
283
|
+
try:
|
|
284
|
+
document = json.loads(data.decode('utf-8'))
|
|
285
|
+
except (UnicodeDecodeError, ValueError):
|
|
286
|
+
return None, 'not JSON'
|
|
287
|
+
if not isinstance(document, dict):
|
|
288
|
+
return None, 'not a JSON object'
|
|
289
|
+
version = document.get('version', VERSION)
|
|
290
|
+
if type(version) is not int or version != VERSION:
|
|
291
|
+
return None, f'not a version {VERSION} profile (another release of lanes wrote it)'
|
|
292
|
+
if document.get('login') != login:
|
|
293
|
+
return None, 'its login is not the one its file name quotes'
|
|
294
|
+
return normalize(document), None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def read(login, directory=None):
|
|
298
|
+
"""`login`'s profile, normalized, or None (no file, an invalid login, or a file that is not its v1 profile).
|
|
299
|
+
Never raises."""
|
|
300
|
+
try:
|
|
301
|
+
file = path(login, directory)
|
|
302
|
+
except L.LanesError:
|
|
303
|
+
return None
|
|
304
|
+
return _load(file, login)[0]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def inspect(login, directory=None):
|
|
308
|
+
"""(profile, problem) for `lanes user show`: the normalized profile or None, and why a file that exists is not
|
|
309
|
+
read (a reason that never echoes a value) or None. Raises L.LanesError for a login that can have no profile (the
|
|
310
|
+
message never echoes it)."""
|
|
311
|
+
return _load(path(login, directory), login)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def load_all(directory=None):
|
|
315
|
+
"""([profiles sorted by file name], [(file name, reason)] for every other users/*.json). Never raises."""
|
|
316
|
+
profiles, problems = [], []
|
|
317
|
+
try:
|
|
318
|
+
files = sorted(Path(directory or users_dir()).glob('*.json'))
|
|
319
|
+
except OSError:
|
|
320
|
+
return profiles, problems
|
|
321
|
+
for file in files:
|
|
322
|
+
file_stem = file.name[:-len('.json')]
|
|
323
|
+
login = login_of(file_stem)
|
|
324
|
+
if not _valid(stem, login) or stem(login) != file_stem:
|
|
325
|
+
problems.append((file.name, 'its name is not a quoted login'))
|
|
326
|
+
continue
|
|
327
|
+
profile, problem = _load(file, login)
|
|
328
|
+
if problem:
|
|
329
|
+
problems.append((file.name, problem))
|
|
330
|
+
elif profile is not None:
|
|
331
|
+
profiles.append(profile)
|
|
332
|
+
return profiles, problems
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# --------------------------------------------------------------------------------------------------------- writing
|
|
336
|
+
|
|
337
|
+
def _acquire(lock, timeout):
|
|
338
|
+
"""An open descriptor holding an exclusive flock on `lock` (created 0600), or None after `timeout` seconds or on
|
|
339
|
+
any OS error."""
|
|
340
|
+
try:
|
|
341
|
+
fd = os.open(lock, os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600)
|
|
342
|
+
except OSError:
|
|
343
|
+
return None
|
|
344
|
+
deadline = time.monotonic() + timeout
|
|
345
|
+
while True:
|
|
346
|
+
try:
|
|
347
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
348
|
+
return fd
|
|
349
|
+
except BlockingIOError:
|
|
350
|
+
left = deadline - time.monotonic()
|
|
351
|
+
if left <= 0:
|
|
352
|
+
break
|
|
353
|
+
time.sleep(min(0.02, left))
|
|
354
|
+
except OSError:
|
|
355
|
+
break
|
|
356
|
+
os.close(fd)
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _stamp(now, current):
|
|
361
|
+
"""The new `updated_at`: now, but always later than the stored one (a clock that stepped back included)."""
|
|
362
|
+
now = time.time() if now is None else now
|
|
363
|
+
return round(max(now, (current['updated_at'] if current else 0) + 0.001), 3)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def update(login, device, fn, now=None, directory=None, timeout=LOCK_S):
|
|
367
|
+
"""Change `login`'s profile: `fn` gets a copy of the stored profile (an empty one when there is none) and changes
|
|
368
|
+
it in place or returns the new one; the result, stamped `updated_at` (now, later than the stored one) and
|
|
369
|
+
`updated_by_device` = `device` — the device the change was made AT (a sidebar, `lanes attach`, `lanes
|
|
370
|
+
next|prev`, `lanes here`), a CLI writer `cli:<command>` (cli_writer, MIGRATION: no device, W9 review F3), or None
|
|
371
|
+
for a caller that names none — is merged into the stored profile (merge()) and written atomically (0600) under the
|
|
372
|
+
lock. -> True when the profile holds the change (an update that changes nothing writes nothing), False when the
|
|
373
|
+
write was DROPPED: the lock was not free within `timeout` seconds (`fn` never ran), the login or the writer is not
|
|
374
|
+
valid (check_writer), the store cannot be written, or the file
|
|
375
|
+
exists but is not this login's v1 profile (never overwritten). Never raises for those; an exception `fn` raises
|
|
376
|
+
(the caller's own refusal) propagates and nothing is written."""
|
|
377
|
+
try:
|
|
378
|
+
file, lock = path(login, directory), lock_path(login, directory)
|
|
379
|
+
if device is not None:
|
|
380
|
+
check_writer(device)
|
|
381
|
+
except L.LanesError:
|
|
382
|
+
return False
|
|
383
|
+
try:
|
|
384
|
+
members._private_dir(file.parent)
|
|
385
|
+
except OSError:
|
|
386
|
+
return False
|
|
387
|
+
fd = _acquire(lock, timeout)
|
|
388
|
+
if fd is None:
|
|
389
|
+
return False
|
|
390
|
+
try:
|
|
391
|
+
current, problem = _load(file, login)
|
|
392
|
+
if problem:
|
|
393
|
+
return False
|
|
394
|
+
working = copy.deepcopy(current) if current is not None else empty(login)
|
|
395
|
+
result = fn(working)
|
|
396
|
+
incoming = normalize(working if result is None else result, login)
|
|
397
|
+
if incoming is None:
|
|
398
|
+
return False
|
|
399
|
+
incoming['updated_at'], incoming['updated_by_device'] = _stamp(now, current), device
|
|
400
|
+
merged = merge(current, incoming)
|
|
401
|
+
if current is not None and _content(merged) == _content(current):
|
|
402
|
+
return True
|
|
403
|
+
L.atomic_write(file, _encode(merged), mode=0o600)
|
|
404
|
+
return True
|
|
405
|
+
except OSError:
|
|
406
|
+
return False
|
|
407
|
+
finally:
|
|
408
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
409
|
+
os.close(fd)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------------------------------------------- migration
|
|
413
|
+
|
|
414
|
+
def _names(directory, suffix):
|
|
415
|
+
try:
|
|
416
|
+
return {name[:-len(suffix)] for name in os.listdir(directory) if name.endswith(suffix)}
|
|
417
|
+
except OSError:
|
|
418
|
+
return set()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _attached(clients, names):
|
|
422
|
+
"""{device: the newest mtime of its clients/<device>.<client>.pid} — the attach marks `lanes attach` writes. A
|
|
423
|
+
device name may carry dots (L.IDENT), so each pid file belongs to the LONGEST of `names` it starts with."""
|
|
424
|
+
newest = {}
|
|
425
|
+
try:
|
|
426
|
+
entries = os.listdir(clients)
|
|
427
|
+
except OSError:
|
|
428
|
+
return newest
|
|
429
|
+
for entry in entries:
|
|
430
|
+
if not entry.endswith('.pid'):
|
|
431
|
+
continue
|
|
432
|
+
device = max((name for name in names if entry.startswith(name + '.') and len(entry) > len(name) + 5),
|
|
433
|
+
key=len, default=None)
|
|
434
|
+
if device is None:
|
|
435
|
+
continue
|
|
436
|
+
try:
|
|
437
|
+
mtime = os.stat(os.path.join(clients, entry)).st_mtime
|
|
438
|
+
except OSError:
|
|
439
|
+
continue
|
|
440
|
+
newest[device] = max(mtime, newest.get(device, mtime))
|
|
441
|
+
return newest
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def owned_devices(login):
|
|
445
|
+
"""[{device, meta, owner, attached}] for every device `login` owns — a devices/<device>.json view, or a device
|
|
446
|
+
with a clients/<device>.seen.json left — oldest attach first. The owner is members.owner_for over the device and
|
|
447
|
+
its recorded base (a window such as mac-2 is the mac's), as create_session derives it; `attached` is the newest
|
|
448
|
+
clients/<device>.*.pid mtime, else the view's created_at, else 0. Never raises."""
|
|
449
|
+
devices, clients = L.STATE / 'devices', L.STATE / 'clients'
|
|
450
|
+
names = {name for name in _names(devices, '.json') | _names(clients, SEEN_SUFFIX)
|
|
451
|
+
if _valid(members.device_name, name)}
|
|
452
|
+
attached = _attached(clients, names)
|
|
453
|
+
found = []
|
|
454
|
+
for name in names:
|
|
455
|
+
meta = L.read_json(devices / f'{name}.json')
|
|
456
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
457
|
+
owner = members.owner_for((name, meta.get('base')), meta.get('owner'))
|
|
458
|
+
if owner and owner['login'] == login:
|
|
459
|
+
created = meta.get('created_at')
|
|
460
|
+
found.append({'device': name, 'meta': meta, 'owner': owner,
|
|
461
|
+
'attached': attached.get(name, created if _time(created) else 0)})
|
|
462
|
+
return sorted(found, key=lambda found_device: (found_device['attached'], found_device['device']))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def owner_of(device, meta=None, members_dir=None):
|
|
466
|
+
"""The normalized owner {login, name, email} `device` follows, or None: members.owner_for over the device and the
|
|
467
|
+
base its devices/<device>.json records, the recorded owner kept while no member record speaks
|
|
468
|
+
(DM-LANES-DEVICE-OWNER) — exactly bin/lanes device_owner. `meta` is that file's document when the caller has it;
|
|
469
|
+
`members_dir` the members/ directory to consult (default members.members_dir(): the sidebar names the one beside
|
|
470
|
+
its own state). Never raises."""
|
|
471
|
+
if not _valid(members.device_name, device):
|
|
472
|
+
return None
|
|
473
|
+
if meta is None:
|
|
474
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
475
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
476
|
+
return members.owner_for((device, meta.get('base')), meta.get('owner'), directory=members_dir)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def login_for(device, meta=None, members_dir=None):
|
|
480
|
+
"""The login whose profile `device` follows (cockpit v3 R2, LNS-75): owner_of's login, None for a device nobody
|
|
481
|
+
owns — which keeps its look and seen marks to itself. Callers: bin/lanes profile_for (`lanes attach`, `lanes
|
|
482
|
+
next|prev`) and the sidebar (cockpit v3 R3, LNS-76), so the profile a look is written to is the one painted. Never
|
|
483
|
+
raises."""
|
|
484
|
+
owner = owner_of(device, meta, members_dir)
|
|
485
|
+
return owner['login'] if owner else None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def device_logins():
|
|
489
|
+
"""{device: login or None} for every device lanes knows of — a devices/<device>.json view, a members/<device>.json
|
|
490
|
+
record, a clients/<device>.seen.json — through login_for. What `lanes user list|show` group by. Never raises."""
|
|
491
|
+
names = (_names(L.STATE / 'devices', '.json') | _names(members.members_dir(), '.json')
|
|
492
|
+
| _names(L.STATE / 'clients', SEEN_SUFFIX))
|
|
493
|
+
return {name: login_for(name) for name in sorted(names) if _valid(members.device_name, name)}
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ------------------------------------------------------------------------------------- the person's arrangement
|
|
497
|
+
|
|
498
|
+
def arrange(boards, profile):
|
|
499
|
+
"""The workspace boards a person sees, in their order (cockpit v3 R4, LNS-77): `boards` (registry.json's
|
|
500
|
+
`workspaces`, catalog order) without the ids the profile's `workspaces.hidden` names, ordered by its
|
|
501
|
+
`workspaces.order` — the ids it names first, in its order (an id no board has is ignored), every board it does not
|
|
502
|
+
name after them in catalog order. An empty visible set is refused: a hidden list that would leave no board hides
|
|
503
|
+
none (no view, sidebar or phone is ever left without a board; the order still applies). `profile` None, or one
|
|
504
|
+
without `workspaces`, is the catalog unchanged. A new list; `boards` is not modified. Pure; never raises.
|
|
505
|
+
Callers: bin/lanes tab_specs (the tabs a view has, `lanes upgrade-session` closing the hidden ones) and the
|
|
506
|
+
sidebar (the phone's owner board, the first one shown)."""
|
|
507
|
+
spaces = profile.get('workspaces') if isinstance(profile, dict) else None
|
|
508
|
+
spaces = spaces if isinstance(spaces, dict) else {}
|
|
509
|
+
order, hidden = (spaces.get(key) if isinstance(spaces.get(key), list) else [] for key in ('order', 'hidden'))
|
|
510
|
+
rank = {}
|
|
511
|
+
for ident in order:
|
|
512
|
+
if isinstance(ident, str):
|
|
513
|
+
rank.setdefault(ident, len(rank))
|
|
514
|
+
hidden = {ident for ident in hidden if isinstance(ident, str)}
|
|
515
|
+
shown = [board for board in boards if board.get('id') not in hidden] or list(boards)
|
|
516
|
+
keyed = sorted(enumerate(shown), key=lambda item: (0, rank[item[1].get('id')], item[0])
|
|
517
|
+
if item[1].get('id') in rank else (1, 0, item[0]))
|
|
518
|
+
return [board for _index, board in keyed]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------------------------------- a device file's own look
|
|
522
|
+
|
|
523
|
+
def device_only(meta):
|
|
524
|
+
"""{key: value} of the look devices/<device>.json keeps for that device alone: each LOOK key its `device_only` list
|
|
525
|
+
names (`lanes attach --device-only` writes the list, cockpit v3 R3 review fix, DEF-1782) and holds a valid value
|
|
526
|
+
for, in LOOK order. Only these win over the owner's profile on that device (sidebar.layered_look). Anything that
|
|
527
|
+
is not such a list marks nothing. Pure; never raises."""
|
|
528
|
+
marked = meta.get('device_only') if isinstance(meta, dict) else None
|
|
529
|
+
if not isinstance(marked, list):
|
|
530
|
+
return {}
|
|
531
|
+
return {key: meta[key] for key, allowed in LOOK.items()
|
|
532
|
+
if key in marked and isinstance(meta.get(key), str) and meta[key] in allowed}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def unfolded(meta):
|
|
536
|
+
"""{key: value} of the device file's OTHER valid LOOK keys: a look recorded on the device before it had an owner,
|
|
537
|
+
or by a release before R2 gave owners a profile (whose record_look wrote every flag to the device file and marked
|
|
538
|
+
none). On a device with an owner it is painted only where the profile sets none (sidebar.layered_look), `lanes user
|
|
539
|
+
show` names it, and it is what migrate folds into the profile (`lanes device claim`, also when the member record
|
|
540
|
+
already names that login; `keys adopt --login`; `keys owner`). Never folded behind anyone's back: a device handed
|
|
541
|
+
to another person still holds its previous owner's look. Pure; never raises."""
|
|
542
|
+
if not isinstance(meta, dict):
|
|
543
|
+
return {}
|
|
544
|
+
own = device_only(meta)
|
|
545
|
+
return {key: meta[key] for key, allowed in LOOK.items()
|
|
546
|
+
if key not in own and isinstance(meta.get(key), str) and meta[key] in allowed}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def mark_device_only(meta, keys):
|
|
550
|
+
"""A copy of devices/<device>.json `meta` whose `device_only` list names `keys` (LOOK's keys among them, in LOOK
|
|
551
|
+
order), or without the list when none is left — so a device that never used --device-only keeps its bytes."""
|
|
552
|
+
marked = [key for key in LOOK if key in set(keys)]
|
|
553
|
+
updated = dict(meta)
|
|
554
|
+
if marked:
|
|
555
|
+
updated['device_only'] = marked
|
|
556
|
+
else:
|
|
557
|
+
updated.pop('device_only', None)
|
|
558
|
+
return updated
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _drop_look(device):
|
|
562
|
+
"""Remove the unfolded LOOK keys from an existing devices/<device>.json (bin/lanes' own encoding) — never a
|
|
563
|
+
--device-only one or its mark (an invalid value goes, and its mark with it); never creates the file."""
|
|
564
|
+
file = L.STATE / 'devices' / f'{device}.json'
|
|
565
|
+
meta = L.read_json(file)
|
|
566
|
+
if not isinstance(meta, dict):
|
|
567
|
+
return
|
|
568
|
+
own = device_only(meta)
|
|
569
|
+
updated = {key: value for key, value in meta.items() if key not in LOOK or key in own}
|
|
570
|
+
if 'device_only' in meta:
|
|
571
|
+
updated = mark_device_only(updated, own)
|
|
572
|
+
if updated == meta:
|
|
573
|
+
return
|
|
574
|
+
try:
|
|
575
|
+
L.atomic_write(file, json.dumps(updated) + '\n')
|
|
576
|
+
except OSError:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def migrate(login, devices=None, now=None, timeout=LOCK_S):
|
|
581
|
+
"""Build `login`'s profile from the devices it owns (owned_devices; only those named in `devices` when given):
|
|
582
|
+
each look key from the most recently attached device that sets it, unless the profile already has that key;
|
|
583
|
+
`seen` the union-max of the devices' clients/<device>.seen.json and the profile's own; `name` from the member
|
|
584
|
+
record when the profile has none. Written through update() as MIGRATION (`cli:migration`: the fold is a CLI act —
|
|
585
|
+
a claim or an adoption, often over SSH — never somebody at one of those devices, W9 review F3); only after it
|
|
586
|
+
landed do those devices' look keys (theme, color, glyphs — never notify/dim) leave devices/<device>.json, so the
|
|
587
|
+
profile wins. A key the device file's `device_only` list marks (`lanes attach --device-only`) is the device's own
|
|
588
|
+
choice: it is neither folded nor removed (unfolded() is what moves). -> the stored profile (an empty one for a
|
|
589
|
+
login that owns no device), or None when the write was dropped (the lock, an unreadable profile, an invalid login:
|
|
590
|
+
no device file is touched then). Never raises."""
|
|
591
|
+
if not _valid(members.check_login, login):
|
|
592
|
+
return None
|
|
593
|
+
wanted = None if devices is None else {devices} if isinstance(devices, str) else set(devices)
|
|
594
|
+
owned = [found for found in owned_devices(login) if wanted is None or found['device'] in wanted]
|
|
595
|
+
look, seen = {}, {}
|
|
596
|
+
for found in owned: # oldest attach first: the newest device's value wins per key
|
|
597
|
+
look.update(unfolded(found['meta']))
|
|
598
|
+
marks = L.clean_seen(L.read_json(L.STATE / 'clients' / f"{found['device']}{SEEN_SUFFIX}"))
|
|
599
|
+
for lane, at in marks.items():
|
|
600
|
+
seen[lane] = max(at, seen.get(lane, at))
|
|
601
|
+
name = next((found['owner']['name'] for found in reversed(owned) if found['owner'].get('name')), None)
|
|
602
|
+
|
|
603
|
+
def fill(profile):
|
|
604
|
+
profile['look'] = {**look, **profile['look']}
|
|
605
|
+
for lane, at in seen.items():
|
|
606
|
+
profile['seen'][lane] = max(at, profile['seen'].get(lane, at))
|
|
607
|
+
if profile['name'] is None and name:
|
|
608
|
+
profile['name'] = name
|
|
609
|
+
|
|
610
|
+
if not update(login, MIGRATION, fill, now=now, timeout=timeout):
|
|
611
|
+
return None
|
|
612
|
+
for found in owned:
|
|
613
|
+
_drop_look(found['device'])
|
|
614
|
+
return read(login)
|