mnfst 0.5.254 → 0.5.256
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/lib/manifest.appwrite.auth.js +311 -1
- package/lib/manifest.appwrite.data.js +331 -220
- package/lib/manifest.appwrite.presences.js +406 -42
- package/lib/manifest.data.js +1 -1
- package/lib/manifest.device.js +25 -12
- package/lib/manifest.integrity.json +7 -7
- package/lib/manifest.native.js +25 -12
- package/lib/manifest.schema.json +17 -0
- package/lib/manifest.sw.js +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,284 @@
|
|
|
6
6
|
/* Requires Alpine JS (alpinejs.dev) to operate
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// mnfst:realtime.hub:start
|
|
10
|
+
/* Shared Appwrite Realtime hub — ONE Realtime instance (one socket) per tab
|
|
11
|
+
across the data, auth and presences plugins. The first carrier to load
|
|
12
|
+
defines it; the build syncs this block byte-identical into each carrier
|
|
13
|
+
from scripts/snippets/manifest.realtime.hub.js.
|
|
14
|
+
|
|
15
|
+
Subscriptions are keyed by a caller-chosen name and ref-counted by
|
|
16
|
+
(channel + canonical server-filter key); server-side realtime queries ride
|
|
17
|
+
per subscription where the instance supports them (verified SDK/server
|
|
18
|
+
contract: see the manifest.data.realtime.js header). Messages are
|
|
19
|
+
classified ONCE — Appwrite sends ~20 event-name variants per change —
|
|
20
|
+
so each handler gets one (action, payload, response) call per message. */
|
|
21
|
+
if (!window.ManifestRealtimeHub) {
|
|
22
|
+
window.ManifestRealtimeHub = (function () {
|
|
23
|
+
// Identity events drop the instance memo (the socket re-authenticates
|
|
24
|
+
// as the new session) and re-bind live subscriptions
|
|
25
|
+
const AUTH_EVENTS = ['manifest:auth:login', 'manifest:auth:logout', 'manifest:auth:anonymous', 'manifest:auth:session-cleared', 'manifest:auth:initialized'];
|
|
26
|
+
|
|
27
|
+
// One Realtime (one socket) per tab, keyed to the client it wraps
|
|
28
|
+
let realtimeCache = null;
|
|
29
|
+
let realtimeClient = null;
|
|
30
|
+
function acquire(client) {
|
|
31
|
+
if (!client || !window.Appwrite?.Realtime) return null;
|
|
32
|
+
if (!realtimeCache || realtimeClient !== client) {
|
|
33
|
+
realtimeCache = new window.Appwrite.Realtime(client);
|
|
34
|
+
realtimeClient = client;
|
|
35
|
+
}
|
|
36
|
+
return realtimeCache;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Map<subKey, { channel, queries, refs, unsubscribe, handlers: Map<name, handler> }>
|
|
40
|
+
const entries = new Map();
|
|
41
|
+
// Map<name, { subKey, channel, token, handler, getFilters, getRealtime }>
|
|
42
|
+
const sources = new Map();
|
|
43
|
+
// Map<name, fn(action, payload, response)> — pre-filters, run once per message per name
|
|
44
|
+
const filters = new Map();
|
|
45
|
+
// Map<name, n> — explicit teardowns; aborts a subscribe in flight
|
|
46
|
+
const tombstones = new Map();
|
|
47
|
+
// Instance the current subscriptions live on
|
|
48
|
+
let activeRealtime = null;
|
|
49
|
+
|
|
50
|
+
// Reduce a message's event-name variants to the single most specific
|
|
51
|
+
// action: delete > update (upsert counts: presences) > create
|
|
52
|
+
function classify(events) {
|
|
53
|
+
const list = Array.isArray(events) ? events : (events ? [events] : []);
|
|
54
|
+
let action = null;
|
|
55
|
+
for (const event of list) {
|
|
56
|
+
if (typeof event !== 'string') continue;
|
|
57
|
+
// Verbs match as path segments only — event names embed
|
|
58
|
+
// user-chosen ids (a table named deleted_posts is not a delete)
|
|
59
|
+
const parts = event.split('.');
|
|
60
|
+
if (parts.includes('delete')) return 'delete';
|
|
61
|
+
if (parts.includes('update') || parts.includes('upsert')) action = 'update';
|
|
62
|
+
else if (!action && parts.includes('create')) action = 'create';
|
|
63
|
+
}
|
|
64
|
+
return action;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Query-capable Realtime service: detect per instance, never assume
|
|
68
|
+
function supportsQueries(realtime) {
|
|
69
|
+
return !!(realtime && (realtime.pendingSubscribes || realtime.slotToSubscriptionId));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// (channel, resolved filter set) → stable subscription key; resolved.key
|
|
73
|
+
// is already canonical so equivalent filter sets share one subscription
|
|
74
|
+
function subKeyFor(channel, resolved) {
|
|
75
|
+
if (!resolved || !resolved.key) return channel;
|
|
76
|
+
return `${channel}\u001f${resolved.key}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fan one SDK message out, classified once: each registered handler gets
|
|
80
|
+
// ONE (action, payload, response) call per message, after that name's
|
|
81
|
+
// pre-filter (setFilter) — before any merge/dedup work
|
|
82
|
+
function dispatch(subKey, response) {
|
|
83
|
+
const entry = entries.get(subKey);
|
|
84
|
+
if (!entry || !response || !response.events) return;
|
|
85
|
+
const action = classify(response.events);
|
|
86
|
+
if (!action) return;
|
|
87
|
+
let payload = response.payload;
|
|
88
|
+
if (!payload || (!payload.$id && !payload.row)) payload = response;
|
|
89
|
+
for (const [name, handler] of entry.handlers) {
|
|
90
|
+
const filter = filters.get(name);
|
|
91
|
+
if (filter && filter(action, payload, response) === false) continue;
|
|
92
|
+
handler(action, payload, response);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// subscribe() returns an unsubscribe function (legacy client) or a
|
|
97
|
+
// Promise<RealtimeSubscription> (Realtime service): normalize teardown
|
|
98
|
+
function dropSub(sub) {
|
|
99
|
+
if (typeof sub === 'function') sub();
|
|
100
|
+
else if (sub && typeof sub.unsubscribe === 'function') sub.unsubscribe(); // per-subscription; never risks the shared socket
|
|
101
|
+
else if (sub && typeof sub.close === 'function') sub.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeUnsubscriber(result) {
|
|
105
|
+
if (!result || typeof result.then !== 'function') {
|
|
106
|
+
return () => { try { dropSub(result); } catch (e) { /* socket already gone */ } };
|
|
107
|
+
}
|
|
108
|
+
const settled = result.catch(() => null);
|
|
109
|
+
return () => { settled.then(dropSub).catch(() => { /* socket already gone */ }); };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sdkSubscribe(realtime, subKey, channel, queries) {
|
|
113
|
+
const cb = response => dispatch(subKey, response);
|
|
114
|
+
const result = (queries && queries.length)
|
|
115
|
+
? realtime.subscribe(channel, cb, queries)
|
|
116
|
+
: realtime.subscribe(channel, cb);
|
|
117
|
+
return makeUnsubscriber(result);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Get or create the shared entry for (channel, filters); registered in the
|
|
121
|
+
// map before the SDK call so an event arriving mid-subscribe already dispatches
|
|
122
|
+
function ensureEntry(realtime, subKey, channel, queries) {
|
|
123
|
+
let entry = entries.get(subKey);
|
|
124
|
+
if (entry) return entry;
|
|
125
|
+
entry = { channel, queries: queries || [], refs: 0, unsubscribe: null, handlers: new Map() };
|
|
126
|
+
entries.set(subKey, entry);
|
|
127
|
+
try {
|
|
128
|
+
entry.unsubscribe = sdkSubscribe(realtime, subKey, channel, queries);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
entries.delete(subKey);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
return entry;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function removeFromEntry(name, subKey) {
|
|
137
|
+
const entry = entries.get(subKey);
|
|
138
|
+
if (!entry) return;
|
|
139
|
+
entry.handlers.delete(name);
|
|
140
|
+
entry.refs = entry.handlers.size;
|
|
141
|
+
if (entry.refs === 0) {
|
|
142
|
+
entries.delete(subKey);
|
|
143
|
+
if (typeof entry.unsubscribe === 'function') {
|
|
144
|
+
try { entry.unsubscribe(); } catch (e) { /* socket already gone */ }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Drop a name's registration; tear the subscription down when the last one leaves
|
|
150
|
+
function detach(name, token) {
|
|
151
|
+
const cur = sources.get(name);
|
|
152
|
+
if (!cur || (token && cur.token !== token)) return;
|
|
153
|
+
sources.delete(name);
|
|
154
|
+
removeFromEntry(name, cur.subKey);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Instance changed (rebuilt on identity change): re-resolve each name's
|
|
158
|
+
// filters ($auth. values are stale) and regroup subscriptions on the new
|
|
159
|
+
// instance. Chained so overlapping rebuilds run in order; superseded stops.
|
|
160
|
+
let syncChain = Promise.resolve();
|
|
161
|
+
function syncInstance(realtime) {
|
|
162
|
+
if (activeRealtime === realtime) return syncChain;
|
|
163
|
+
activeRealtime = realtime;
|
|
164
|
+
syncChain = syncChain
|
|
165
|
+
.then(() => rebindAll(realtime))
|
|
166
|
+
.catch(error => console.error('[Manifest Realtime] Rebind failed:', error));
|
|
167
|
+
return syncChain;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function rebindAll(realtime) {
|
|
171
|
+
for (const [, entry] of entries) {
|
|
172
|
+
if (typeof entry.unsubscribe === 'function') {
|
|
173
|
+
try { entry.unsubscribe(); } catch (e) { /* old instance */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
entries.clear();
|
|
177
|
+
const canQuery = supportsQueries(realtime);
|
|
178
|
+
for (const [name, src] of sources) {
|
|
179
|
+
if (activeRealtime !== realtime) return; // superseded by a newer instance
|
|
180
|
+
try {
|
|
181
|
+
// Inside the try: one source's failing getFilters must not
|
|
182
|
+
// strand the sources after it unsubscribed
|
|
183
|
+
const resolved = (canQuery && src.getFilters) ? await src.getFilters() : null;
|
|
184
|
+
if (activeRealtime !== realtime) return;
|
|
185
|
+
const subKey = subKeyFor(src.channel, resolved);
|
|
186
|
+
const entry = ensureEntry(realtime, subKey, src.channel, resolved?.queries);
|
|
187
|
+
entry.handlers.set(name, src.handler);
|
|
188
|
+
entry.refs = entry.handlers.size;
|
|
189
|
+
src.subKey = subKey;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error('[Manifest Realtime] Failed to re-subscribe channel:', src.channel, error);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// One SDK subscribe per distinct (channel, filter set); names sharing both
|
|
197
|
+
// share it. getRealtime: async () => instance (re-run on identity rebuilds).
|
|
198
|
+
// isCurrent: false once the requesting load is superseded — skip, never
|
|
199
|
+
// touch a newer entry. getFilters: async () => ({ queries, key }) | null,
|
|
200
|
+
// re-run per (re)subscribe; null → channel-only subscribe. A replaced
|
|
201
|
+
// registration detaches only after the new subscription is live (no event
|
|
202
|
+
// gap); overlap double-delivery is the consumer's dedupe to absorb.
|
|
203
|
+
async function subscribe({ name, channel, onEvent, isCurrent, getFilters, getRealtime }) {
|
|
204
|
+
const epoch = tombstones.get(name) || 0;
|
|
205
|
+
const realtime = getRealtime ? await getRealtime() : null;
|
|
206
|
+
if (isCurrent && !isCurrent()) return null;
|
|
207
|
+
if (!realtime) {
|
|
208
|
+
detach(name);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
await syncInstance(realtime);
|
|
212
|
+
if (isCurrent && !isCurrent()) return null;
|
|
213
|
+
|
|
214
|
+
const resolved = (supportsQueries(realtime) && getFilters) ? await getFilters() : null;
|
|
215
|
+
if (isCurrent && !isCurrent()) return null;
|
|
216
|
+
// Torn down while resolving: land nothing
|
|
217
|
+
if ((tombstones.get(name) || 0) !== epoch) return null;
|
|
218
|
+
// Instance replaced while resolving (identity rebuild): re-run against the live one
|
|
219
|
+
if (activeRealtime !== realtime) return subscribe({ name, channel, onEvent, isCurrent, getFilters, getRealtime });
|
|
220
|
+
const subKey = subKeyFor(channel, resolved);
|
|
221
|
+
|
|
222
|
+
const prev = sources.get(name);
|
|
223
|
+
let entry;
|
|
224
|
+
try {
|
|
225
|
+
entry = ensureEntry(realtime, subKey, channel, resolved?.queries);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.error('[Manifest Realtime] Failed to subscribe:', channel, error);
|
|
228
|
+
detach(name);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
const token = Symbol(name);
|
|
232
|
+
entry.handlers.set(name, onEvent);
|
|
233
|
+
entry.refs = entry.handlers.size;
|
|
234
|
+
sources.set(name, { subKey, channel, token, handler: onEvent, getFilters, getRealtime });
|
|
235
|
+
if (prev && prev.subKey !== subKey) removeFromEntry(name, prev.subKey);
|
|
236
|
+
|
|
237
|
+
return () => detach(name, token);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Explicit teardown by name; also aborts a subscribe in flight and
|
|
241
|
+
// drops the name's pre-filter (a replacement re-registers its own)
|
|
242
|
+
function unsubscribe(name) {
|
|
243
|
+
tombstones.set(name, (tombstones.get(name) || 0) + 1);
|
|
244
|
+
filters.delete(name);
|
|
245
|
+
detach(name);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Programmatic pre-filter: fn(action, payload, response) runs once per
|
|
249
|
+
// message per name BEFORE the handler; false drops it. null clears.
|
|
250
|
+
function setFilter(name, fn) {
|
|
251
|
+
if (fn == null) filters.delete(name);
|
|
252
|
+
else if (typeof fn === 'function') filters.set(name, fn);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Identity events: drop the memo, then re-bind live subscriptions on a
|
|
256
|
+
// fresh instance from any registered source's getter
|
|
257
|
+
if (typeof window !== 'undefined') {
|
|
258
|
+
AUTH_EVENTS.forEach(type => window.addEventListener(type, async () => {
|
|
259
|
+
realtimeCache = null;
|
|
260
|
+
realtimeClient = null;
|
|
261
|
+
if (!sources.size) return;
|
|
262
|
+
try {
|
|
263
|
+
const src = [...sources.values()].find(s => s.getRealtime);
|
|
264
|
+
const realtime = src ? await src.getRealtime() : null;
|
|
265
|
+
if (realtime) syncInstance(realtime);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.error('[Manifest Realtime] Identity rebuild failed:', error);
|
|
268
|
+
}
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
AUTH_EVENTS,
|
|
274
|
+
acquire,
|
|
275
|
+
subscribe,
|
|
276
|
+
unsubscribe,
|
|
277
|
+
setFilter,
|
|
278
|
+
classify,
|
|
279
|
+
supportsQueries,
|
|
280
|
+
_entries: entries, // Expose for debugging (keyed by channel + filter set)
|
|
281
|
+
_sources: sources
|
|
282
|
+
};
|
|
283
|
+
})();
|
|
284
|
+
}
|
|
285
|
+
// mnfst:realtime.hub:end
|
|
286
|
+
|
|
9
287
|
(function () {
|
|
10
288
|
'use strict';
|
|
11
289
|
|
|
@@ -14,12 +292,14 @@
|
|
|
14
292
|
const ctx = {
|
|
15
293
|
client: null,
|
|
16
294
|
presences: null,
|
|
17
|
-
realtime: null,
|
|
18
295
|
sub: null,
|
|
19
296
|
heartbeat: null,
|
|
20
297
|
started: false,
|
|
21
298
|
status: 'online',
|
|
22
299
|
metadata: null,
|
|
300
|
+
scope: 'auto', // 'auto'/'team' = current team, 'global', or an explicit team id
|
|
301
|
+
teamsEnabled: false, // manifest.appwrite.auth.teams (read at start)
|
|
302
|
+
settleTimer: null, // bounded wait for teams to settle
|
|
23
303
|
auto: true, // focus/blur auto-flip
|
|
24
304
|
heartbeatMs: 25000, // TTL refresh cadence
|
|
25
305
|
onFocus: null,
|
|
@@ -37,19 +317,14 @@
|
|
|
37
317
|
});
|
|
38
318
|
}
|
|
39
319
|
|
|
40
|
-
|
|
320
|
+
// manifest.json appwrite.presences block ({ scope } today; true = defaults)
|
|
321
|
+
async function getPresencesConfig() {
|
|
41
322
|
try {
|
|
42
|
-
const manifest = await window.ManifestDataConfig?.ensureManifest?.();
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const endpoint = resolveEnv(aw.endpoint);
|
|
46
|
-
const projectId = resolveEnv(aw.projectId);
|
|
47
|
-
if (!endpoint || !projectId) return null;
|
|
48
|
-
let devKey = aw.devKey ? resolveEnv(aw.devKey) : null;
|
|
49
|
-
if (devKey && /\$\{/.test(devKey)) devKey = null;
|
|
50
|
-
return { endpoint, projectId, devKey };
|
|
323
|
+
const manifest = await (window.ManifestDataConfig?.ensureManifest?.() || window.ManifestAppwriteAuthConfig?.ensureManifest?.());
|
|
324
|
+
const block = manifest?.appwrite?.presences ?? manifest?.appwrite?.presence;
|
|
325
|
+
return (block && typeof block === 'object') ? block : {};
|
|
51
326
|
} catch (e) {
|
|
52
|
-
return
|
|
327
|
+
return {};
|
|
53
328
|
}
|
|
54
329
|
}
|
|
55
330
|
|
|
@@ -79,20 +354,62 @@
|
|
|
79
354
|
return `hsl(${Math.abs(hash) % 360}, 70%, 50%)`;
|
|
80
355
|
}
|
|
81
356
|
|
|
82
|
-
// Appwrite client
|
|
357
|
+
// Shared Appwrite client via the auth plugin (a hard dependency) + the
|
|
358
|
+
// realtime hub: presences never constructs its own Client or Realtime, so
|
|
359
|
+
// data + auth + presence ride one socket per tab
|
|
83
360
|
async function ensureServices() {
|
|
84
|
-
if (ctx.presences && ctx.
|
|
361
|
+
if (ctx.presences && ctx.client) return true;
|
|
85
362
|
const A = window.Appwrite;
|
|
86
|
-
if (!A || !A.Presences || !A.Realtime) return false;
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
ctx.
|
|
91
|
-
ctx.presences = new A.Presences(client);
|
|
92
|
-
ctx.realtime = new A.Realtime(client);
|
|
363
|
+
if (!A || !A.Presences || !A.Realtime || !window.ManifestRealtimeHub) return false;
|
|
364
|
+
const services = await window.ManifestAppwriteAuthConfig?.getAppwriteClient?.();
|
|
365
|
+
if (!services?.client) return false;
|
|
366
|
+
ctx.client = services.client;
|
|
367
|
+
ctx.presences = new A.Presences(services.client);
|
|
93
368
|
return true;
|
|
94
369
|
}
|
|
95
370
|
|
|
371
|
+
/* Scope */
|
|
372
|
+
|
|
373
|
+
// Teams load in the background after auth init; until they settle, a scoped
|
|
374
|
+
// write must not fall back to read(users()) — that is the cross-tenant
|
|
375
|
+
// broadcast window. Settles on the auth event, or after a bounded wait for
|
|
376
|
+
// sessions the background load never covers (e.g. guests without guestTeams).
|
|
377
|
+
let teamsSettled = false;
|
|
378
|
+
window.addEventListener('manifest:auth:teams-loaded', () => { settleTeams(); });
|
|
379
|
+
['manifest:auth:logout', 'manifest:auth:session-cleared', 'manifest:auth:login', 'manifest:auth:anonymous'].forEach(type =>
|
|
380
|
+
window.addEventListener(type, () => { teamsSettled = false; }));
|
|
381
|
+
function settleTeams() {
|
|
382
|
+
if (teamsSettled) return;
|
|
383
|
+
teamsSettled = true;
|
|
384
|
+
if (ctx.settleTimer) { clearTimeout(ctx.settleTimer); ctx.settleTimer = null; }
|
|
385
|
+
// Settled with a team: the team-switch effect rescopes. Settled with
|
|
386
|
+
// none: rewrite here, from self-only to the global users() grant.
|
|
387
|
+
if (ctx.started && !scopeTeamId()) { hydrate(); rescope(); }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Roster scope as a team id: explicit id > current team ('auto'/'team') > none ('global')
|
|
391
|
+
function scopeTeamId() {
|
|
392
|
+
const s = ctx.scope;
|
|
393
|
+
if (s === 'global' || s === false || s == null) return null;
|
|
394
|
+
if (typeof s === 'string' && s !== 'auto' && s !== 'team') return s;
|
|
395
|
+
return currentTeamId();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Client-side scope filter (always on, defense in depth): a team scope is
|
|
399
|
+
// not expressible as a server-side realtime query — the payload has no team
|
|
400
|
+
// field and $permissions is an array the server's strict equal can't match —
|
|
401
|
+
// so out-of-scope records drop here, before the store. Matches the plugin's
|
|
402
|
+
// own write convention: read(Role.team(teamId)).
|
|
403
|
+
function inScope(raw, userId) {
|
|
404
|
+
const team = scopeTeamId();
|
|
405
|
+
if (!team) return true;
|
|
406
|
+
const meId = authUser()?.$id;
|
|
407
|
+
if (meId && userId === meId) return true;
|
|
408
|
+
const perms = Array.isArray(raw?.$permissions) ? raw.$permissions : [];
|
|
409
|
+
// read("any") is explicitly world-readable: in every scope
|
|
410
|
+
return perms.some(p => p === 'read("any")' || p === `read("team:${team}")` || p.startsWith(`read("team:${team}/`));
|
|
411
|
+
}
|
|
412
|
+
|
|
96
413
|
/* Records */
|
|
97
414
|
|
|
98
415
|
function normalize(rec) {
|
|
@@ -129,7 +446,7 @@
|
|
|
129
446
|
const res = await ctx.presences.list();
|
|
130
447
|
const rows = res?.presences || res?.rows || res?.documents || [];
|
|
131
448
|
const map = {};
|
|
132
|
-
rows.forEach(r => { const n = normalize(r); if (n) map[n.userId] = n; });
|
|
449
|
+
rows.forEach(r => { const n = normalize(r); if (n && inScope(r, n.userId)) map[n.userId] = n; });
|
|
133
450
|
if (s) s.records = map;
|
|
134
451
|
} catch (e) {
|
|
135
452
|
if (s) s.error = e?.message || String(e);
|
|
@@ -138,30 +455,28 @@
|
|
|
138
455
|
|
|
139
456
|
function subscribe() {
|
|
140
457
|
const A = window.Appwrite;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
458
|
+
const hub = window.ManifestRealtimeHub;
|
|
459
|
+
const channel = (A.Channel && typeof A.Channel.presences === 'function') ? A.Channel.presences() : 'presences';
|
|
460
|
+
hub.subscribe({
|
|
461
|
+
name: 'presence:roster',
|
|
462
|
+
channel,
|
|
463
|
+
getRealtime: async () => hub.acquire(ctx.client),
|
|
464
|
+
// One classified call per message (presence upserts classify as update)
|
|
465
|
+
onEvent: (action, payload) => {
|
|
466
|
+
const rec = normalize(payload);
|
|
146
467
|
if (!rec) return;
|
|
147
468
|
// Ignore echoes of our own presence (tracked locally; delayed echoes would clobber newer status)
|
|
148
469
|
const meId = authUser()?.$id;
|
|
149
470
|
if (meId && rec.userId === meId) return;
|
|
150
|
-
|
|
471
|
+
// A known record that LEAVES scope is removed, not just ignored
|
|
472
|
+
if (!inScope(payload, rec.userId)) { removeRecord(rec.userId); return; }
|
|
473
|
+
if (action === 'delete') removeRecord(rec.userId);
|
|
151
474
|
else putRecord(rec);
|
|
152
|
-
});
|
|
153
|
-
// Normalise the unsubscribe handle
|
|
154
|
-
if (handle && typeof handle.then === 'function') {
|
|
155
|
-
handle.then(r => { ctx.sub = typeof r === 'function' ? r : (r && r.close ? () => r.close() : null); });
|
|
156
|
-
ctx.sub = () => { handle.then(r => { if (typeof r === 'function') r(); else if (r && r.close) r.close(); }); };
|
|
157
|
-
} else if (typeof handle === 'function') {
|
|
158
|
-
ctx.sub = handle;
|
|
159
|
-
} else if (handle && typeof handle.close === 'function') {
|
|
160
|
-
ctx.sub = () => handle.close();
|
|
161
475
|
}
|
|
162
|
-
}
|
|
476
|
+
}).catch(e => {
|
|
163
477
|
const s = store(); if (s) s.error = e?.message || String(e);
|
|
164
|
-
}
|
|
478
|
+
});
|
|
479
|
+
ctx.sub = () => hub.unsubscribe('presence:roster');
|
|
165
480
|
}
|
|
166
481
|
|
|
167
482
|
/* Operations */
|
|
@@ -175,10 +490,16 @@
|
|
|
175
490
|
ctx.status = status != null ? status : ctx.status;
|
|
176
491
|
ctx.metadata = metadata !== undefined ? metadata : ctx.metadata;
|
|
177
492
|
|
|
178
|
-
// Read-scope + owner write (owner update/delete required, else updates 401)
|
|
179
|
-
|
|
493
|
+
// Read-scope + owner write (owner update/delete required, else updates 401).
|
|
494
|
+
// Reads follow the roster scope: team role when scoped, all users when
|
|
495
|
+
// global. Scoped with teams NOT yet settled: self-only — never the
|
|
496
|
+
// users() broadcast (the leak window); settle/team-switch rewrites it.
|
|
497
|
+
const teamId = scopeTeamId();
|
|
498
|
+
const pending = !teamId && ctx.teamsEnabled && !teamsSettled
|
|
499
|
+
&& ctx.scope !== 'global' && ctx.scope !== false;
|
|
500
|
+
const readRole = teamId ? A.Role.team(teamId) : (pending ? A.Role.user(user.$id) : A.Role.users());
|
|
180
501
|
const permissions = [
|
|
181
|
-
|
|
502
|
+
A.Permission.read(readRole),
|
|
182
503
|
A.Permission.update(A.Role.user(user.$id)),
|
|
183
504
|
A.Permission.delete(A.Role.user(user.$id))
|
|
184
505
|
];
|
|
@@ -202,6 +523,15 @@
|
|
|
202
523
|
}
|
|
203
524
|
}
|
|
204
525
|
|
|
526
|
+
// Scope change: old-scope rosters never receive a perms rewrite (delivery
|
|
527
|
+
// follows the OLD read grant), so delete first — the delete event still
|
|
528
|
+
// carries it — then re-upsert under the new scope
|
|
529
|
+
async function rescope() {
|
|
530
|
+
const user = authUser();
|
|
531
|
+
if (user && ctx.presences) { try { await ctx.presences.delete({ presenceId: user.$id }); } catch (e) { } }
|
|
532
|
+
await set(ctx.status, ctx.metadata);
|
|
533
|
+
}
|
|
534
|
+
|
|
205
535
|
async function clear() {
|
|
206
536
|
const user = authUser();
|
|
207
537
|
if (!user || !ctx.presences) return;
|
|
@@ -226,10 +556,28 @@
|
|
|
226
556
|
if (typeof opts.heartbeatMs === 'number') ctx.heartbeatMs = opts.heartbeatMs;
|
|
227
557
|
if (typeof opts.status === 'string') ctx.status = opts.status;
|
|
228
558
|
|
|
559
|
+
// Scope: start() option > manifest appwrite.presences.scope > 'auto'
|
|
560
|
+
const cfg = await getPresencesConfig();
|
|
561
|
+
if (cfg.scope != null) ctx.scope = resolveEnv(cfg.scope);
|
|
562
|
+
if (opts.scope != null) ctx.scope = opts.scope;
|
|
563
|
+
|
|
229
564
|
const s = store();
|
|
230
565
|
if (!authUser()) { if (s) s.error = 'sign in required'; return false; }
|
|
231
566
|
if (!(await ensureServices())) { if (s) s.error = 'Appwrite Presences unavailable'; return false; }
|
|
232
567
|
if (ctx.started) return true;
|
|
568
|
+
|
|
569
|
+
// Teams settle via the auth plugin's background load; disabled teams or
|
|
570
|
+
// an already-active team settle now, and a bounded wait covers sessions
|
|
571
|
+
// the load never fires for (e.g. guests without guestTeams)
|
|
572
|
+
try {
|
|
573
|
+
const authCfg = await window.ManifestAppwriteAuthConfig?.getAppwriteConfig?.();
|
|
574
|
+
ctx.teamsEnabled = !!authCfg?.teams;
|
|
575
|
+
} catch (e) { ctx.teamsEnabled = false; }
|
|
576
|
+
if (!ctx.teamsEnabled || currentTeamId()) settleTeams();
|
|
577
|
+
else if (!teamsSettled && !ctx.settleTimer) {
|
|
578
|
+
ctx.settleTimer = setTimeout(() => { ctx.settleTimer = null; settleTeams(); }, 8000);
|
|
579
|
+
}
|
|
580
|
+
|
|
233
581
|
ctx.started = true;
|
|
234
582
|
|
|
235
583
|
await hydrate();
|
|
@@ -252,6 +600,7 @@
|
|
|
252
600
|
}
|
|
253
601
|
|
|
254
602
|
function stop() {
|
|
603
|
+
if (ctx.settleTimer) { clearTimeout(ctx.settleTimer); ctx.settleTimer = null; }
|
|
255
604
|
if (ctx.heartbeat) { clearInterval(ctx.heartbeat); ctx.heartbeat = null; }
|
|
256
605
|
if (ctx.onBlur) { window.removeEventListener('blur', ctx.onBlur); ctx.onBlur = null; }
|
|
257
606
|
if (ctx.onFocus) { window.removeEventListener('focus', ctx.onFocus); ctx.onFocus = null; }
|
|
@@ -273,6 +622,21 @@
|
|
|
273
622
|
});
|
|
274
623
|
} catch (e) { }
|
|
275
624
|
|
|
625
|
+
// Re-scope on team switch: refilter the roster, delete + rewrite own
|
|
626
|
+
// presence (old teammates only see the delete; the rewrite isn't
|
|
627
|
+
// delivered to them once the read grant moves)
|
|
628
|
+
let lastTeam;
|
|
629
|
+
try {
|
|
630
|
+
Alpine.effect(() => {
|
|
631
|
+
const team = currentTeamId();
|
|
632
|
+
if (team === lastTeam) return;
|
|
633
|
+
lastTeam = team;
|
|
634
|
+
if (!ctx.started) return;
|
|
635
|
+
hydrate();
|
|
636
|
+
rescope();
|
|
637
|
+
});
|
|
638
|
+
} catch (e) { }
|
|
639
|
+
|
|
276
640
|
const api = {
|
|
277
641
|
start, stop, set, clear, of, all,
|
|
278
642
|
get records() { return store()?.records || {}; },
|
package/lib/manifest.data.js
CHANGED
package/lib/manifest.device.js
CHANGED
|
@@ -15,6 +15,11 @@ function manifestNativePlugin(name) {
|
|
|
15
15
|
const cap = window.Capacitor;
|
|
16
16
|
return (cap && cap.Plugins && cap.Plugins[name]) || null;
|
|
17
17
|
}
|
|
18
|
+
// Whether a plugin is in the native header list (no list: assume installed)
|
|
19
|
+
function manifestNativeInstalled(name) {
|
|
20
|
+
const headers = window.Capacitor && window.Capacitor.PluginHeaders;
|
|
21
|
+
return !Array.isArray(headers) || headers.some(h => h && h.name === name);
|
|
22
|
+
}
|
|
18
23
|
// Whether a plugin exposes a native method. Bundled apps get a registerPlugin
|
|
19
24
|
// Proxy where every property is a function, so prefer the native header; a
|
|
20
25
|
// header list without this plugin means it isn't installed natively.
|
|
@@ -158,6 +163,7 @@ function initManifestNativeNetwork() {
|
|
|
158
163
|
// namespaced localStorage fallback on the web. Intended for session tokens etc.
|
|
159
164
|
// Native: a plugin registered as SecureStorage (@aparajita/capacitor-secure-storage,
|
|
160
165
|
// or any getItem/setItem or get/set shape); $secure.use(adapter) overrides.
|
|
166
|
+
// Native without a plugin rejects; never localStorage.
|
|
161
167
|
// The web fallback is NOT encrypted — it degrades honestly, not securely.
|
|
162
168
|
|
|
163
169
|
const MANIFEST_SECURE_NS = 'mnfst:';
|
|
@@ -228,25 +234,32 @@ function manifestSecureNativeBackend(plugin, name) {
|
|
|
228
234
|
};
|
|
229
235
|
}
|
|
230
236
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const headers = window.Capacitor && window.Capacitor.PluginHeaders;
|
|
234
|
-
return !Array.isArray(headers) || headers.some(h => h && h.name === name);
|
|
235
|
-
}
|
|
237
|
+
const MANIFEST_SECURE_NONE = 'No secure-storage plugin is installed; install @aparajita/capacitor-secure-storage and run npx cap sync, or provide one with $secure.use(adapter)';
|
|
238
|
+
const MANIFEST_SECURE_UNSYNCED = 'SecureStorage plugin is not installed natively; run npx cap sync';
|
|
236
239
|
|
|
237
|
-
// Rejects every call; never falls back to unencrypted web storage
|
|
238
|
-
|
|
239
|
-
|
|
240
|
+
// Rejects every call, logs once; never falls back to unencrypted web storage
|
|
241
|
+
let _manifestSecureMissingLogged = false;
|
|
242
|
+
function manifestSecureMissingBackend(msg) {
|
|
243
|
+
const fail = (op) => async () => {
|
|
244
|
+
const e = new Error(msg);
|
|
245
|
+
if (!_manifestSecureMissingLogged) {
|
|
246
|
+
_manifestSecureMissingLogged = true;
|
|
247
|
+
try { console.error('[Manifest] $secure.' + op + ' failed', e); } catch (_) {}
|
|
248
|
+
}
|
|
249
|
+
throw e;
|
|
250
|
+
};
|
|
240
251
|
return { get: fail('get'), set: fail('set'), remove: fail('remove'), keys: fail('keys'), clear: fail('clear') };
|
|
241
252
|
}
|
|
242
253
|
|
|
254
|
+
// Missing backends aren't cached, so a later-registered plugin is picked up
|
|
243
255
|
let _manifestSecureBackend = null;
|
|
244
256
|
function manifestSecureBackend() {
|
|
245
257
|
if (_manifestSecureBackend) return _manifestSecureBackend;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return
|
|
258
|
+
if (!manifestNativeIsNative()) return (_manifestSecureBackend = manifestSecureWebBackend());
|
|
259
|
+
const plugin = manifestNativePlugin('SecureStorage');
|
|
260
|
+
if (!plugin) return manifestSecureMissingBackend(MANIFEST_SECURE_NONE);
|
|
261
|
+
if (!manifestNativeInstalled('SecureStorage')) return manifestSecureMissingBackend(MANIFEST_SECURE_UNSYNCED);
|
|
262
|
+
return (_manifestSecureBackend = manifestSecureNativeBackend(plugin, 'SecureStorage'));
|
|
250
263
|
}
|
|
251
264
|
|
|
252
265
|
function initManifestSecure() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"manifest.appwrite.auth.js": "sha384-
|
|
3
|
-
"manifest.appwrite.data.js": "sha384-
|
|
4
|
-
"manifest.appwrite.presences.js": "sha384-
|
|
2
|
+
"manifest.appwrite.auth.js": "sha384-9WNhIeSEzxxJm+yP7cV41kV2xyzyXgFCNpThqiREcNbb6tnIW6O7VRZPL2jwy9Nk",
|
|
3
|
+
"manifest.appwrite.data.js": "sha384-ot7BHrPKWwiWjT10bvg7/hVKHADiMVTG0zoC0zlG389de40O/L7YFzd3ZzDnKXN5",
|
|
4
|
+
"manifest.appwrite.presences.js": "sha384-qsRtzFCtW7pS+I95EQuZQoPAeZvBsNDSMFwDFBJriyCWcNv2Jo2KFvOweClJhowJ",
|
|
5
5
|
"manifest.bindings.js": "sha384-lPtMf8Ykaic9gNVZKq8Scia1881ZeMAyxer3WCIkUNwwrgzGBWto1OXADUwjvORF",
|
|
6
6
|
"manifest.charts.js": "sha384-G+huD7UvjF9zDovdVVlh4I5X1Mq4oNnJfoFg4NnGdP3R/4Skq9hy5cGuHNYLxDYm",
|
|
7
7
|
"manifest.chat.js": "sha384-88DfMfJLoi2oxclVD2iIMDkIGAExMXq5k47mekUredlicLaP3oZY5bwtwqs+TqDF",
|
|
@@ -11,24 +11,24 @@
|
|
|
11
11
|
"manifest.combobox.js": "sha384-MILTkrgsTzsYUjGo4ZuADqV85Xs4GmfB4xexfe9vbJMIoQTZ42W4zs+DTaVSPEsx",
|
|
12
12
|
"manifest.components.js": "sha384-BUFA8h+G5Plr82sV7bKzO42pPoiRZwRQcKj1lt2ROFiKjjlkayU/vmkAFqEsdBA+",
|
|
13
13
|
"manifest.computed.js": "sha384-GYML/e81gQfN+iDbqhPUgny8bINCyL+SrbTofoGk4hlCnLLjv51mCv42JMqq+ku5",
|
|
14
|
-
"manifest.data.js": "sha384-
|
|
14
|
+
"manifest.data.js": "sha384-1BIeRvJ8LOJLtCX4uPCepikXSOaSisgTTwfdRYAbChowEGJX1WGC7cfSSBsld0sM",
|
|
15
15
|
"manifest.datepicker.js": "sha384-2XwFFnS4Bg5FDvG/v8x6KUvrjcBQEOYyTxynX2K/D/Qb3PI6+Y04lEwCnVDJLzwL",
|
|
16
16
|
"manifest.defer.js": "sha384-GK9qJkYtchtyzSQ+3zmDQUJE3+au4qthT2M8JnFjXxgfwCH5IVKc2r3tackgMryU",
|
|
17
|
-
"manifest.device.js": "sha384-
|
|
17
|
+
"manifest.device.js": "sha384-AWismZl/UjR/NqH7z4Sfg0DZLPpySDuS0tL34ZsbHdNYDD14JzBW9xp/ScmQKilQ",
|
|
18
18
|
"manifest.dropdowns.js": "sha384-acufUps7R9WjqB4XXW5JLACqniuHJGM4CNTb2rF4B2Q/jv7ZRmsAwfr2McanOz3s",
|
|
19
19
|
"manifest.edit.js": "sha384-MOgQNdNLeHijhR0u2W6ntoOulKFglsATCFDqkinesOU15tHoILk/JNzHKkGi5dUG",
|
|
20
20
|
"manifest.export.js": "sha384-Rx6XvqBZc4apzP/FwcDr6yIln4/zNa08Q4ZqBZcEqUz1aNZQfl4h0+Ae7mqCQyuG",
|
|
21
21
|
"manifest.icons.js": "sha384-kKOINpX70YzNO3FvkV6umrmO3f0KIu0DNbwB+ej55a7y/lhU7v/myQtXL0bwCTbt",
|
|
22
22
|
"manifest.localization.js": "sha384-EImsIw8Z3l13V5eH9ZJF/yfsOfIvR1oPRNSArANkPji7V8O3IRf6zMwsNiAGj3Hk",
|
|
23
23
|
"manifest.markdown.js": "sha384-heyDR40LXHaAmAuA+fa381uFwShk1oAqpFFY6E2Qq4T3VN0TQfRqyc++MF40K5Hj",
|
|
24
|
-
"manifest.native.js": "sha384-
|
|
24
|
+
"manifest.native.js": "sha384-AWismZl/UjR/NqH7z4Sfg0DZLPpySDuS0tL34ZsbHdNYDD14JzBW9xp/ScmQKilQ",
|
|
25
25
|
"manifest.payments.js": "sha384-8pofk2zhxtapPMpwnFyTelPMrYhShNhDCE722uQh/EYFvZFuPLmSdgunqsarVQjv",
|
|
26
26
|
"manifest.rich.text.js": "sha384-ZBV9jXmu2nmprEHicddRE/m7nR4RPZtVc969Jte0+EJYBcVph7itkjcgN/61PyeC",
|
|
27
27
|
"manifest.router.js": "sha384-9LMIkwrgnxmmFDv70TND2MRJF1z6Nl4yqG7fH5DbJ8oUIhdu/u4yUPSime1/EYhP",
|
|
28
28
|
"manifest.slides.js": "sha384-3uRTkyK9XPLmnxI2+igZlpi4EyPlU/7IHj5j3BZJJ2KN455vXyk99fiXV3feO/XY",
|
|
29
29
|
"manifest.status.js": "sha384-MBO345ab7H6Uq1X2dZLt4Ge1Gcra+Z2qYApvlAeIV8CJhDDJrz2FPXpp+Kcp8zcM",
|
|
30
30
|
"manifest.svg.js": "sha384-qr/8H9+2cIhSXNbRWRtIdrDlTOxKlNJqwEefHvCYoiBNW/kKhPTFa109Orv/EBnb",
|
|
31
|
-
"manifest.sw.js": "sha384-
|
|
31
|
+
"manifest.sw.js": "sha384-BQY7u/VfJ7DZ23QutEXo4OU+EO646rWLdHPTLRB3JzwIY5InbZEtxAPYKMlQGu35",
|
|
32
32
|
"manifest.tabs.js": "sha384-otU09H7jnawVKQdLAYpMCd3jx00tgyPVr42D2hLIBzN5dG4se505YesavdnMqEi1",
|
|
33
33
|
"manifest.tailwind.js": "sha384-r/Jvqb+Piz21jUts10BbVAJoKkShOdJRVFpywyi4HPIi/OfOJq5CtPyWAPbdkMgt",
|
|
34
34
|
"manifest.toasts.js": "sha384-3zkWxzw693pxg3QDFsoYCA3CqYaLKmBaMArULXODD66Utolpewl0OAeTB92aWUVv",
|