Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
121 lines
4.1 KiB
JavaScript
121 lines
4.1 KiB
JavaScript
// Local alarms survive host restarts. They cannot wake a powered-off PC.
|
|
export class Alarms {
|
|
constructor(identity, launch, storage, schedule) {
|
|
this.storage = storage;
|
|
this.schedule = schedule;
|
|
this.identity = identity;
|
|
this.launch = launch;
|
|
this.listeners = new Map();
|
|
this.queue = Promise.resolve();
|
|
this.lastLaunch = new Map();
|
|
}
|
|
async init() {
|
|
this.state = (await this.storage.load()) || { nextId: 1, records: [], pending: [] };
|
|
this.timer = this.schedule(() => this.serial(() => this.tick()).catch(console.error), 1000);
|
|
}
|
|
serial(fn) {
|
|
const p = this.queue.then(fn);
|
|
this.queue = p.catch(() => {});
|
|
return p;
|
|
}
|
|
save() {
|
|
return this.storage.save(this.state);
|
|
}
|
|
cleanup(actor) {
|
|
this.listeners.delete(actor);
|
|
}
|
|
due(record) {
|
|
return record.respectTimezone === 'ignoreTimezone' ? +new Date(...record.local) : record.date;
|
|
}
|
|
request(actor, app, params) {
|
|
if (!app.manifest.permissions?.alarms) throw new Error('PERMISSION_DENIED');
|
|
return this.serial(async () => {
|
|
const { operation, date, respectTimezone, data, id, local } = params;
|
|
if (operation === 'getAll')
|
|
return this.state.records
|
|
.filter((r) => r.owner === app.id)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
date: this.due(r),
|
|
respectTimezone: r.respectTimezone,
|
|
data: r.data,
|
|
}));
|
|
if (operation === 'add') {
|
|
if (
|
|
!Number.isFinite(date) ||
|
|
!['honorTimezone', 'ignoreTimezone'].includes(respectTimezone) ||
|
|
!Array.isArray(local) ||
|
|
local.length !== 7 ||
|
|
!local.every(Number.isFinite)
|
|
)
|
|
throw new Error('InvalidStateError');
|
|
if (this.state.records.filter((r) => r.owner === app.id).length >= 256)
|
|
throw new Error('QuotaExceededError');
|
|
if (JSON.stringify(data).length > 65536) throw new Error('QuotaExceededError');
|
|
const record = {
|
|
owner: app.id,
|
|
id: this.state.nextId++,
|
|
date,
|
|
respectTimezone,
|
|
data,
|
|
local,
|
|
};
|
|
this.state.records.push(record);
|
|
await this.save();
|
|
return record.id;
|
|
}
|
|
if (operation === 'remove') {
|
|
this.state.records = this.state.records.filter((r) => r.owner !== app.id || r.id !== id);
|
|
await this.save();
|
|
return null;
|
|
}
|
|
if (operation === 'subscribe') {
|
|
const routes =
|
|
app.manifest.messages
|
|
?.flatMap((m) => Object.entries(m))
|
|
.filter(([name]) => name === 'alarm')
|
|
.map(([, path]) => path) || [];
|
|
if (
|
|
!routes.some(
|
|
(path) => actor.manager.documentURI.spec.split(/[?#]/)[0] === app.origin + path,
|
|
)
|
|
)
|
|
throw new Error('PERMISSION_DENIED');
|
|
this.listeners.set(actor, app.id);
|
|
await this.tick();
|
|
return null;
|
|
}
|
|
throw new Error('METHOD_NOT_SUPPORTED');
|
|
});
|
|
}
|
|
async tick() {
|
|
const ready = this.state.records.filter((r) => this.due(r) <= Date.now());
|
|
if (ready.length) {
|
|
this.state.records = this.state.records.filter((r) => !ready.includes(r));
|
|
this.state.pending.push(...ready.map((r) => ({ ...r, date: this.due(r) })));
|
|
await this.save();
|
|
}
|
|
let changed = false;
|
|
for (const record of [...this.state.pending]) {
|
|
const receiver = [...this.listeners].find(
|
|
([actor, owner]) => owner === record.owner && this.identity(actor)?.id === owner,
|
|
);
|
|
if (receiver) {
|
|
try {
|
|
receiver[0].sendAsyncMessage('Vulpes:Event', {
|
|
type: 'alarm',
|
|
data: { id: record.id, date: record.date, data: record.data },
|
|
});
|
|
this.state.pending.splice(this.state.pending.indexOf(record), 1);
|
|
changed = true;
|
|
} catch (_) {
|
|
this.listeners.delete(receiver[0]);
|
|
}
|
|
} else if (Date.now() - (this.lastLaunch.get(record.owner) || 0) > 5000) {
|
|
this.lastLaunch.set(record.owner, Date.now());
|
|
this.launch(record.owner);
|
|
}
|
|
}
|
|
if (changed) await this.save();
|
|
}
|
|
}
|