Compare commits

..
Author SHA1 Message Date
Daniel Salazar 0b86cbfce0 Merge remote-tracking branch 'origin/main' into jas-batch 2026-11-02 20:47:39 -07:00
Daniel Salazar c94902a023 feat(events): batch handler invocations to the events dispatcher
Invocations that come due within a few milliseconds go out as one
/invoke-batch request (up to events.invokeBatchSize, default 60). The
dispatcher runs a few at a time and streams a started line and an answer line
per call, so a slow handler delays no other call's answer. A call's timeout
runs from its started line, so time queued behind other calls is never the
handler's; queue time comes out of the connection-wait share instead, and a
call still queued past it is never started and fails as Puter's.

A lone call still uses /invoke, and a dispatcher without the batch route gets
single calls for five minutes before the batch route is tried again.
2026-11-02 19:04:07 -07:00
Daniel Salazar e6d390a478 fix(events): delayed sends recheck the app's access when they fire, and a handler set missing the run's handler is re-read on the primary
Also stops job polling at drain start and marks replayable notification sends.
2026-11-02 08:42:00 -07:00
Daniel Salazar 0bbba23e68 docs(events): add the Change Data at a Set Time recipe
A one-time schedule that changes data when it runs, cancelling and moving
it, and a repeat-a-few-times variant with maxRuns.
2026-11-01 22:33:02 -07:00
Daniel Salazar 7dc966e1ec docs(events): schedule and delayed-send accuracy and style pass
Spread and send-time wording match the runner, the per-user runs-at-once
and rate-limited stored notification limits are published, and the new
pages follow the docs writing rules.
2026-11-01 22:04:22 -07:00
Daniel Salazar 31b48f7203 fix(puter.js): quota refusals are final, and schedule error codes match the server
Schedule, subscription and handler quota 429s no longer go through the
generic retry. ScheduleErrorCode lists what the schedule methods throw,
and the notify({ sendAt }) overload has type tests.
2026-11-01 22:04:22 -07:00
Daniel Salazar e3554eba54 fix(events): pre-release fixes for jobs, schedule seams and delayed sends
- The delayed-send write is a field on the notifications driver, not a
  method, so /drivers/call can't reach it.
- A delayed send checks for its own earlier write by uid, so it takes no
  gap lock that every other notification insert waits on.
- The notification send_key index builds in place without locking writes.
- Reaps re-check expiry and suspension on the primary, so a row resumed
  since the replica read is kept.
- Revocation settles skip schedule rows they can't settle.
- An empty handler set read from a replica is re-read on the primary.
- One ended notice per settle, not one per page.
- Sweeps that run once per fleet take more batches per run.
- The global usage rate check keeps its 14-minute minimum window.
- shutdown() waits for prepare hooks a drain already started.
2026-11-01 22:04:22 -07:00
Daniel Salazar b9ec53516d fix(events): stop resuming a holder's later pages once the credit sweep's signal aborts, and keep its checkpoint 2026-11-01 20:31:49 -07:00
Daniel Salazar 6ea852755c docs(events): document schedules, sendAt and cancel(), with recipes 2026-11-01 20:31:49 -07:00
Daniel Salazar d360c2c350 feat(puter.js): schedules, notify({ sendAt }) and notifications.cancel(), and treat a 503 events_disabled as final 2026-11-01 20:30:56 -07:00
Daniel Salazar 35d53dbbcc feat(notifications): add notification.send_key, the deferred mailbox write and notify({ sendAt }) through the schedules contract 2026-11-01 20:30:56 -07:00
Daniel Salazar 9c9b3f2f2e feat(events): seams for schedules run by an extension, with events_disabled where none is installed 2026-11-01 20:30:56 -07:00
Daniel Salazar d1b2b2a5eb refactor(jobs): run core's periodic sweeps as system jobs, and publish the generation bumps of reaped subscriptions 2026-11-01 20:30:55 -07:00
Daniel Salazar b6b4042e0e feat(jobs): add JobScheduler for fleet- and region-wide system jobs 2026-11-01 20:30:55 -07:00
Daniel Salazar b5cc3eefb6 feat(config): type the jobs config as an augmentable IJobsConfig and resolve its namespace in one place 2026-11-01 20:30:55 -07:00
Daniel Salazar c1fac3648e feat(db): add nowMsExpr, the database clock in epoch milliseconds, and share the retry backoff 2026-11-01 20:30:55 -07:00
52 changed files with 235 additions and 3447 deletions

No files matched your search

-48
View File
@@ -1,48 +0,0 @@
name: Notify HeyPuter (dev)
on:
workflow_dispatch:
inputs:
puter_ref:
description: "puter branch, tag or SHA"
type: string
default: "main"
permissions:
contents: read
jobs:
authorize:
if: github.repository == 'HeyPuter/puter'
runs-on: ubuntu-latest
steps:
- name: Require maintain or admin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.triggering_actor }}
run: |
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
exit 1
fi
notify:
needs: authorize
runs-on: ubuntu-latest
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
environment: heyputer-dispatch
steps:
- name: Trigger heyputer dev deploy
env:
PUTER_REF: ${{ inputs.puter_ref }}
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
run: |
jq -nc --arg ref "$PUTER_REF" \
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d @-
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
+1 -2
View File
@@ -8,7 +8,6 @@ on:
jobs:
notify:
runs-on: ubuntu-latest
environment: heyputer-dispatch
steps:
- name: Trigger heyputer build
run: |
@@ -16,4 +15,4 @@ jobs:
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
-3
View File
@@ -22,7 +22,6 @@ import type {
import { Actor } from '../../core';
import type { UsageInput } from '../../services/metering/types';
import { FSEntry } from '../../stores/fs/FSEntry';
import type { SignupUtm } from '../../util/signupUtm';
// GUI write events spread an entry plus per-event metadata into `response`.
// The exact field set varies by emit site (FSController / LegacyFSController /
@@ -251,8 +250,6 @@ export type EventMap = {
is_temp?: boolean;
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
bonus_code?: string;
/** The `utm_*` params the signup carried, if any. Client-supplied. */
utm?: SignupUtm;
[key: string]: unknown;
};
// Signup bonus codes are pure mechanism here: an extension decides what a
@@ -1073,62 +1073,6 @@ describe('AuthController.handleSignup', () => {
).toBe(true);
});
it('announces the utm params a form signup carried', async () => {
const baseline = heardSignupSuccess.length;
const username = `utm_${uniq()}`;
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
utm_source: 'newsletter',
utm_campaign: 'launch',
utm_id: 'not-tracked',
}),
makeRes(),
);
const evt = heardSignupSuccess
.slice(baseline)
.find((e) => e.username === username);
expect(evt?.utm).toEqual({ source: 'newsletter', campaign: 'launch' });
expect(evt).not.toHaveProperty('password');
});
it('announces utm on a temp signup without exposing its placeholder email', async () => {
const baseline = heardSignupSuccess.length;
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, utm_source: 'ads' }),
res,
);
const evt = heardSignupSuccess
.slice(baseline)
.find((e) => e.is_temp === true);
expect(evt?.utm).toEqual({ source: 'ads' });
expect(evt?.email).toBeNull();
});
it('leaves utm off the event when the signup carried none', async () => {
const baseline = heardSignupSuccess.length;
const username = `noutm_${uniq()}`;
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const evt = heardSignupSuccess
.slice(baseline)
.find((e) => e.username === username);
expect(evt).toBeDefined();
expect(evt).not.toHaveProperty('utm');
});
it('still allows claiming a pseudo-user row when registration is disabled', async () => {
const authConfig = server.controllers.auth.config as {
disable_user_signup?: boolean;
@@ -77,7 +77,6 @@ import {
isAbsentBonusCode,
normalizeBonusCode,
} from '../../util/signupBonus.js';
import { readSignupUtm } from '../../util/signupUtm.js';
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import { buildUserDetails, scrubSensitive } from '../../util/userDetails.js';
import {
@@ -889,7 +888,6 @@ export class AuthController extends PuterController {
isTemp: is_temp,
fingerprint,
bonusCode,
utm: readSignupUtm(body),
});
await this.#completeLogin(req, res, user);
}
@@ -2386,46 +2386,4 @@ describe('OIDC signup bonus codes', () => {
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
});
it('carries shaped utm params in the signed state', async () => {
const decoded = await startState({
flow: 'signup',
utm_source: 'ads',
utm_campaign: 'launch',
utm_id: 'not-tracked',
utm_medium: '',
});
expect(decoded.utm).toEqual({ source: 'ads', campaign: 'launch' });
});
it('leaves utm out of the state when none were sent', async () => {
const decoded = await startState({ flow: 'signup' });
expect(decoded).not.toHaveProperty('utm');
});
it('announces the state utm on the new account, re-shaped', async () => {
const email = `utm-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
utm: { source: 'ads', medium: 7, extra: 'x' },
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.cookies).toHaveLength(1);
const deadline = Date.now() + 2000;
let success: Record<string, unknown> | undefined;
while (!success && Date.now() < deadline) {
success = heardSignupSuccess.find((e) => e.email === email);
if (!success) await new Promise((r) => setTimeout(r, 10));
}
expect(success?.utm).toEqual({ source: 'ads' });
});
});
+3 -12
View File
@@ -33,11 +33,6 @@ import {
import type { UserRow } from '../../stores/user/UserStore.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
import {
normalizeSignupUtm,
readSignupUtm,
type SignupUtm,
} from '../../util/signupUtm.js';
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
@@ -93,7 +88,6 @@ function resolutionErrorCode(code: string | undefined): string {
interface OIDCSignupOptions {
referrer?: string | null;
bonusCode?: string | null;
utm?: SignupUtm | null;
}
function signupOptionsFromState(
@@ -104,7 +98,6 @@ function signupOptionsFromState(
// Normalized when the state was signed; re-checked since this is the
// value signup acts on.
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
utm: normalizeSignupUtm(stateDecoded.utm),
};
}
@@ -502,10 +495,8 @@ export class OIDCController extends PuterController {
? req.query.bonusCode[0]
: req.query.bonusCode;
const bonusCode = normalizeBonusCode(rawBonusCode);
const utm = readSignupUtm(req.query);
if (flow === 'login' || flow === 'signup') {
if (bonusCode) statePayload.bonus_code = bonusCode;
if (utm) statePayload.utm = utm;
if (bonusCode && (flow === 'login' || flow === 'signup')) {
statePayload.bonus_code = bonusCode;
}
if (embeddedInPopup && msgId) {
statePayload.embedded_in_popup = true;
@@ -896,7 +887,7 @@ if (window.opener) {
provider,
userinfo as { sub: string; email?: string },
signup.referrer ?? null,
{ bonusCode: signup.bonusCode ?? null, utm: signup.utm ?? null },
{ bonusCode: signup.bonusCode ?? null },
);
// A concurrent callback (a second tab, a provider retry) created the
// account between step 2 and the insert. Nothing went wrong for the
@@ -1164,31 +1164,6 @@ describe('OIDCService.createUserFromOIDC', () => {
server.clients.event.off('puter.signup.success', onSignup);
});
it('announces the utm it was given', async () => {
const email = `utm-${crypto.randomBytes(4).toString('hex')}@example.com`;
const signups: Array<Record<string, unknown>> = [];
const onSignup = (_k: string, data: unknown) =>
signups.push(data as Record<string, unknown>);
server.clients.event.on('puter.signup.success', onSignup);
try {
const result = await runWithContext({ req }, () =>
oidc().createUserFromOIDC(
'google',
{ sub: `utm-sub-${email}`, email, email_verified: true },
null,
{ utm: { source: 'ads', campaign: 'launch' } },
),
);
expect(result.success).toBe(true);
expect(signups[0]?.utm).toEqual({
source: 'ads',
campaign: 'launch',
});
} finally {
server.clients.event.off('puter.signup.success', onSignup);
}
});
it('retries the generated username when its home path is already occupied', async () => {
// The RNG is input to the candidate generator, not the code under
// test: compute the same first candidate the service will try.
+2 -8
View File
@@ -30,7 +30,6 @@ import { Context } from '../../core';
import { HttpError } from '../../core/http/HttpError.js';
import crypto from 'node:crypto';
import { v4 as uuidv4 } from 'uuid';
import type { SignupUtm } from '../../util/signupUtm.js';
import { verifyOidcIdToken, type JwksCacheEntry } from './oidcIdToken';
const GOOGLE_DISCOVERY_URL =
@@ -493,17 +492,13 @@ export class OIDCService extends PuterService {
* re-resolve rather than surface an error — see
* `#resolveOrCreateOIDCUser`.
*
* `bonusCode` must already be canonical (`normalizeBonusCode`) and `utm`
* already shaped (`normalizeSignupUtm`).
* `bonusCode` must already be canonical (`normalizeBonusCode`).
*/
async createUserFromOIDC(
providerId: string,
claims: OIDCUserInfo,
referrer?: string | null,
{
bonusCode = null,
utm = null,
}: { bonusCode?: string | null; utm?: SignupUtm | null } = {},
{ bonusCode = null }: { bonusCode?: string | null } = {},
): Promise<{
success: boolean;
user?: UserRow;
@@ -691,7 +686,6 @@ export class OIDCService extends PuterService {
bonusCode,
emailConfirmed: true,
saveAccount: true,
utm,
});
return { success: true, user };
@@ -381,29 +381,3 @@ describe('SignupService.confirmEmail', () => {
expect(after!.email).toBe(other.email);
});
});
describe('SignupService.validateEmail', () => {
it('refuses Puter mailbox addresses before any listener runs', async () => {
const listener = vi.fn();
server.clients.event.on('email.validate', listener);
try {
for (const email of [
'[email protected]',
'[email protected]',
'[email protected]',
]) {
await expect(
server.services.signup.validateEmail(email),
).rejects.toMatchObject({ statusCode: 400 });
}
expect(listener).not.toHaveBeenCalled();
await expect(
server.services.signup.validateEmail(
`ok-${slug()}@example.com`,
),
).resolves.toBeUndefined();
} finally {
server.clients.event.off('email.validate', listener);
}
});
});
+3 -19
View File
@@ -26,13 +26,11 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { isOwnedEmailConflict } from '../../stores/user/UserStore.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { isPuterEmailAddress } from '../email/mailbox.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
validateSignupBonus,
} from '../../util/signupBonus.js';
import type { SignupUtm } from '../../util/signupUtm.js';
import { isUsernameTaken } from '../../util/username.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import {
@@ -84,7 +82,6 @@ export interface SignupInput {
isTemp: boolean;
fingerprint: string | null;
bonusCode: string | null;
utm?: SignupUtm | null;
}
/** The trusted client address; `req.ip` honors `trust proxy`. */
@@ -98,20 +95,10 @@ export const signupClientIp = (req: Request): string | null =>
*/
export class SignupService extends PuterService {
/**
* Puter mailbox addresses, the config blocklist (suffix match on the
* cleaned address), then the `email.validate` hook. Throws 400 on
* rejection.
* Config blocklist (suffix match on the cleaned address), then the
* `email.validate` hook. Throws 400 on rejection.
*/
async validateEmail(email: string): Promise<void> {
// A Puter mailbox can't be an account's own email: it is reached
// through an account in the first place.
if (isPuterEmailAddress(email.trim())) {
throw new HttpError(
400,
'A Puter email address cannot be used as your account email. Please use a different email address.',
{ legacyCode: 'email_not_allowed' as never },
);
}
if (isBlockedEmail(email, this.config.blockedEmailDomains)) {
throw new HttpError(400, 'This email is not allowed.', {
legacyCode: 'email_not_allowed' as never,
@@ -280,7 +267,7 @@ export class SignupService extends PuterService {
* refused signup answers with.
*/
async signup(input: SignupInput): Promise<UserRow> {
const { req, body, isTemp, fingerprint, bonusCode, utm } = input;
const { req, body, isTemp, fingerprint, bonusCode } = input;
// Runs before the duplicate checks so a disabled endpoint doesn't
// reveal which usernames or emails exist. Claiming a pre-existing
@@ -549,7 +536,6 @@ export class SignupService extends PuterService {
// The created/claimed row: a claimed placeholder has credentials.
isTemp: user.password === null && user.email === null,
saveAccount: !isTemp,
utm,
});
return user;
}
@@ -569,7 +555,6 @@ export class SignupService extends PuterService {
/** The provider verified the address, so this is its confirmation. */
emailConfirmed?: boolean;
saveAccount: boolean;
utm?: SignupUtm | null;
},
): void {
if (opts.emailConfirmed) this.#emitEmailConfirmed(user);
@@ -591,7 +576,6 @@ export class SignupService extends PuterService {
// counters are written under one key and read under another.
ip: opts.ip,
...(opts.bonusCode ? { bonus_code: opts.bonusCode } : {}),
...(opts.utm ? { utm: opts.utm } : {}),
},
{},
);
-85
View File
@@ -1,85 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import {
normalizeSignupUtm,
readSignupUtm,
SIGNUP_UTM_MAX_LENGTH,
} from './signupUtm.js';
describe('readSignupUtm', () => {
it('reads the utm_* params, keyed without the prefix', () => {
expect(
readSignupUtm({
utm_source: 'newsletter',
utm_medium: 'email',
utm_campaign: 'launch',
utm_term: 'cloud os',
utm_content: 'hero-cta',
}),
).toEqual({
source: 'newsletter',
medium: 'email',
campaign: 'launch',
term: 'cloud os',
content: 'hero-cta',
});
});
it('drops unknown keys, non-strings, blanks and oversized values', () => {
expect(
readSignupUtm({
utm_source: ' ads ',
utm_medium: 42,
utm_campaign: ' ',
utm_term: 'x'.repeat(SIGNUP_UTM_MAX_LENGTH + 1),
utm_content: { nested: true },
utm_id: 'not-tracked',
password: 'secret',
}),
).toEqual({ source: 'ads' });
});
it('takes the first value of a repeated query param', () => {
expect(readSignupUtm({ utm_source: ['first', 'second'] })).toEqual({
source: 'first',
});
});
it('returns null when nothing usable is present', () => {
expect(readSignupUtm({})).toBeNull();
expect(readSignupUtm({ utm_source: '' })).toBeNull();
expect(readSignupUtm(undefined)).toBeNull();
});
});
describe('normalizeSignupUtm', () => {
it('keeps a well-formed value and re-checks each field', () => {
expect(
normalizeSignupUtm({ source: 'ads', medium: 7, extra: 'x' }),
).toEqual({ source: 'ads' });
});
it('returns null for anything that is not a plain object', () => {
expect(normalizeSignupUtm(null)).toBeNull();
expect(normalizeSignupUtm('ads')).toBeNull();
expect(normalizeSignupUtm(['ads'])).toBeNull();
});
});
-71
View File
@@ -1,71 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Campaign attribution a signup arrives with, as `utm_*` params. Core only
* shapes and forwards the values; what is done with them is up to listeners on
* `puter.signup.success`.
*/
export const SIGNUP_UTM_KEYS = [
'source',
'medium',
'campaign',
'term',
'content',
] as const;
export type SignupUtmKey = (typeof SIGNUP_UTM_KEYS)[number];
/** The `utm_*` values a signup carried, keyed without the prefix. */
export type SignupUtm = Partial<Record<SignupUtmKey, string>>;
/** Caps each value, which also bounds the OIDC state token that carries them. */
export const SIGNUP_UTM_MAX_LENGTH = 255;
const shapeValue = (raw: unknown): string | null => {
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed && trimmed.length <= SIGNUP_UTM_MAX_LENGTH ? trimmed : null;
};
/**
* A `SignupUtm` holding only the known keys with usable string values, or null
* when none are left.
*/
export function normalizeSignupUtm(raw: unknown): SignupUtm | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const src = raw as Record<string, unknown>;
const utm: SignupUtm = {};
for (const key of SIGNUP_UTM_KEYS) {
const value = shapeValue(src[key]);
if (value) utm[key] = value;
}
return Object.keys(utm).length > 0 ? utm : null;
}
/** The `utm_*` params in a request body or query, or null when there are none. */
export function readSignupUtm(params: unknown): SignupUtm | null {
if (!params || typeof params !== 'object') return null;
const src = params as Record<string, unknown>;
return normalizeSignupUtm(
Object.fromEntries(SIGNUP_UTM_KEYS.map((k) => [k, src[`utm_${k}`]])),
);
}
+1 -3
View File
@@ -6,8 +6,6 @@ platforms: [websites, apps]
Signs the user out of the application.
On a website, signing out also turns push notifications off in this browser. It ends the push subscription Puter made for your app here and removes it from Puter, and [`onPush()`](/Notifications/onPush/) doesn't subscribe again until the next user calls [`enable()`](/Notifications/enable/). The user is signed out as soon as you call it. The returned promise resolves once the subscription is ended, so wait for it before you reload the page.
## Syntax
```js
@@ -20,7 +18,7 @@ None
## Return value
A `Promise` that resolves with nothing once the user is signed out and, on a website, this browser's push subscription for your app is ended. It doesn't reject.
None
## Example
+2 -2
View File
@@ -6,7 +6,7 @@ platforms: [websites, apps]
Turns push notifications off for your app. Notifications still reach your app live while it's open.
On a website, it ends this browser's push subscription for your app and removes it from Puter. Only the subscription Puter made is removed, so a subscription another push service made on your site stays as it is. Pages of your site in this browser, including ones open in other tabs, stop showing notifications themselves while they're in the background, so a notification goes out as a push to the user's other browsers instead. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt.
On a website, it ends this browser's push subscription for your app and removes it from Puter. Pages of your site in this browser, including ones open in other tabs, stop showing notifications themselves while they're in the background, so a notification goes out as a push to the user's other browsers instead. The browser's permission isn't changed, so a later [`enable()`](/Notifications/enable/) turns pushes back on without a prompt.
The choice sticks. Page loads don't turn pushes back on, because [`onPush()`](/Notifications/onPush/) only keeps a subscription current after [`enable()`](/Notifications/enable/). Call [`enable()`](/Notifications/enable/) from a click to turn them on again.
@@ -21,7 +21,7 @@ puter.notifications.disable(options)
## Parameters
#### `options` (Object) (optional)
- `serviceWorker` (String): The path of the worker passed to [`enable()`](/Notifications/enable/). When it's set, only that worker's subscription is removed. Without it, Puter's subscription is removed under whichever of your site's service workers holds it. Only used on a website.
- `serviceWorker` (String): The path of the worker passed to [`enable()`](/Notifications/enable/). Defaults to `/puter-sw.js`. Only used on a website.
## Return value
A `Promise` that resolves to `true` when a subscription was removed, and `false` when there was none. Inside the Puter desktop, it resolves `true` when your app held the permission. It resolves `false` in a browser that can't receive pushes.
+1 -1
View File
@@ -10,7 +10,7 @@ On a website, the browser asks on your own origin, and pushes arrive through you
The call resolves as soon as the browser has answered and Puter has its subscription. It doesn't wait for the confirmation. Pushes start once your page hands the confirmation back, usually within seconds. If the confirmation doesn't arrive within 30 seconds, the `onError` of [`onPush()`](/Notifications/onPush/) gets `push_verification_timeout`, or the console does without one, and the next page load tries again.
The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, after [`puter.auth.signOut()`](/Auth/signOut/), or after a different user signs in, page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
The choice sticks. Once pushes are on, [`onPush()`](/Notifications/onPush/) keeps the subscription current on later page loads without asking again. After [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/), page loads don't subscribe again until your page calls [`enable()`](/Notifications/enable/) from a click.
If the user turned your app off in Puter's settings, the call rejects with `push_revoked`. To ask again, call [`requestPermission()`](/Notifications/requestPermission/) from a click, which opens Puter's permission prompt, and then call [`enable()`](/Notifications/enable/) again.
+1 -1
View File
@@ -26,7 +26,7 @@ puter.notifications.notify(notification)
- `data` (any): A JSON value of up to 1 KB, handed back on click. Push services can read it, so put ids in it, not personal content.
- `group` (String): A later notification with the same `group` replaces this one, as [Groups](#groups) describes. Up to 128 letters, digits and `. _ : @ -`.
- `channel` (String): What kind of notification it is, such as `messages` or `reminders`. The [`onPush()`](/Notifications/onPush/) handler registered with this key decides what its push shows. Up to 32 lowercase letters, digits and `-`.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your site's root, or your service worker's folder when your pages are inside it, and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your service worker's scope (`/` by default), and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `priority` (String): Only `'normal'` is available to apps.
- `ttl` (Number): How many seconds a push may wait for a device that's offline, from 1 to 86400. Defaults to 3600. It doesn't change how long the notification is kept.
- `to` (Object): Send it to the other side of a key-value share instead, as `{ handle }` with the share's handle. See [Notify the other side of a share](#notify-the-other-side-of-a-share).
+1 -1
View File
@@ -11,7 +11,7 @@ Clicks reach your app in two ways:
- **On a website**, from a tap on a push, or on a notification a page of your site showed itself. When a page of your site is open, the tap focuses it and calls its `click` handlers. A page without a `click` handler goes to the notification's `url` instead, which is the `url` your [`onPush()`](/Notifications/onPush/) handler returned, or else the notification's `target`. When no page is open, the tap opens that `url`, and the new page's first `click` handler gets the click.
- **Inside the Puter desktop**, from a tap on a push that Puter shows. Puter opens your app, or brings it to the front, and delivers the click.
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup. On a website, a page a tap opened drops the click when no `click` handler is added within 10 seconds of the page loading.
A click that arrives before your first `click` handler is added, such as the one that opened your app, goes to that first handler, once. Handlers added after it don't get it, so add your handler early during startup.
To remove a handler, call [`puter.notifications.off('click', handler)`](/Notifications/on/).
+1 -1
View File
@@ -37,7 +37,7 @@ The `channel` your notifications are sent with. 1 to 32 lowercase letters, digit
A plain `function` or an arrow function. It's called with the notification, in the same shape [`onNotification()`](/Notifications/onNotification/) delivers. It may return, or resolve to, an object that changes what the browser shows:
- `title`, `body`, `icon`, `badge`, `image`, `tag` (String): As in the browser's `showNotification()`. A notification sent with a `group` gets a `tag` from it by default, so a later one in the group replaces it on screen.
- `url` (String): A path or URL on your origin. Defaults to the notification's `target`. A relative path is read from your site's root, or from your worker's folder when your pages are inside it ([requirements](/Notifications/push/#requirements)). A URL on another origin is replaced with that root or folder. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
- `url` (String): A path or URL on your origin, resolved against your worker's scope. Defaults to the notification's `target`. A URL on another origin is replaced with your worker's scope. When no page of your site is open, a tap opens it. When a page is open, the tap focuses that page and goes to [`on('click')`](/Notifications/on/). A page without a `click` handler goes to `url` instead.
Returning nothing shows the notification's own `title` and `text`. Inside the Puter desktop, the result isn't used, and a push shows the notification's own title and text.
+4 -4
View File
@@ -40,7 +40,7 @@ On a website, pushes arrive on your own domain through a small service worker, a
- **HTTPS.** Browsers only allow service workers on `https://` pages, and on `http://localhost` for testing.
- **A worker on your own origin.** The worker file has to come from the same origin as your pages. A worker on a CDN or another subdomain can't be registered.
- **At the root of your site, by default.** [`enable()`](/Notifications/enable/) registers `/puter-sw.js`. A worker elsewhere, such as `/puter/puter-sw.js`, works when you pass its path as `serviceWorker`. A tap with no URL opens your site's root, and a relative URL is read from it. When the page that calls [`onPush()`](/Notifications/onPush/) or [`enable()`](/Notifications/enable/) is inside the worker's folder, such as an app at `/app/` with `/app/puter-sw.js`, taps use that folder instead.
- **At the root of your site, by default.** [`enable()`](/Notifications/enable/) registers `/puter-sw.js`. A worker elsewhere, such as `/puter/puter-sw.js`, works when you pass its path as `serviceWorker`. A tap with no page open resolves relative URLs against the worker's folder.
- **Served as JavaScript.** The response needs a JavaScript `Content-Type`, such as `text/javascript`, and a `200` status. Browsers refuse a worker that comes back as `text/html` or through a redirect.
[Puter Hosting](/Hosting/) meets all of these when `puter-sw.js` is in the root of the folder you host. With a build tool, put the file in the folder it copies as is, such as `public/` in Vite or Next.js.
@@ -112,7 +112,7 @@ document.getElementById('enable').addEventListener('click', async () => {
[`enable()`](/Notifications/enable/#return-value) lists every error it rejects with. When nobody is signed in, it opens the Puter sign-in after the browser's prompt. Sign the user in first, from its own button, so the sign-in window isn't blocked as a popup.
To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Signing out also ends this browser's push subscription for your app and removes it from Puter. Signing in as a different user does the same, so pushes stay off in this browser until the new user calls [`enable()`](/Notifications/enable/). Only the subscription Puter made is removed. A subscription another push service made on your site, under any service worker, stays as it is.
To turn pushes off in this browser again, call [`disable()`](/Notifications/disable/). Page loads don't turn pushes back on after [`disable()`](/Notifications/disable/), after the user turns your app off or removes this browser in Puter, or after [`puter.auth.signOut()`](/Auth/signOut/). Signing out also removes this browser's registration for your app.
## Sending a notification
@@ -150,7 +150,7 @@ To notify another user, share data with them with `notify: true` and send throug
## When the user taps
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. A tap reaches one page, once: when an open page doesn't answer within about half a second, the tap opens its URL instead, and the slow page doesn't get it, unless that page is already at the tap's URL. If that page wasn't loaded through your worker, such as one opened before the worker was installed, the URL opens in a new tab. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
On a website, a tap on a push, or on a notification a page of your site showed itself, focuses an open page of your site and calls its [`on('click')`](/Notifications/on/) handlers. With no page open, it opens the handler's `url`, or else the notification's `target`, and the new page's first [`on('click')`](/Notifications/on/) handler gets the click. Inside the Puter desktop, a tap opens your app and hands the click to [`on('click')`](/Notifications/on/).
## Groups and sensitive notifications
@@ -310,5 +310,5 @@ The browser can be allowed to show notifications while the operating system hide
- Say who did what. "Ana shared Q3-report.pdf" reads well as a push. A push that only says there's something new can be hidden as spam on Android.
- The `data` field is handed back on click, and push services can read it, so put ids in it and fetch the rest. Set `sensitive: true` when the title and text shouldn't leave Puter.
- A push carries about 4 KB, with `data` at most 1 KB. A push that doesn't fit has its text shortened.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your site's root instead, or the worker's folder when your pages are inside it.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your worker's folder instead.
- Pushes from your app are billed to the user who receives them. See [What pushes cost](/rate-limits-and-quotas/#what-pushes-cost). Puter's own notifications are free.
+1 -10
View File
@@ -17,13 +17,6 @@ puter.ui.showFontPicker(options)
#### `defaultFont` (String)
The default font to select when the font picker is opened.
#### `options` (Object)
An object with the following properties:
- `defaultFont` (String): The font to select when the font picker is opened. The name is matched without regard to case. `defaultValue` and `default` are accepted as aliases. If more than one is given, `defaultValue` is used, then `defaultFont`, then `default`.
## Return value
A `Promise` that resolves to an object with a `fontFamily` property holding the name of the selected font. If the user closes the picker without selecting a font, the promise resolves to `false` in a Puter app and to `null` on a website.
## Examples
@@ -35,9 +28,7 @@ A `Promise` that resolves to an object with a `fontFamily` property holding the
<script>
puter.ui.showFontPicker().then((font)=>{
if (font) {
document.body.style.fontFamily = font.fontFamily;
}
document.body.style.fontFamily = font.fontFamily;
})
</script>
</body>
@@ -6,10 +6,8 @@
<script>
document.getElementById('pick-font').addEventListener('click', () => {
puter.ui.showFontPicker().then((font) => {
if (font) {
document.getElementById('preview').style.fontFamily = font.fontFamily;
puter.print('Selected font: ' + font.fontFamily);
}
document.getElementById('preview').style.fontFamily = font.fontFamily;
puter.print('Selected font: ' + font.fontFamily);
});
});
</script>
-3
View File
@@ -1630,9 +1630,6 @@ const ipc_listener = async (event, handled) => {
}
// set options
if ( typeof event.data.options === 'string' ) {
event.data.options = { defaultFont: event.data.options };
}
event.data.options = event.data.options ?? {};
// clear window_options for security reasons
-152
View File
@@ -39,7 +39,6 @@ import { openPublishWebsiteDialog, openPublishWorkerDialog } from './UIPublishDi
import { dedupedName } from '../../helpers/dedupedName.js';
import { compareEntries, compareNames } from './sortEntries.js';
import { middleTruncate } from './middleTruncate.js';
import { resolveTypedPath } from './typedPath.js';
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
import { icons } from '../../helpers/actionIcons.js';
@@ -164,7 +163,6 @@ const TabFiles = {
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
</div>
<input type="text" class="path-editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" enterkeyhint="go" aria-label="${i18n('path')}">
<div class="path-breadcrumbs"></div>
<div class="path-actions">
<button class="path-action-btn select-mode-btn" title="${i18n('select')}" aria-pressed="false">${icons.select}</button>
@@ -838,142 +836,6 @@ const TabFiles = {
if ( crumbs ) crumbs.scrollLeft = crumbs.scrollWidth;
},
/**
* Lets the path bar be typed into: a click on it anywhere but on a crumb
* turns it into a text field, as in an Explorer window.
*
* @returns {void}
*/
bindPathEditor () {
this.$el_window.on('click', '.files-tab .path-breadcrumbs', (e) => {
if ( $(e.target).closest('.dirname').length ) return;
this.openPathEditor();
});
this.$el_window.on('keydown', '.files-tab .path-editor', (e) => {
// Enter and Escape mid-composition belong to the IME; jQuery's
// event doesn't carry isComposing, the browser's does.
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
if ( e.key === 'Enter' ) {
e.preventDefault();
e.stopPropagation();
this.submitPathEditor();
} else if ( e.key === 'Escape' ) {
e.preventDefault();
e.stopPropagation();
this.closePathEditor({ refocus: true });
}
});
this.$el_window.on('input', '.files-tab .path-editor', (e) => {
e.currentTarget.removeAttribute('aria-invalid');
});
this.$el_window.on('focusout', '.files-tab .path-editor', () => {
if ( ! this.pathLookup ) this.closePathEditor();
});
},
/**
* Swaps the breadcrumbs for a text field to type a path into.
*
* @param {string} [initial] - Text to start from, with the caret after
* it; by default the current path, selected
* @returns {void}
*/
openPathEditor (initial) {
const $path = this.$el_window?.find('.files-tab .header .path');
const input = $path?.find('.path-editor')[0];
if ( !input || !this.currentPath ) return;
input.value = initial ?? this.currentPath;
input.removeAttribute('aria-invalid');
$path.addClass('path-editing');
input.focus();
if ( initial === undefined ) input.select();
else input.setSelectionRange(input.value.length, input.value.length);
},
/**
* Puts the breadcrumbs back in place of the path field.
*
* @param {{ refocus?: boolean }} [options] - refocus hands the keyboard
* back to the listing
* @returns {void}
*/
closePathEditor ({ refocus = false } = {}) {
const $path = this.$el_window?.find('.files-tab .header .path');
if ( ! $path?.hasClass('path-editing') ) return;
$path.removeClass('path-editing');
// A lookup still running for the closed field is called off.
this.pathLookup = null;
// Hidden, the crumbs lost their scroll position.
this.revealCurrentCrumb();
if ( refocus ) this.focusListing();
},
/**
* Goes where the path field says: into a folder, or to a file's folder
* with the file selected. A path to nothing leaves the field open.
*
* @returns {Promise<void>}
*/
async submitPathEditor () {
// A second Enter during the lookup would look it up again, and a
// path to nothing would get a second alert.
if ( this.pathLookup ) return;
const input = this.$el_window.find('.files-tab .path-editor')[0];
const typed = input.value.trim();
const target = resolveTypedPath(typed, {
currentPath: this.currentPath,
homePath: window.home_path,
sharedPath: window.shared_path,
});
if ( target === undefined || target === this.currentPath ) {
this.closePathEditor({ refocus: true });
return;
}
let folder = target;
let file;
// Neither Shared nor the root can be stat'ed, and both are folders.
if ( target !== window.shared_path && target !== '/' ) {
// The stat and the alert take the focus from the field, which
// would close it.
const lookup = this.pathLookup = {};
try {
let entry;
try {
entry = await puter.fs.stat({ path: target, consistency: 'eventual' });
} catch {
entry = undefined;
}
if ( this.pathLookup !== lookup ) return;
if ( ! entry ) {
input.setAttribute('aria-invalid', 'true');
// Settled on close: Escape closes the alert without
// settling what UIAlert returns.
await new Promise(resolve => UIAlert({
message: i18n('path_not_found', [typed], false),
window_options: { on_close: resolve },
}));
input.focus();
input.select();
return;
}
if ( ! entry.is_dir ) {
file = entry.path ?? target;
folder = path.dirname(file);
}
} finally {
if ( this.pathLookup === lookup ) this.pathLookup = null;
}
}
this.closePathEditor({ refocus: true });
if ( folder !== this.currentPath ) {
this.pushNavHistory(folder);
await this.renderDirectory(folder);
}
if ( file ) this.selectRowsWhere(row => row.getAttribute('data-path') === file);
},
/**
* Checks if the Dashboard Files tab is currently active and visible.
*
@@ -1391,14 +1253,6 @@ const TabFiles = {
return false;
}
// No name holds a slash, so typing one starts a path to go to.
if ( e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && _this.typeSearchTerm === '' ) {
e.preventDefault();
e.stopImmediatePropagation();
_this.openPathEditor('/');
return false;
}
// Type-to-select: letter/number keys search items by name
if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) {
e.preventDefault();
@@ -1560,8 +1414,6 @@ const TabFiles = {
this.click();
});
this.bindPathEditor();
const el_window_navbar_back_btn = document.querySelector(`.path-btn-back`);
const el_window_navbar_forward_btn = document.querySelector(`.path-btn-forward`);
const el_window_navbar_up_btn = document.querySelector(`.path-btn-up`);
@@ -6034,10 +5886,6 @@ const TabFiles = {
this.$el_window.find('.files-tab .files .row.selected').removeClass('selected');
rows.addClass('selected');
// The arrow keys and Shift+click go on from here, not from a row
// selected before.
window.active_element = rows[0];
window.latest_selected_item = rows[0];
this.updateFooterStats();
rows[0].scrollIntoView({ block: 'nearest' });
},
-248
View File
@@ -1426,251 +1426,3 @@ describe('TabFiles listSharedView', () => {
expect(rows[1].holder_team).toEqual(team);
});
});
describe('TabFiles path field', () => {
let UIAlert;
/** The tab's real header and an empty listing, its listeners bound. */
const mountHeader = (currentPath = '/alice/Documents') => {
// Drag and drop is jQuery UI's, which isn't loaded here.
$.fn.droppable ??= function () {
return this;
};
$.fn.draggable ??= function () {
return this;
};
document.body.innerHTML = `<div class="window">${TabFiles.html()}</div>`;
TabFiles.$el_window = $('.window');
TabFiles.currentPath = currentPath;
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
$('.path-breadcrumbs').html(TabFiles.renderPath(currentPath));
TabFiles.bindPathEditor();
};
const editor = () => $('.path-editor')[0];
const editing = () => $('.header .path').hasClass('path-editing');
const keydown = (el, key) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
beforeEach(async () => {
({ default: UIAlert } = await import('../UIAlert.js'));
UIAlert.mockClear();
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.shared_path = 'puter://shared';
window.byte_format = bytes => `${bytes} B`;
window.dashboard_nav_history = ['/alice/Documents'];
window.dashboard_nav_history_current_position = 0;
Element.prototype.scrollIntoView ??= () => {};
window.puter = { fs: {
readdir: vi.fn(async () => []),
stat: vi.fn(async ({ path }) => {
if ( path === '/alice/Pictures' ) return { path, is_dir: true };
if ( path === '/alice/Pictures/cat.png' ) return { path, is_dir: false };
throw { code: 'subject_does_not_exist' };
}),
} };
});
afterEach(() => {
TabFiles.$el_window.off();
$(document).off('keydown.tabfiles');
vi.restoreAllMocks();
});
it('opens with the current path selected on a click past the crumbs', () => {
mountHeader();
$('.path-breadcrumbs')[0].click();
expect(editing()).toBe(true);
expect(document.activeElement).toBe(editor());
expect(editor().value).toBe('/alice/Documents');
expect(editor().selectionStart).toBe(0);
expect(editor().selectionEnd).toBe('/alice/Documents'.length);
});
it('keeps a phone keyboard from rewriting the path, and offers Go', () => {
mountHeader();
expect(editor().getAttribute('autocorrect')).toBe('off');
expect(editor().getAttribute('autocapitalize')).toBe('off');
expect(editor().getAttribute('enterkeyhint')).toBe('go');
});
it('stays closed on a click on a crumb', () => {
mountHeader();
vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
$('.path-breadcrumbs .dirname').first()[0].click();
expect(editing()).toBe(false);
});
it('opens on a typed slash, holding just the slash', () => {
mountHeader();
vi.spyOn(TabFiles, 'isDashboardFilesActive').mockReturnValue(true);
TabFiles.typeSearchTerm = '';
TabFiles.setupKeyboardShortcuts();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '/', which: 191, bubbles: true }));
expect(editing()).toBe(true);
expect(editor().value).toBe('/');
});
it('goes into the folder typed, and Back returns', async () => {
mountHeader();
TabFiles.openPathEditor();
editor().value = '../Pictures';
await TabFiles.submitPathEditor();
expect(TabFiles.currentPath).toBe('/alice/Pictures');
expect(window.dashboard_nav_history).toEqual(['/alice/Documents', '/alice/Pictures']);
expect(editing()).toBe(false);
});
it('goes to the root, which can\'t be looked up, from / or ..', async () => {
for ( const typed of ['/', '..'] ) {
mountHeader('/alice');
TabFiles.openPathEditor();
editor().value = typed;
await TabFiles.submitPathEditor();
expect(TabFiles.currentPath).toBe('/');
}
expect(window.puter.fs.stat).not.toHaveBeenCalled();
expect(UIAlert).not.toHaveBeenCalled();
});
it('opens a typed file\'s folder with the file selected', async () => {
mountHeader();
window.puter.fs.readdir = vi.fn(async () => [{ name: 'cat.png', path: '/alice/Pictures/cat.png', is_dir: false, uid: 'u-cat', size: 1, modified: 0 }]);
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}"></div>`);
});
TabFiles.openPathEditor();
editor().value = '~/Pictures/cat.png';
await TabFiles.submitPathEditor();
expect(TabFiles.currentPath).toBe('/alice/Pictures');
expect($('.files .row.selected').attr('data-path')).toBe('/alice/Pictures/cat.png');
});
it('moves the arrow keys\' starting row to a typed file in the folder on screen', async () => {
mountHeader('/alice/Pictures');
$('.files').html(['a.png', 'cat.png', 'z.png'].map(name => `<div class="row item" data-name="${name}" data-path="/alice/Pictures/${name}"></div>`).join(''));
const [first, cat] = $('.files .row').toArray();
first.classList.add('selected');
window.latest_selected_item = first;
TabFiles.openPathEditor();
editor().value = 'cat.png';
await TabFiles.submitPathEditor();
expect($('.files .row.selected').toArray()).toEqual([cat]);
expect(window.latest_selected_item).toBe(cat);
expect(window.active_element).toBe(cat);
});
it('stays open, marked, on a path to nothing', async () => {
mountHeader();
const render = vi.spyOn(TabFiles, 'renderDirectory');
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
// The alert takes the focus, which must not close the field.
UIAlert.mockImplementation(async ({ window_options }) => {
editor().blur();
window_options.on_close();
return true;
});
await TabFiles.submitPathEditor();
expect(UIAlert.mock.calls[0][0].message).toContain('/alice/Gone');
expect(render).not.toHaveBeenCalled();
expect(editing()).toBe(true);
expect(editor().getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(editor());
});
it('takes the field back when the alert is closed without its button', async () => {
mountHeader();
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
// Escape removes the alert, and what UIAlert returns never settles.
UIAlert.mockImplementation(({ window_options }) => {
editor().blur();
window_options.on_close();
return new Promise(() => {});
});
await TabFiles.submitPathEditor();
expect(document.activeElement).toBe(editor());
editor().blur();
expect(editing()).toBe(false);
});
it('looks a path up once, however often Enter is pressed meanwhile', async () => {
mountHeader();
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
UIAlert.mockImplementation(async ({ window_options }) => window_options.on_close());
await Promise.all([TabFiles.submitPathEditor(), TabFiles.submitPathEditor()]);
expect(window.puter.fs.stat).toHaveBeenCalledTimes(1);
expect(UIAlert).toHaveBeenCalledTimes(1);
});
it('calls off a lookup on Escape, and takes a new path at once', async () => {
mountHeader();
const render = vi.spyOn(TabFiles, 'renderDirectory').mockResolvedValue();
let settle;
window.puter.fs.stat.mockImplementationOnce(() => new Promise(resolve => {
settle = resolve;
}));
TabFiles.openPathEditor();
editor().value = '/alice/Gone';
const calledOff = TabFiles.submitPathEditor();
keydown(editor(), 'Escape');
TabFiles.openPathEditor();
editor().value = '/alice/Pictures';
await TabFiles.submitPathEditor();
settle(undefined);
await calledOff;
expect(render.mock.calls).toEqual([['/alice/Pictures']]);
expect(UIAlert).not.toHaveBeenCalled();
});
it('leaves Enter and Escape to an IME composing a name', () => {
mountHeader();
const submit = vi.spyOn(TabFiles, 'submitPathEditor').mockResolvedValue();
TabFiles.openPathEditor();
for ( const key of ['Enter', 'Escape'] ) {
editor().dispatchEvent(new KeyboardEvent('keydown', { key, isComposing: true, bubbles: true }));
editor().dispatchEvent(new KeyboardEvent('keydown', { key: 'Process', keyCode: 229, bubbles: true }));
}
expect(submit).not.toHaveBeenCalled();
expect(editing()).toBe(true);
});
it('gives the crumbs back on Escape or when it loses the focus', () => {
mountHeader();
TabFiles.openPathEditor();
keydown(editor(), 'Escape');
expect(editing()).toBe(false);
TabFiles.openPathEditor();
editor().blur();
expect(editing()).toBe(false);
});
});
-39
View File
@@ -1,39 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import path from '../../lib/path.js';
/**
* Where a path typed into the Files tab's path bar leads: `~` is the home
* folder, and a relative path starts from the folder on screen.
*
* @param {string} typed
* @param {{ currentPath: string, homePath: string, sharedPath: string }} places
* @returns {string | undefined} An absolute path, the Shared view's path, or
* undefined when nothing was typed
*/
export function resolveTypedPath (typed, { currentPath, homePath, sharedPath }) {
const text = typed.trim();
if ( text === '' ) return undefined;
if ( text.replace(/\/+$/, '') === sharedPath ) return sharedPath;
if ( text === '~' || text.startsWith('~/') ) return path.resolve(homePath, `.${text.slice(1)}`);
// The Shared view is a query, not a folder a relative path can start from.
const base = currentPath?.startsWith('/') ? currentPath : homePath;
return path.resolve(base, text);
}
@@ -1,61 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { resolveTypedPath } from './typedPath.js';
const places = { currentPath: '/alice/Documents', homePath: '/alice', sharedPath: 'puter://shared' };
describe('resolveTypedPath', () => {
it('takes an absolute path as typed, without the trailing slash', () => {
expect(resolveTypedPath('/bob/Public/', places)).toBe('/bob/Public');
expect(resolveTypedPath('/', places)).toBe('/');
});
it('ignores the space around a pasted path', () => {
expect(resolveTypedPath(' /alice/Pictures \n', places)).toBe('/alice/Pictures');
});
it('reads ~ as the home folder', () => {
expect(resolveTypedPath('~', places)).toBe('/alice');
expect(resolveTypedPath('~/Desktop', places)).toBe('/alice/Desktop');
});
it('starts a relative path from the folder on screen', () => {
expect(resolveTypedPath('Work/2026', places)).toBe('/alice/Documents/Work/2026');
expect(resolveTypedPath('../Videos', places)).toBe('/alice/Videos');
expect(resolveTypedPath('/alice/./Documents/../Public', places)).toBe('/alice/Public');
});
it('starts a relative path from home in the Shared view', () => {
expect(resolveTypedPath('Desktop', { ...places, currentPath: 'puter://shared' })).toBe('/alice/Desktop');
});
it('keeps the Shared view\'s path', () => {
expect(resolveTypedPath('puter://shared/', places)).toBe('puter://shared');
});
it('leads nowhere when nothing was typed', () => {
expect(resolveTypedPath(' ', places)).toBeUndefined();
});
it('a ~ inside a name is just a character', () => {
expect(resolveTypedPath('~notes', places)).toBe('/alice/Documents/~notes');
});
});
+5 -9
View File
@@ -41,7 +41,7 @@ const font_list = new Set([
async function UIWindowFontPicker (options) {
// set sensible defaults
if ( arguments.length > 0 ) {
// if first argument is a string, then assume it is the default font
// if first argument is a string, then assume it is the default color
if ( window.isString(arguments[0]) ) {
options = {};
options.default = arguments[0];
@@ -49,17 +49,13 @@ async function UIWindowFontPicker (options) {
}
options = options || {};
const defaultFont = options.defaultValue ?? options.defaultFont ?? options.default;
const isDefault = (font) => window.isString(defaultFont)
&& font.toLowerCase() === defaultFont.trim().toLowerCase();
return new Promise(async (resolve) => {
let h = '';
h += '<div>';
h += '<div style="padding: 20px; border-bottom: 1px solid #ced7e1; width: 100%; box-sizing: border-box;">';
h += '<div class="font-list" style="margin-bottom: 10px; height: 200px; overflow-y: scroll; background-color: white; padding: 0 10px;">';
fontAvailable.forEach(element => {
h += `<p class="font-selector disable-user-select ${isDefault(element) ? 'font-selector-active' : ''}" style="font-family: '${html_encode(element)}';" data-font-family="${html_encode(element)}">${html_encode(element)}</p>`; // 👉️ one, two, three, four
h += `<p class="font-selector disable-user-select ${options.default === element ? 'font-selector-active' : ''}" style="font-family: '${html_encode(element)}';" data-font-family="${html_encode(element)}">${html_encode(element)}</p>`; // 👉️ one, two, three, four
});
h += '</div>';
@@ -93,10 +89,10 @@ async function UIWindowFontPicker (options) {
on_close: () => {
resolve(false);
},
onAppend: function (win) {
let active_font = $(win).find('.font-selector-active');
onAppend: function (window) {
let active_font = $(window).find('.font-selector-active');
if ( active_font.length > 0 ) {
window.scrollParentToChild($(win).find('.font-list').get(0), active_font.get(0));
window.scrollParentToChild($(window).find('.font-list').get(0), active_font.get(0));
}
},
window_class: 'window-login',
-2
View File
@@ -26,7 +26,6 @@ import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedirect.js';
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
import { signupUtmQuery } from '../helpers/signupUtm.js';
async function UIWindowLogin (options) {
options = options ?? {};
@@ -223,7 +222,6 @@ async function UIWindowLogin (options) {
if ( window.signup_bonus_code ) {
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
}
url += signupUtmQuery(window.signup_utm);
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
if ( window.openerOrigin ) {
-3
View File
@@ -29,7 +29,6 @@ import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/authRedire
import { authLogoHeader, wireAuthLogoHeader } from '../helpers/authLogoHeader.js';
import { isAttestedOrigin } from '../helpers/attestedOrigin.js';
import { bonusRequirementsKey, checkSignupBonusCode } from '../helpers/signupBonusCode.js';
import { signupUtmQuery } from '../helpers/signupUtm.js';
// What a checked bonus code grants, above the form (and the provider buttons).
function renderBonusNotice(el_window, result) {
@@ -325,7 +324,6 @@ function UIWindowSignup(options) {
if (window.signup_bonus_code) {
url += `&bonusCode=${encodeURIComponent(window.signup_bonus_code)}`;
}
url += signupUtmQuery(window.signup_utm);
if (
window.embedded_in_popup &&
window.url_query_params?.get('msg_id')
@@ -562,7 +560,6 @@ function UIWindowSignup(options) {
// Include captcha in request only if required
const requestData = {
...window.signup_utm,
username: username,
email: email,
password: password,
-72
View File
@@ -4421,47 +4421,6 @@ body.myapps-reordering .myapps-tile {
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
/* The row's empty space is the bar's, so a click there can open the
path for typing (see openPathEditor). */
flex: 1 1 auto;
align-self: stretch;
cursor: text;
}
/* Keeps a click target past the last crumb when a deep path fills the row. */
.dashboard-section-files .header .path-breadcrumbs:not(:empty)::after {
content: '';
flex: 0 0 24px;
align-self: stretch;
}
.dashboard-section-files .header .path-editor {
display: none;
flex: 1 1 auto;
min-width: 0;
height: 28px;
/* Clear of the action buttons by their own 10px gap */
margin: 0 10px;
padding: 0 8px;
box-sizing: border-box;
font-size: 13px;
border: 1px solid var(--select-color);
border-radius: 6px;
background: var(--dashboard-input-background);
color: var(--dashboard-text);
outline: none;
}
.dashboard-section-files .header .path-editor[aria-invalid="true"] {
border-color: var(--dashboard-danger-text);
}
.dashboard-section-files .header .path.path-editing .path-editor {
display: block;
}
.dashboard-section-files .header .path.path-editing .path-breadcrumbs {
display: none;
}
.dashboard-section-files .header .path-breadcrumbs::-webkit-scrollbar {
@@ -6137,26 +6096,6 @@ body.myapps-reordering .myapps-tile {
border-bottom: 1px solid var(--dashboard-border);
}
/* Sized as a crumb is, so the header doesn't jump as the field opens */
.dashboard-section-files .header .path-editor {
order: 1;
flex: 0 1 100%;
margin: 0 0 8px 0;
height: auto;
padding: 3px 8px;
line-height: inherit;
}
/* The crumbs' divider, kept while the field stands in for them */
.dashboard-section-files .header .path.path-editing::after {
content: '';
order: 1;
flex: 0 1 100%;
height: 1px;
margin-bottom: 8px;
background: var(--dashboard-border);
}
.dashboard-section-files .header .path-nav-buttons {
order: 2;
border-right: none;
@@ -6341,17 +6280,6 @@ body.myapps-reordering .myapps-tile {
min-height: 40px;
}
.dashboard-section-files .header .path-editor {
margin-left: 50px;
height: 40px;
/* Under 16px, iOS zooms the page in on focus. */
font-size: 16px;
}
.dashboard-section-files .header .path.path-editing::after {
margin-left: 50px;
}
/* Finger-sized crumbs, in that row */
@media (pointer: coarse) {
.dashboard-section-files .header .path .dirname {
-79
View File
@@ -1,79 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Campaign `utm_*` params from the page a visitor landed on, sent with
* whichever signup they end up making. Kept for the tab's session so a reload
* or in-app navigation that drops the query doesn't lose them. The backend
* re-checks every value.
*/
export const SIGNUP_UTM_PARAMS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
const STORAGE_KEY = 'signup_utm';
const MAX_LENGTH = 255;
/**
* The `utm_*` values in `params` (URLSearchParams or a plain object) as
* `{ utm_source: ... }`, or null when there are none.
*/
export function readSignupUtm(params) {
if (!params || typeof params !== 'object') return null;
const get =
typeof params.get === 'function'
? (key) => params.get(key)
: (key) => params[key];
const utm = {};
for (const key of SIGNUP_UTM_PARAMS) {
const value = get(key);
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (trimmed && trimmed.length <= MAX_LENGTH) utm[key] = trimmed;
}
return Object.keys(utm).length > 0 ? utm : null;
}
/**
* The page's utm params, saved to `storage` for later loads; with none on the
* page, the ones saved earlier this session.
*/
export function rememberSignupUtm(params, storage) {
const fromPage = readSignupUtm(params);
try {
if (fromPage) {
storage?.setItem(STORAGE_KEY, JSON.stringify(fromPage));
return fromPage;
}
const saved = storage?.getItem(STORAGE_KEY);
return saved ? readSignupUtm(JSON.parse(saved)) : null;
} catch {
return fromPage;
}
}
/** `utm` as `&key=value` pairs to append to a query string, or ''. */
export function signupUtmQuery(utm) {
return utm ? `&${new URLSearchParams(utm).toString()}` : '';
}
-110
View File
@@ -1,110 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import {
readSignupUtm,
rememberSignupUtm,
signupUtmQuery,
} from './signupUtm.js';
const query = (search) => new URLSearchParams(search);
const memoryStorage = () => {
const items = new Map();
return {
getItem: (k) => (items.has(k) ? items.get(k) : null),
setItem: (k, v) => items.set(k, String(v)),
};
};
describe('readSignupUtm', () => {
it('reads the known utm params from a query', () => {
expect(
readSignupUtm(
query('?utm_source=ads&utm_campaign=launch&utm_id=x&ref=y'),
),
).toEqual({ utm_source: 'ads', utm_campaign: 'launch' });
});
it('drops blank and oversized values', () => {
expect(
readSignupUtm(
query(`?utm_source=%20&utm_term=${'x'.repeat(256)}`),
),
).toBeNull();
});
it('reads a plain object and ignores non-strings', () => {
expect(readSignupUtm({ utm_source: 'ads', utm_medium: 3 })).toEqual({
utm_source: 'ads',
});
expect(readSignupUtm(undefined)).toBeNull();
});
});
describe('rememberSignupUtm', () => {
it('saves the page params and returns them on a later load without any', () => {
const storage = memoryStorage();
expect(rememberSignupUtm(query('?utm_source=ads'), storage)).toEqual({
utm_source: 'ads',
});
expect(rememberSignupUtm(query(''), storage)).toEqual({
utm_source: 'ads',
});
});
it('lets new page params replace the saved ones', () => {
const storage = memoryStorage();
rememberSignupUtm(query('?utm_source=ads'), storage);
expect(
rememberSignupUtm(query('?utm_source=newsletter'), storage),
).toEqual({ utm_source: 'newsletter' });
});
it('still returns the page params when storage throws', () => {
const storage = {
getItem: () => {
throw new Error('blocked');
},
setItem: () => {
throw new Error('blocked');
},
};
expect(rememberSignupUtm(query('?utm_source=ads'), storage)).toEqual({
utm_source: 'ads',
});
expect(rememberSignupUtm(query(''), storage)).toBeNull();
});
it('ignores a corrupted saved value', () => {
const storage = memoryStorage();
storage.setItem('signup_utm', '{not json');
expect(rememberSignupUtm(query(''), storage)).toBeNull();
});
});
describe('signupUtmQuery', () => {
it('encodes utm as extra query pairs', () => {
expect(signupUtmQuery({ utm_source: 'a b', utm_campaign: 'x&y' })).toBe(
'&utm_source=a+b&utm_campaign=x%26y',
);
expect(signupUtmQuery(null)).toBe('');
});
});
-1
View File
@@ -283,7 +283,6 @@ const en = {
items_selected: '{{items}} selected',
folder_empty: 'No files in this directory.',
folder_open_failed: 'This folder couldn\'t be opened.',
path_not_found: 'Couldn\'t find "%%". Check the path and try again.',
reserved_name_not_allowed: 'The name "{{name}}" is not allowed, because it is a reserved name. Please choose another name.',
upload_some_failed: 'Some uploads failed:',
upload_to_trash_not_allowed: 'Uploading to trash is not allowed!',
-9
View File
@@ -67,7 +67,6 @@ import { installAppIconFallback } from './helpers/appIcon.js';
import launch_app from './helpers/launchApp.js';
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
import { rememberSignupUtm } from './helpers/signupUtm.js';
import { parse_url_paths } from './helpers/urlPaths.js';
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
import update_mouse_position from './helpers/updateMousePosition.js';
@@ -1414,13 +1413,6 @@ window.initgui = async function (options) {
// GET query params provided
window.url_query_params = new URLSearchParams(window.location.search);
window.signup_bonus_code = readSignupBonusCode(window.url_query_params);
let sessionStore = null;
try {
sessionStore = window.sessionStorage;
} catch {
// storage blocked; utm from this page load still applies
}
window.signup_utm = rememberSignupUtm(window.url_query_params, sessionStore);
// Install device signal helpers; collection is lazy. The fingerprint is
// on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude
@@ -2583,7 +2575,6 @@ window.initgui = async function (options) {
// if this is a popup, show a spinner
let spinner_init_ts = Date.now();
const requestData = {
...window.signup_utm,
referrer: referrer,
is_temp: true,
};
+4 -6
View File
@@ -530,16 +530,14 @@ export class AuthModule extends PuterModule {
/**
* Signs the user out of this app by discarding its auth token. On a
* website, this browser's push subscription for the app is removed too. The
* token is gone at once; the promise resolves once the subscription is.
* website, this browser's push subscription for the app is removed too.
*
* @type {() => Promise<void>}
* @type {() => void}
*/
signOut = async () => {
signOut = () => {
// Started first, so it still holds the token of the user leaving.
const forgetting = puter.notifications?._forgetPush?.(puter.authToken);
void puter.notifications?._forgetPush?.(puter.authToken);
puter.resetAuthToken();
await forgetting;
};
/**
+1 -36
View File
@@ -260,46 +260,11 @@ describe('signOut', () => {
]);
});
it('resolves once push cleanup is done, with the token discarded at once', async () => {
let finish;
const puter = {
authToken: 't',
notifications: {
_forgetPush: vi.fn(
() =>
new Promise((resolve) => {
finish = resolve;
}),
),
},
resetAuthToken: vi.fn(() => {
puter.authToken = null;
}),
};
vi.stubGlobal('puter', puter);
try {
let done = false;
const signingOut = makeAuth()
.signOut()
.then(() => {
done = true;
});
expect(puter.authToken).toBeNull();
await Promise.resolve();
expect(done).toBe(false);
finish();
await signingOut;
expect(done).toBe(true);
} finally {
vi.unstubAllGlobals();
}
});
it('signs out where there is no notifications module', () => {
const puter = { authToken: 't', resetAuthToken: vi.fn() };
vi.stubGlobal('puter', puter);
try {
expect(makeAuth().signOut()).toBeInstanceOf(Promise);
expect(() => makeAuth().signOut()).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
+6 -18
View File
@@ -1419,24 +1419,12 @@ export class UIModule extends EventListener {
};
/**
* @overload
* @param {string} [defaultFont]
* @returns {Promise<{ fontFamily: string } | false | null>}
*/
/**
* @overload
* @param {FontPickerOptions & { default?: string, defaultValue?: string }} [options]
* @returns {Promise<{ fontFamily: string } | false | null>}
*/
/**
* Shows a font picker. Resolves to the chosen font, or to `false` inside a
* Puter app and `null` on a website when the user closes the picker without
* choosing. Accepts either a default font name or an options object.
* `default` and `defaultValue` are legacy aliases for `defaultFont`;
* `defaultValue` wins over `defaultFont`, which wins over `default`.
* Shows a font picker. Resolves to the chosen font. Accepts either a
* default font name or an options object. `default` is a legacy alias for
* `defaultFont`.
*
* @param {string | (FontPickerOptions & { default?: string, defaultValue?: string })} [options]
* @returns {Promise<{ fontFamily: string } | false | null>}
* @param {string | (FontPickerOptions & { default?: string })} [options]
* @returns {Promise<{ fontFamily: string }>}
*/
showFontPicker (options) {
if ( this.messageTarget ) {
@@ -1448,7 +1436,7 @@ export class UIModule extends EventListener {
return new Promise((resolve) => {
const opts = typeof options === 'string' ? { defaultFont: options } : (options ?? {});
const el = document.createElement('puter-font-picker');
const defaultFont = opts.defaultValue || opts.defaultFont || opts.default || 'System UI';
const defaultFont = opts.defaultFont || opts.default || 'System UI';
el.setAttribute('default-font', defaultFont);
el.addEventListener('response', (e) => resolve(e.detail));
document.body.appendChild(el);
@@ -4,14 +4,12 @@ import { webPushSupported, workerFor } from './lib/webPush.js';
* Turns push notifications off for this app. Notifications still arrive live
* while the app is open.
*
* On a website it ends this browser's subscription made with Puter's key (the
* one under `serviceWorker` when given, else any on the site), removes it from
* Puter, and stops this site's pages here from showing notifications
* themselves. `onPush()` stops renewing the subscription until `enable()` is
* called again. Inside the Puter desktop it revokes the app's
* `notifications:push` permission for the user, so its notifications stop going
* out as pushes to any device, and a later `enable()` asks for the permission
* again.
* On a website it ends this browser's subscription, removes it from Puter, and
* stops this site's pages here from showing notifications themselves.
* `onPush()` stops renewing the subscription until `enable()` is called again.
* Inside the Puter desktop it revokes the app's `notifications:push` permission
* for the user, so its notifications stop going out as pushes to any device,
* and a later `enable()` asks for the permission again.
*
* @this {import('./index.js').NotificationsModule}
* @param {{ serviceWorker?: string }} [options]
@@ -23,8 +21,7 @@ export async function disable(options = {}) {
if (puter.env === 'app') return (await puter.ui?.disablePush_?.()) === true;
if (puter.env !== 'web' && puter.env !== 'gui') return false;
if (!webPushSupported()) return false;
const anywhere = puter.env === 'web' && !options.serviceWorker;
return await this._disableWebPush(
anywhere ? null : workerFor(puter.env, options.serviceWorker),
workerFor(puter.env, options.serviceWorker),
);
}
+63 -258
View File
@@ -5,19 +5,15 @@ import { cancel } from './cancel.js';
import { disable } from './disable.js';
import { enable } from './enable.js';
import { enablePush } from './enablePush.js';
import { request } from './lib/api.js';
import {
CLICK_KEEP_MS,
CLICKS_STORE,
HANDLERS_STORE,
TAP_BASE_PREFIX,
forgetOldClicks,
forgetPushState,
hasOptIn,
idbDelete,
idbGet,
idbPut,
setOptIn,
takeClick,
} from './lib/pushStore.js';
import { ReceiptLog } from './lib/receipts.js';
import { toClick } from './lib/shapes.js';
@@ -25,11 +21,9 @@ import {
currentWebPush,
removeWebPush,
syncWebPush,
unsubscribeWebPush,
webPushState,
webPushSupported,
workerFor,
workerScope,
} from './lib/webPush.js';
import { listDevices } from './listDevices.js';
import { notify } from './notify.js';
@@ -54,15 +48,11 @@ export const CLICK_PARAM = 'puter.notification';
/** Where a site's tabs tell each other that a user's opt-in changed. */
const OPT_IN_CHANNEL = 'puter-push-opt-in';
/** Held by a site's tabs while one changes this browser's subscription. */
const PUSH_LOCK = 'puter-push';
/** As long as `push-sw.js` gives a handler. */
const HANDLER_TIMEOUT_MS = 5_000;
/** How long an older `push-sw.js`, which never answers, is waited for. */
const WORKER_SHOW_TIMEOUT_MS = 2_000;
/** How long after load a page a tap opened waits for a `click` handler. */
const UNREAD_TAP_MS = 10_000;
/** Sync failures that are the server's or the user's choice, not a fault. */
const QUIET_SYNC_ERRORS = new Set([
'push_device_removed',
@@ -70,8 +60,6 @@ const QUIET_SYNC_ERRORS = new Set([
'push_revoked',
'push_unavailable',
'push_verification_canceled',
'reauth_required',
'token_auth_failed',
'too_many_requests',
]);
@@ -85,21 +73,6 @@ const warnStorage = (error) => {
);
};
/** Runs `run` alone among the site's tabs, or as is where Web Locks refuse. */
const withPushLock = async (run) => {
let started = false;
const start = () => {
started = true;
return run();
};
try {
return await globalThis.navigator.locks.request(PUSH_LOCK, start);
} catch (error) {
if (started) throw error;
return await start();
}
};
/** Hidden, or in a window without focus. */
const pageAway = () => {
const doc = globalThis.document;
@@ -130,41 +103,20 @@ const withinHandlerTime = async (promise) => {
}
};
/** Where a tap goes: on `base`'s origin, with relative paths read from it. */
export const tapUrl = (url, base) => {
const from = new URL(base);
/**
* Where a tap goes: on this origin, with relative paths read from the worker's
* scope.
*/
export const tapUrl = (url, scope) => {
const base = new URL(scope);
try {
const resolved = new URL(url ?? '', from);
return resolved.origin === from.origin ? resolved.href : from.href;
const resolved = new URL(url ?? '', base);
return resolved.origin === base.origin ? resolved.href : base.href;
} catch {
return from.href;
return base.href;
}
};
/** Where taps resolve: the worker's scope if this page is in it, else `/`. */
const tapBaseFor = (worker, pageUrl) => {
const scope = workerScope(worker);
return {
scope,
base: pageUrl.startsWith(scope) ? scope : new URL('/', scope).href,
};
};
/** The tap base a page saved for `scope`, else `scope`. */
const savedTapBase = async (scope) => {
try {
const base = await idbGet(HANDLERS_STORE, TAP_BASE_PREFIX + scope);
if (
typeof base === 'string' &&
new URL(base).origin === new URL(scope).origin
)
return base;
} catch {
// Nothing saved, or IndexedDB is unavailable.
}
return scope;
};
/**
* Hands the notification to `push-sw.js`, which shows it unless another window
* of the app has focus. Resolves whether the worker answered.
@@ -252,11 +204,6 @@ export class NotificationsModule extends EventListener {
* doesn't register this browser after them.
*/
#stopVerifying = new AbortController();
/** The cleanup after a user change, which `enable()` waits for. */
#userChange = Promise.resolve();
/** The token whose push state sign-out already ended. */
#forgottenToken = null;
#sweptClicks = false;
/** @param {import('../../index.js').Puter} puter */
constructor(puter) {
@@ -283,10 +230,6 @@ export class NotificationsModule extends EventListener {
]) {
methods[name] = methods[name].bind(this);
}
if (puter.env === 'web') {
this.#watchUser();
this.#dropUnreadTap();
}
}
/**
@@ -388,16 +331,12 @@ export class NotificationsModule extends EventListener {
/**
* `onPush`'s sync on load, once per worker: only for a user who turned push
* on here with `enable()`, else Puter's subscription here ends.
* on here with `enable()`.
*
* @param {{ url: string; scope?: string }} worker
* @internal
*/
_syncPushOnLoad(worker) {
if (!this.#sweptClicks) {
this.#sweptClicks = true;
forgetOldClicks().catch(() => {});
}
if (!this.#syncs.has(worker.url)) {
this.#syncs.set(worker.url, this.#syncIfOptedIn(worker));
}
@@ -423,9 +362,7 @@ export class NotificationsModule extends EventListener {
* @internal
*/
async _enableWebPush(worker) {
await this.#userChange;
this.#useWorker(worker);
this.#listenForOptInChanges();
/** @type {() => void} */
let pending = () => {};
const sent = new Promise((resolve) => {
@@ -437,9 +374,6 @@ export class NotificationsModule extends EventListener {
onPending: pending,
signal,
renewRemoved: true,
interactive: true,
withLock: withPushLock,
onAnnounced: () => this.#recordOptIn(true),
});
try {
// The GUI reads the registered device right after.
@@ -458,6 +392,7 @@ export class NotificationsModule extends EventListener {
);
}
this.#workerRegistered = true;
await this.#recordOptIn(true);
void this.#finishSync(
syncing,
worker,
@@ -467,19 +402,19 @@ export class NotificationsModule extends EventListener {
/**
* `disable()`: ends this browser's subscription and forgets every opt-in
* here. A website without a `worker` ends Puter's wherever it is.
* here.
*
* @param {{ url: string; scope?: string } | null} worker
* @param {{ url: string; scope?: string }} worker
* @returns {Promise<boolean>}
* @internal
*/
async _disableWebPush(worker) {
this.#stopSyncs();
if (this.puter.env === 'web') this.#optInChanged('off');
return await withPushLock(async () => {
if (this.puter.env === 'web') await this.#forgetOptIns();
this.#cancelVerification();
try {
return await removeWebPush(this.puter, { worker });
});
} finally {
if (this.puter.env === 'web') await this.#forgetOptIns();
}
}
/**
@@ -517,83 +452,34 @@ export class NotificationsModule extends EventListener {
}
/**
* Sign-out on a website: forgets every opt-in and tap here, ends Puter's
* subscription in the browser, then removes it with the token of the user
* leaving. Never throws.
* Sign-out on a website: removes this browser's subscription with the token
* of the user leaving, and forgets their opt-in and taps. Never throws.
*
* @param {string | null | undefined} authToken
* @internal
*/
async _forgetPush(authToken) {
if (this.puter.env !== 'web') return;
this.#forgottenToken = authToken ?? null;
this.#stopSyncs();
this.#optInChanged('off');
await withPushLock(async () => {
await this.#forgetOptIns();
if (!webPushSupported()) return;
try {
await (authToken
? removeWebPush(this.puter, { authToken, keepalive: true })
: unsubscribeWebPush(this.puter));
} catch (error) {
console.warn(
'[puter.notifications] could not remove the push subscription at sign-out',
error,
);
this.#syncs.clear();
this.#cancelVerification();
try {
if (authToken && webPushSupported()) {
await removeWebPush(this.puter, {
worker: this.#worker ?? workerFor('web'),
authToken,
});
}
});
}
/** Another user signing in here ends push for the last one, as sign-out. */
#watchUser() {
/** @type {{ token: string; user: Promise<string | null> } | null} */
let last = null;
const note = () => {
const token = this.puter.authToken;
// Signed in again with it, so a later change cleans up again.
if (token && token === this.#forgottenToken)
this.#forgottenToken = null;
if (!token || token === last?.token) return;
const previous = last;
last = { token, user: this.#tokenUser(token) };
if (previous) {
this.#userChange = Promise.all([
this.#userChange,
this.#forgetIfAnotherUser(previous, last),
]);
}
};
note();
this.puter.onAuthStateChanged?.(note);
}
async #forgetIfAnotherUser(previous, current) {
if (previous.token === this.#forgottenToken) return;
const [was, now] = await Promise.all([previous.user, current.user]);
if (was && now && was !== now) await this._forgetPush(previous.token);
}
/** The user `token` names: its `uu` claim, else its cached `/whoami`. */
async #tokenUser(token) {
const claim = this.puter.decodeJwtPayload?.(token)?.uu;
const named =
typeof claim === 'string'
? this.puter
.decodeCompressedAppID?.(claim)
?.replace(/^app-/, '')
: null;
if (named) return named;
// `setAuthToken` starts that lookup right after telling its listeners.
await null;
if (this.puter.authToken !== token) return null;
const user = await this.puter.whoamiCache_;
return typeof user?.uuid === 'string' ? user.uuid : null;
} catch (error) {
console.warn(
'[puter.notifications] could not remove the push subscription at sign-out',
error,
);
}
await this.#forgetOptIns();
}
#useWorker(worker) {
this.#worker = worker;
if (this.puter.env === 'web') void this.#saveTapBase(worker);
void (async () => {
try {
const registration =
@@ -608,18 +494,6 @@ export class NotificationsModule extends EventListener {
})();
}
async #saveTapBase(worker) {
try {
const { scope, base } = tapBaseFor(
worker,
globalThis.location.href,
);
await idbPut(HANDLERS_STORE, TAP_BASE_PREFIX + scope, base);
} catch {
// Taps then open the worker's scope.
}
}
/** Whether this page shows a browser notification itself while it is away. */
#showsOwn() {
return (
@@ -636,14 +510,8 @@ export class NotificationsModule extends EventListener {
const known =
this.puter.whoami ?? (await this.puter.whoamiCache_) ?? null;
if (typeof known?.uuid === 'string') return known.uuid;
try {
const user = await request(this.puter, 'GET', '/whoami', null, {
interactiveReauth: false,
});
return typeof user?.uuid === 'string' ? user.uuid : null;
} catch {
return null;
}
const user = await this.puter.auth?.whoami?.();
return typeof user?.uuid === 'string' ? user.uuid : null;
}
async #recordOptIn(on) {
@@ -673,30 +541,28 @@ export class NotificationsModule extends EventListener {
this.#optInVersion++;
}
/** The user's opt-in, read into `#optedIn`; null when it can't be read. */
/** The signed-in user's opt-in, read from IndexedDB into `#optedIn`. */
async #readOptIn() {
this.#listenForOptInChanges();
const version = this.#optInVersion;
/** @type {boolean | null} */
let on = false;
try {
if (!needsSignIn(this.puter)) {
const uuid = await this.#userUuid();
on = uuid ? await hasOptIn(uuid) : null;
}
const uuid = needsSignIn(this.puter)
? null
: await this.#userUuid();
on = !!uuid && (await hasOptIn(uuid));
} catch (error) {
warnStorage(error);
on = null;
}
if (version === this.#optInVersion) this.#optedIn = on === true;
if (version === this.#optInVersion) this.#optedIn = on;
return on;
}
/** Other tabs re-read the opt-in; `off` also stops their syncs. */
#optInChanged(kind = 'changed') {
/** Other tabs of the site re-read the opt-in this one just wrote. */
#optInChanged() {
this.#listenForOptInChanges();
try {
this.#optInChannel?.postMessage(kind);
this.#optInChannel?.postMessage('changed');
} catch {
// A closed channel; the other tabs read it on their next sync.
}
@@ -709,40 +575,21 @@ export class NotificationsModule extends EventListener {
)
return;
this.#optInChannel = new BroadcastChannel(OPT_IN_CHANNEL);
this.#optInChannel.onmessage = (event) => {
if (event.data === 'off') this.#stopSyncs();
void this.#readOptIn();
};
this.#optInChannel.onmessage = () => void this.#readOptIn();
}
async #syncIfOptedIn(worker) {
const optedIn = await this.#readOptIn();
if (optedIn === false) await this.#endUnwanted();
if (!optedIn) return;
if (!(await this.#readOptIn())) return;
await this.#finishSync(
syncWebPush(this.puter, {
worker,
signal: this.#stopVerifying.signal,
stillWanted: async () => (await this.#readOptIn()) === true,
withLock: withPushLock,
}),
worker,
'could not keep the push subscription current',
);
}
/** Ends a subscription left here without an opt-in, in the browser only. */
async #endUnwanted() {
await withPushLock(async () => {
if ((await this.#readOptIn()) !== false) return;
try {
await unsubscribeWebPush(this.puter);
} catch {
// The next load tries again.
}
});
}
/** Settles a sync nobody waits on; quiet about the expected failures. */
async #finishSync(syncing, worker, what) {
try {
@@ -778,11 +625,9 @@ export class NotificationsModule extends EventListener {
await this.#recordOptIn(false);
}
/** Ends this page's syncs in flight; the next `onPush` syncs again. */
#stopSyncs() {
#cancelVerification() {
this.#stopVerifying.abort();
this.#stopVerifying = new AbortController();
this.#syncs.clear();
}
/**
@@ -927,7 +772,7 @@ export class NotificationsModule extends EventListener {
uid: notification.uid,
target: sensitive ? null : notification.target,
data: sensitive ? null : notification.data,
url: tapUrl(url, await savedTapBase(scope)),
url: tapUrl(url, scope),
},
},
};
@@ -952,35 +797,21 @@ export class NotificationsModule extends EventListener {
if (event.data?.type !== WORKER_CLICK_MESSAGE) return;
const click = toClick(event.data.notification);
if (!click || !this._hasListeners('click')) return;
const take = () => {
if (event.data.id !== undefined) {
event.source?.postMessage?.({
type: WORKER_CLICK_ACK_MESSAGE,
id: event.data.id,
});
}
this.emit('click', click);
};
if (event.data.kept !== true) {
take();
return;
if (event.data.id !== undefined) {
event.source?.postMessage?.({
type: WORKER_CLICK_ACK_MESSAGE,
id: event.data.id,
});
}
// A tap the worker kept runs only if this page takes it first.
void (async () => {
let claimed = false;
try {
claimed = !!(await takeClick(click.uid, event.data.id));
} catch {
// Left to the worker, which opens it when nobody acks.
}
if (claimed) take();
})();
this.emit('click', click);
});
container?.startMessages?.();
const uid = this.#tapInUrl();
const url = globalThis.location
? new URL(globalThis.location.href)
: null;
const uid = url?.searchParams.get(CLICK_PARAM);
if (!uid) return;
const url = new URL(globalThis.location.href);
url.searchParams.delete(CLICK_PARAM);
globalThis.history?.replaceState?.(
globalThis.history.state,
@@ -989,11 +820,8 @@ export class NotificationsModule extends EventListener {
);
void (async () => {
try {
const kept = await takeClick(uid);
const click =
kept?.at > Date.now() - CLICK_KEEP_MS
? toClick(kept)
: null;
const click = toClick(await idbGet(CLICKS_STORE, uid));
await idbDelete(CLICKS_STORE, uid);
if (click) firstHandler(click);
} catch (error) {
console.warn(
@@ -1003,29 +831,6 @@ export class NotificationsModule extends EventListener {
}
})();
}
/** The tap this page was opened for, from its URL. */
#tapInUrl() {
try {
return new URL(globalThis.location.href).searchParams.get(
CLICK_PARAM,
);
} catch {
return null;
}
}
/** A tap that opened this page is dropped when no `click` handler reads it. */
#dropUnreadTap() {
const uid = this.#tapInUrl();
if (!uid) return;
const drop = () =>
setTimeout(() => {
if (!this.#listening) takeClick(uid).catch(() => {});
}, UNREAD_TAP_MS);
if (globalThis.document?.readyState === 'complete') drop();
else globalThis.addEventListener?.('load', drop, { once: true });
}
}
/**
@@ -4,50 +4,29 @@ import { PuterJSError } from '../../../lib/PuterJSError.js';
const UNAVAILABLE_MESSAGE =
"Push notifications aren't available on this deployment";
/** A request the browser still sends after the page unloads; no sign-in. */
const sendKeepalive = (puter, method, route, body, authToken) => {
const token = authToken ?? puter.authToken;
return fetch(`${puter.APIOrigin}${route}`, {
method,
keepalive: true,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
};
/**
* One `/push` call; the server's `{ message, code }` passes through. A 404 the
* device routes didn't answer themselves becomes `push_unavailable`. With
* `authToken`, that token is sent instead of the live one, and a rejected one
* opens no sign-in. `interactiveReauth: false` keeps a rejected live token from
* opening one too. `keepalive` sends it even if the page unloads meanwhile,
* where the browser allows it.
* opening one too.
*/
export async function request(
puter,
method,
route,
body,
{ authToken, interactiveReauth = true, keepalive = false } = {},
{ authToken, interactiveReauth = true } = {},
) {
const send = () =>
fetchUrl(`${puter.APIOrigin}${route}`, {
method,
...(authToken
? { authToken, interactiveReauth: false }
: { includePuterAuth: true }),
...(interactiveReauth ? {} : { interactiveReauth: false }),
headers: { 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
});
// Some browsers refuse a keepalive request that needs a CORS preflight.
const response = keepalive
? await sendKeepalive(puter, method, route, body, authToken).catch(send)
: await send();
const response = await fetchUrl(`${puter.APIOrigin}${route}`, {
method,
...(authToken
? { authToken, interactiveReauth: false }
: { includePuterAuth: true }),
...(interactiveReauth ? {} : { interactiveReauth: false }),
headers: { 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
});
const isJson = response.headers
.get('content-type')
@@ -7,10 +7,6 @@ export const CLICKS_STORE = 'clicks';
// stays at the version workers already installed open.
const OPT_IN_PREFIX = 'puter:opt-in:';
export const SERVER_KEY_ENTRY = 'puter:server-key';
/** Where a tap without a URL goes, per worker scope; read by `push-sw.js`. */
export const TAP_BASE_PREFIX = 'puter:tap-base:';
/** How long a kept tap waits for its page, as in `push-sw.js`. */
export const CLICK_KEEP_MS = 5 * 60 * 1000;
const open = (idb) =>
new Promise((resolve, reject) => {
@@ -64,31 +60,3 @@ export const forgetPushState = async (idb = globalThis.indexedDB) => {
);
await run(idb, CLICKS_STORE, 'readwrite', (s) => s.clear());
};
/** Takes tap `uid` (only the copy posted as `id`, if given) in one transaction. */
export const takeClick = async (uid, id, idb = globalThis.indexedDB) => {
const matches = (kept) => !!kept && (id === undefined || kept.id === id);
const kept = await run(idb, CLICKS_STORE, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if (matches(req.result)) s.delete(uid);
});
return req;
});
return matches(kept) ? kept : null;
};
/** Removes kept taps older than `CLICK_KEEP_MS`. */
export const forgetOldClicks = (idb = globalThis.indexedDB) => {
const cutoff = Date.now() - CLICK_KEEP_MS;
return run(idb, CLICKS_STORE, 'readwrite', (s) => {
const req = s.openCursor();
req.addEventListener('success', () => {
const cursor = req.result;
if (!cursor) return;
if (!(cursor.value?.at > cutoff)) cursor.delete();
cursor.continue();
});
return req;
});
};
@@ -60,10 +60,6 @@ const scriptUrl = (worker) => {
}
};
/** The scope `worker` registers at: its own, else its script's folder. */
export const workerScope = (worker) =>
new URL(worker.scope ?? './', scriptUrl(worker)).href;
const workerConflict = (worker, other) =>
new PuterJSError(
`Another service worker (${other.scriptURL}) runs where ${worker.url} would. ` +
@@ -125,7 +121,7 @@ const siteWorkerAt = async (nav, worker) => {
let scope;
let existing;
try {
scope = workerScope(worker);
scope = new URL('./', scriptUrl(worker)).href;
existing = await nav.serviceWorker.getRegistration(scope);
} catch {
return null;
@@ -224,79 +220,24 @@ const subscribeWith = async (registration, key) => {
};
/** Tells the server about `subscription`; `active` or `pending`. */
const announce = (puter, subscription, label, interactive) =>
request(
puter,
'POST',
'/push/web/subscriptions',
{
subscription: subscriptionBody(subscription),
...(label ? { label } : {}),
},
{ interactiveReauth: interactive },
);
const turnedOff = () =>
new PuterJSError(
'Push notifications were turned off before this browser was confirmed',
'push_verification_canceled',
);
/** The key Puter last subscribed this origin with, or null. */
const storedPuterKey = async () => {
try {
const stored = await idbGet(HANDLERS_STORE, SERVER_KEY_ENTRY);
return typeof stored === 'string' ? fromBase64Url(stored) : null;
} catch {
return null;
}
};
const announce = (puter, subscription, label) =>
request(puter, 'POST', '/push/web/subscriptions', {
subscription: subscriptionBody(subscription),
...(label ? { label } : {}),
});
/** Whether `buffer` is the key Puter last subscribed this origin with. */
const earlierPuterKey = async (buffer) => {
const stored = await storedPuterKey();
return !!stored && sameKey(buffer, stored);
};
/** Puter's key as stored here, else as the server gives it, else null. */
const puterKey = async (puter, authToken) => {
const stored = await storedPuterKey();
if (stored) return stored;
try {
const { publicKey } = await request(
puter,
'GET',
'/push/web/key',
undefined,
{ authToken, interactiveReauth: false },
const stored = await idbGet(HANDLERS_STORE, SERVER_KEY_ENTRY);
return (
typeof stored === 'string' && sameKey(buffer, fromBase64Url(stored))
);
return typeof publicKey === 'string' && publicKey !== ''
? fromBase64Url(publicKey)
: null;
} catch {
return null;
return false;
}
};
/** Subscriptions with Puter's key, at `worker` or anywhere on the origin. */
const puterSubscriptions = async (puter, { worker, authToken, nav }) => {
const registrations = worker
? [await nav.serviceWorker.getRegistration(worker.scope ?? worker.url)]
: await nav.serviceWorker.getRegistrations();
const subscriptions = (
await Promise.all(
registrations.map((r) => r?.pushManager.getSubscription()),
)
).filter(Boolean);
// The GUI's own worker carries Puter's subscription only.
if (subscriptions.length === 0 || puter.env === 'gui') return subscriptions;
const key = await puterKey(puter, authToken);
if (!key) return [];
return subscriptions.filter((subscription) =>
sameKey(subscription.options?.applicationServerKey, key),
);
};
const rememberPuterKey = async (publicKey) => {
try {
await idbPut(HANDLERS_STORE, SERVER_KEY_ENTRY, publicKey);
@@ -324,49 +265,41 @@ const currentSubscription = async (puter, registration, key) => {
return null;
};
/** Announces what `subscribe` gives, then confirms it if the server asks. */
/**
* Announces `subscription` and, for one the server doesn't have yet, waits for
* the confirmation push and verifies it.
*/
const register = async (
puter,
nav,
subscribe,
{ label, onPending, signal, interactive, whileWanted, onAnnounced },
subscription,
{ label, onPending, signal },
) => {
/** @type {ReturnType<typeof nextChallenge> | null} */
let challenge = null;
// Listening first: the confirmation can arrive before the POST returns.
const challenge = nextChallenge(nav, signal);
try {
const { subscription, result } = await whileWanted(async () => {
const subscription = await subscribe();
// Before the POST: the confirmation can arrive before it returns.
challenge = nextChallenge(nav, signal);
const result = await announce(
puter,
subscription,
label,
interactive,
);
if (signal?.aborted) throw turnedOff();
await onAnnounced?.();
return { subscription, result };
});
const result = await announce(puter, subscription, label);
if (result.status === 'active') return result.device;
onPending?.();
const answer = await challenge.promise;
if (answer === null) throw turnedOff();
const { device } = await whileWanted(() =>
request(
puter,
'POST',
'/push/web/subscriptions/verify',
{
subscription: subscriptionBody(subscription),
challenge: answer,
},
{ interactiveReauth: false },
),
if (answer === null) {
throw new PuterJSError(
'Push notifications were turned off before this browser was confirmed',
'push_verification_canceled',
);
}
const { device } = await request(
puter,
'POST',
'/push/web/subscriptions/verify',
{
subscription: subscriptionBody(subscription),
challenge: answer,
},
);
return device;
} finally {
challenge?.cancel();
challenge.cancel();
}
};
@@ -383,20 +316,12 @@ const register = async (
* onPending?: () => void;
* signal?: AbortSignal;
* renewRemoved?: boolean;
* interactive?: boolean;
* stillWanted?: () => boolean | Promise<boolean>;
* withLock?: <T>(run: () => Promise<T>) => Promise<T>;
* onAnnounced?: () => Promise<void>;
* }} options
* `onPending` runs once the server has the subscription and only its
* confirmation is left. Aborting `signal` stops waiting for that
* confirmation, with `push_verification_canceled`. `renewRemoved` replaces a
* subscription the user removed in Puter instead of rejecting with
* `push_device_removed`. `interactive` lets a rejected token open Puter's
* sign-in for the key and the subscription; the confirmation never does.
* Subscribing and confirming each run in `withLock` while `stillWanted`
* holds, else reject with `push_verification_canceled`. `onAnnounced` runs in
* the same lock once the server has the subscription.
* `push_device_removed`.
* @returns {Promise<Record<string, unknown>>} The server's device record.
*/
export async function syncWebPush(
@@ -408,19 +333,9 @@ export async function syncWebPush(
onPending,
signal,
renewRemoved = false,
interactive = false,
stillWanted = () => true,
withLock = (run) => run(),
onAnnounced,
},
) {
const { publicKey } = await request(
puter,
'GET',
'/push/web/key',
undefined,
{ interactiveReauth: interactive },
);
const { publicKey } = await request(puter, 'GET', '/push/web/key');
if (typeof publicKey !== 'string' || publicKey === '') {
throw new PuterJSError(
"Push notifications aren't available on this deployment",
@@ -432,51 +347,24 @@ export async function syncWebPush(
keepSiteWorker: puter.env !== 'gui',
});
/** @type {PushSubscription | null} */
let subscription = null;
const options = {
label,
onPending,
signal,
interactive,
onAnnounced,
whileWanted: (run) =>
withLock(async () => {
if (signal?.aborted || !(await stillWanted()))
throw turnedOff();
return await run();
}),
};
let subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
const options = { label, onPending, signal };
try {
return await register(
puter,
nav,
async () => {
subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
return subscription;
},
options,
);
return await register(puter, nav, subscription, options);
} catch (error) {
const renewable =
RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code) ||
(renewRemoved && error?.code === 'push_device_removed');
if (!renewable || !subscription || signal?.aborted) throw error;
if (!renewable || signal?.aborted) throw error;
// A brand-new subscription the push service doesn't know yet, an
// endpoint another account turned off here, or one the user removed.
const stale = subscription;
return await register(
puter,
nav,
async () => {
await stale.unsubscribe();
return await subscribeWith(registration, key);
},
options,
);
await subscription.unsubscribe();
subscription = await subscribeWith(registration, key);
return await register(puter, nav, subscription, options);
}
}
@@ -501,53 +389,31 @@ export async function webPushState(puter, endpoint) {
}
}
/** Ends Puter's subscriptions in the browser only; resolves their endpoints. */
export async function unsubscribeWebPush(
puter,
{ worker = null, authToken, nav = globalThis.navigator } = {},
) {
const subscriptions = await puterSubscriptions(puter, {
worker,
authToken,
nav,
});
for (const subscription of subscriptions) {
try {
await subscription.unsubscribe();
} catch {
// Already gone, or the browser can't tell.
}
}
return subscriptions.map((subscription) => subscription.endpoint);
}
/** `unsubscribeWebPush`, then the server; resolves whether there was one. */
/**
* Ends this browser's subscription for `worker` and tells the server, with
* `authToken` in place of the live token when given.
*/
export async function removeWebPush(
puter,
{
worker = null,
authToken,
keepalive = false,
nav = globalThis.navigator,
} = {},
{ worker, authToken, nav = globalThis.navigator } = {},
) {
const endpoints = await unsubscribeWebPush(puter, {
worker,
authToken,
nav,
});
await Promise.all(
endpoints.map((endpoint) =>
request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint },
{ authToken, interactiveReauth: false, keepalive },
),
),
const registration = await nav.serviceWorker.getRegistration(
worker.scope ?? worker.url,
);
return endpoints.length > 0;
const subscription = await registration?.pushManager.getSubscription();
if (!subscription) return false;
try {
await request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken },
);
} finally {
await subscription.unsubscribe();
}
return true;
}
/** This browser's subscription for `worker`, or null. */
@@ -9,7 +9,6 @@ vi.mock('../../lib/networkUtils.js', () => ({
const { NotificationsModule } = await import('./index.js');
const { webPushState } = await import('./lib/webPush.js');
const { request } = await import('./lib/api.js');
const { PuterJSError } = await import('../../lib/PuterJSError.js');
const jsonResponse = (body, status = 200) => ({
@@ -1454,94 +1453,3 @@ describe('webPushState', () => {
).resolves.toBe(null);
});
});
describe('request with keepalive', () => {
it('sends through fetch with keepalive and the given token, never through fetchUrl', async () => {
const fetch = vi.fn(async () => jsonResponse({ removed: true }));
vi.stubGlobal('fetch', fetch);
try {
const puter = { APIOrigin: 'https://api.test', authToken: 'live' };
await expect(
request(
puter,
'DELETE',
'/push/web/subscriptions',
{ endpoint: 'e' },
{ authToken: 'leaving', keepalive: true },
),
).resolves.toEqual({ removed: true });
expect(fetch).toHaveBeenCalledWith(
'https://api.test/push/web/subscriptions',
{
method: 'DELETE',
keepalive: true,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer leaving',
},
body: JSON.stringify({ endpoint: 'e' }),
},
);
expect(mockFetchUrl).not.toHaveBeenCalled();
} finally {
vi.unstubAllGlobals();
}
});
it('sends it once more through fetchUrl when the browser refuses keepalive', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('Failed to fetch');
}),
);
mockFetchUrl.mockResolvedValue(jsonResponse({ removed: true }));
try {
await expect(
request(
{ APIOrigin: 'https://api.test', authToken: 'live' },
'DELETE',
'/push/web/subscriptions',
{ endpoint: 'e' },
{
authToken: 'leaving',
interactiveReauth: false,
keepalive: true,
},
),
).resolves.toEqual({ removed: true });
expect(mockFetchUrl).toHaveBeenCalledTimes(1);
expect(mockFetchUrl.mock.calls[0][1]).toMatchObject({
method: 'DELETE',
authToken: 'leaving',
interactiveReauth: false,
});
} finally {
vi.unstubAllGlobals();
}
});
it('passes the server error through', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
jsonResponse({ message: 'no', code: 'token_auth_failed' }, 401),
),
);
try {
const error = await rejects(() =>
request(
{ APIOrigin: 'https://api.test', authToken: 't' },
'DELETE',
'/push/web/subscriptions',
{ endpoint: 'e' },
{ keepalive: true },
),
);
expect(error.code).toBe('token_auth_failed');
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -1,4 +1,5 @@
import { PuterJSError } from '../../lib/PuterJSError.js';
import { needsSignIn } from '../events/lib/signIn.js';
import { webPushSupported, workerFor } from './lib/webPush.js';
/** Same rule the server applies to a notification's `channel`. */
@@ -119,7 +120,12 @@ export async function onPush(key, handler, options = {}) {
worker,
});
if (worker && webPushSupported() && Notification.permission === 'granted') {
if (
worker &&
!needsSignIn(puter) &&
webPushSupported() &&
Notification.permission === 'granted'
) {
// Not awaited: a new subscription waits on a confirmation push.
this._syncPushOnLoad(worker);
}
File diff suppressed because it is too large. Load diff
+24 -80
View File
@@ -21,11 +21,8 @@
const CLICKS = 'clicks';
const HANDLER_TIMEOUT_MS = 5000;
const CLICK_KEEP_MS = 5 * 60 * 1000;
// Browsers allow openWindow only shortly after the click.
const CLICK_ACK_BUDGET_MS = 600;
// Firefox allows openWindow for 1 s after the click; this leaves room for it.
const CLICK_OPEN_BUDGET_MS = 800;
// Saved by the page; the worker's scope is used when there is none.
const TAP_BASE_PREFIX = 'puter:tap-base:';
const DEFAULT_TITLE = 'New notification';
const CHALLENGE_MESSAGE = 'puter-push-challenge';
const CLICK_MESSAGE = 'puter-notification-click';
@@ -147,32 +144,20 @@
return shown;
}
async function tapBase () {
// Taps only ever open this origin; relative paths are read from the worker's scope.
function sameOriginUrl (url) {
const scope = self.registration.scope ?? `${self.location.origin}/`;
try {
const base = await idb(HANDLERS, 'readonly', (s) => s.get(TAP_BASE_PREFIX + scope));
if ( typeof base === 'string' && new URL(base).origin === self.location.origin ) return base;
const parsed = new URL(url ?? '', scope);
return parsed.origin === self.location.origin ? parsed.href : scope;
} catch {
// nothing saved, or IndexedDB is unavailable
}
return scope;
}
// Taps only ever open this origin; relative paths are read from the tap base.
async function sameOriginUrl (url) {
const base = await tapBase();
try {
const parsed = new URL(url ?? '', base);
return parsed.origin === self.location.origin ? parsed.href : base;
} catch {
return base;
return scope;
}
}
async function windows ({ topLevel = false } = {}) {
async function windows () {
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
return all.filter((c) => {
if ( topLevel && c.frameType === 'nested' ) return false;
try {
return new URL(c.url).origin === self.location.origin;
} catch {
@@ -199,7 +184,7 @@
uid: notification.uid,
target: notification.target,
data: notification.data,
url: await sameOriginUrl(url),
url: sameOriginUrl(url),
},
};
try {
@@ -216,7 +201,7 @@
// A page that got a notification live while away; held back while another window of the app has focus.
async function showForPage (message, source) {
for ( const win of await windows({ topLevel: true }) ) {
for ( const win of await windows() ) {
if ( win.focused && win.id !== source?.id ) return false;
}
const options = message.options && typeof message.options === 'object' ? message.options : {};
@@ -242,33 +227,6 @@
});
}
// One readwrite get+delete, so only the page or the worker gets the tap.
async function claimClick (uid, id) {
const matches = (kept) => !!kept && (id === undefined || kept.id === id);
const kept = await idb(CLICKS, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if ( matches(req.result) ) s.delete(uid);
});
return req;
});
return matches(kept) ? kept : null;
}
// Like claimClick, but keeps the copy, without its id, for the page opened next.
async function reclaimClick (uid, id) {
const kept = await idb(CLICKS, 'readwrite', (s) => {
const req = s.get(uid);
req.addEventListener('success', () => {
if ( req.result?.id !== id ) return;
const { id: _, ...copy } = req.result;
s.put({ ...copy, at: Date.now() }, uid);
});
return req;
});
return kept?.id === id ? kept : null;
}
function waitForClickAck (id, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
@@ -283,49 +241,37 @@
});
}
// Kept for the page that claims it: an open one by `id`, or one the tap opens.
async function keepClick (click, id) {
if ( ! click.uid ) return false;
// Kept for the page the tap opens, which reads it from the URL.
async function keepClick (click) {
if ( ! click.uid ) return;
try {
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now(), ...(id ? { id } : {}) }, click.uid));
return true;
await forgetOldClicks();
await idb(CLICKS, 'readwrite', (s) => s.put({ ...click, at: Date.now() }, click.uid));
} catch ( err ) {
console.warn('[puter] could not keep a tap', err);
return false;
}
}
// An open window with a click handler takes the tap; otherwise one opens its URL.
async function routeClick (puter, startedAt = Date.now()) {
const click = { uid: puter.uid, target: puter.target ?? null, data: puter.data ?? null };
const url = await sameOriginUrl(puter.url);
const open = await windows({ topLevel: true });
const url = sameOriginUrl(puter.url);
const open = await windows();
const win = open.find((c) => c.url === url) ?? open.find((c) => c.focused) ?? open[0];
if ( win ) {
const id = `click-${Date.now()}-${++nextClickId}`;
const kept = await keepClick(click, id);
const postedAt = Date.now();
const deadline = Math.min(postedAt + CLICK_ACK_BUDGET_MS, startedAt + CLICK_OPEN_BUDGET_MS);
const acked = waitForClickAck(id, Math.max(0, deadline - postedAt));
win.postMessage({ type: CLICK_MESSAGE, id, notification: click, ...(kept ? { kept: true } : {}) });
const id = `click-${++nextClickId}`;
const acked = waitForClickAck(id, Math.max(0, startedAt + CLICK_ACK_BUDGET_MS - Date.now()));
win.postMessage({ type: CLICK_MESSAGE, id, notification: click });
try {
await win.focus();
} catch {
// focus is refused once the click's window has passed
}
if ( await acked ) {
// A page that took the tap removed it; an older page leaves it.
if ( kept ) await claimClick(click.uid, id).catch(() => null);
return;
}
// Already there: the page may still take its copy; reloading would lose its state.
if ( await acked ) return;
// Already there: reloading would only lose what the user had open.
if ( win.url === url ) return;
// A failed claim counts as the page's, so the tap runs at most once.
if ( kept && ! await reclaimClick(click.uid, id).catch(() => null) ) return;
if ( ! kept ) await keepClick(click);
} else {
await keepClick(click);
}
await keepClick(click);
const opened = new URL(url);
if ( click.uid ) opened.searchParams.set(CLICK_PARAM, click.uid);
if ( win?.navigate ) {
@@ -339,12 +285,10 @@
await self.clients.openWindow(opened.href);
}
const forgetOldClicksQuietly = () => forgetOldClicks().catch(() => {});
self.addEventListener('push', (event) => {
const message = readMessage(event.data);
if ( ! message ) return;
event.waitUntil(Promise.all([showPush(message), forgetOldClicksQuietly()]));
event.waitUntil(showPush(message));
});
self.addEventListener('message', (event) => {
@@ -370,7 +314,7 @@
if ( ! puter || typeof puter !== 'object' ) return;
const startedAt = Date.now();
event.notification.close();
event.waitUntil(Promise.all([routeClick(puter, startedAt), forgetOldClicksQuietly()]));
event.waitUntil(routeClick(puter, startedAt));
});
self.addEventListener('pushsubscriptionchange', (event) => {
+5 -215
View File
@@ -10,14 +10,13 @@ const SOURCE = readFileSync(
);
const ORIGIN = 'https://app.example';
/** Enough of IndexedDB for this worker; logs transactions, fails while `broken`. */
/** Enough of IndexedDB for get/put and a cursor walk. */
const fakeIndexedDB = (stores) => {
const request = (run) => {
const req = { listeners: [] };
req.addEventListener = (_t, fn) => req.listeners.push(fn);
queueMicrotask(() => {
try {
if (stores.broken) throw new Error('IndexedDB is not available');
req.result = run(req);
req.onsuccess?.();
for (const fn of req.listeners) fn();
@@ -33,32 +32,13 @@ const fakeIndexedDB = (stores) => {
return {
get: (key) => request(() => map.get(key)),
put: (value, key) => request(() => void map.set(key, value)),
delete: (key) => request(() => void map.delete(key)),
openCursor: () => {
const keys = [...map.keys()];
const cursorAt = (req, i) =>
i < keys.length
? {
value: map.get(keys[i]),
delete: () => map.delete(keys[i]),
continue: () =>
queueMicrotask(() => {
req.result = cursorAt(req, i + 1);
for (const fn of req.listeners) fn();
}),
}
: null;
return request((req) => cursorAt(req, 0));
},
openCursor: () => request(() => null),
};
};
return {
open: () => {
const req = request(() => ({
transaction: (name, mode) => {
stores.log.push({ name, mode });
return { objectStore: () => objectStore(name) };
},
transaction: (name) => ({ objectStore: () => objectStore(name) }),
close: () => {},
createObjectStore: () => {},
}));
@@ -80,19 +60,12 @@ const windowClient = (url, over = {}) => ({
const loadWorker = ({
handlers = {},
clicks = {},
windows = [],
matchAllDelayMs = 0,
scope: registrationScope = `${ORIGIN}/`,
} = {}) => {
const listeners = {};
const shown = [];
const stores = {
handlers: new Map(Object.entries(handlers)),
clicks: new Map(Object.entries(clicks)),
log: [],
broken: false,
};
const stores = { handlers: new Map(Object.entries(handlers)) };
const scope = {
location: { origin: ORIGIN },
addEventListener: (type, fn) => {
@@ -105,10 +78,7 @@ const loadWorker = ({
}),
},
clients: {
matchAll: vi.fn(async () => {
if (matchAllDelayMs) await wait(matchAllDelayMs);
return windows;
}),
matchAll: vi.fn(async () => windows),
openWindow: vi.fn(async () => null),
},
};
@@ -153,8 +123,6 @@ const loadWorker = ({
};
const pushData = (payload) => ({ json: () => payload });
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const TAP_BASE = 'puter:tap-base:';
describe('push-sw push', () => {
it('leaves a push without a Puter message to the app', async () => {
@@ -293,60 +261,6 @@ describe('push-sw push', () => {
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/app/`);
});
it('opens the site`s root for a folder worker when the page saved it as the tap base', async () => {
const worker = loadWorker({
scope: `${ORIGIN}/puter/`,
handlers: {
[`${TAP_BASE}${ORIGIN}/puter/`]: `${ORIGIN}/`,
chat: '() => ({ url: "chat/1" })',
},
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n2', title: 'Hi', channel: 'chat' } }),
});
expect(worker.shown.map((n) => n.options.data.puter.url)).toEqual([
`${ORIGIN}/`,
`${ORIGIN}/chat/1`,
]);
});
it('keeps to the scope when the saved tap base is on another origin', async () => {
const worker = loadWorker({
scope: `${ORIGIN}/puter/`,
handlers: { [`${TAP_BASE}${ORIGIN}/puter/`]: 'https://elsewhere.example/' },
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
expect(worker.shown[0].options.data.puter.url).toBe(`${ORIGIN}/puter/`);
});
it('removes taps kept longer than five minutes', async () => {
const now = Date.now();
const worker = loadWorker({
clicks: {
old: { uid: 'old', at: now - 6 * 60 * 1000 },
fresh: { uid: 'fresh', at: now },
},
});
await worker.fire('push', {
data: pushData({ puter: { uid: 'n1', title: 'Hi' } }),
});
expect([...worker.stores.clicks.keys()]).toEqual(['fresh']);
});
it('hands a challenge to nested frames too', async () => {
const nested = windowClient(`${ORIGIN}/embed`, { frameType: 'nested' });
const worker = loadWorker({ windows: [nested] });
await worker.fire('push', {
data: pushData({ puter: { type: 'challenge', challenge: 'c.sig' } }),
});
expect(nested.postMessage).toHaveBeenCalledWith({ type: 'puter-push-challenge', challenge: 'c.sig' });
});
it('shows a challenge and hands it to this origin`s windows', async () => {
const mine = windowClient(`${ORIGIN}/`);
const other = windowClient('https://elsewhere.example/');
@@ -380,7 +294,6 @@ describe('push-sw notificationclick', () => {
type: 'puter-notification-click',
id: expect.any(String),
notification: { uid: 'n1', target: '/chat', data: { id: 1 } },
kept: true,
});
expect(open.focus).toHaveBeenCalled();
expect(open.navigate).not.toHaveBeenCalled();
@@ -427,121 +340,6 @@ describe('push-sw notificationclick', () => {
expect(worker.stores.clicks.get('n1')).toMatchObject({ uid: 'n1', target: '/chat' });
});
it('removes the copy an older page acknowledged without taking it', async () => {
const open = windowClient(`${ORIGIN}/chat`);
const worker = loadWorker({ windows: [open] });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }, acks(worker));
expect(worker.stores.clicks.has('n1')).toBe(false);
});
it('skips nested frames and opens a window for the tap', async () => {
const nested = windowClient(`${ORIGIN}/chat`, { frameType: 'nested', focused: true });
const worker = loadWorker({ windows: [nested] });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` }, acks(worker));
expect(nested.postMessage).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
});
it('removes taps kept longer than five minutes', async () => {
const worker = loadWorker({ clicks: { old: { uid: 'old', at: Date.now() - 6 * 60 * 1000 } } });
await worker.tap({ uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` });
expect([...worker.stores.clicks.keys()]).toEqual(['n1']);
});
describe('delivers a tap once', () => {
/** A page that claims the kept tap after `claimAfter` ms and acks `ackAfter` ms after that. */
const slowPage = (worker, ran, { claimAfter, ackAfter = 0 }) => (win, message) => {
setTimeout(() => {
const kept = worker.stores.clicks.get(message.notification.uid);
if (message.kept) {
if (kept?.id !== message.id) return;
worker.stores.clicks.delete(message.notification.uid);
}
ran.push(message.notification.uid);
setTimeout(() => acks(worker)(win, message), ackAfter);
}, claimAfter);
};
/** Handler runs: in the open page, plus one for a page opened or navigated with the tap kept for it. */
const deliveries = (worker, open, ran) => {
const reopened = open.navigate.mock.calls.length + worker.scope.clients.openWindow.mock.calls.length;
return ran.length + (reopened && worker.stores.clicks.has('n1') ? 1 : 0);
};
const TAP = { uid: 'n1', target: '/chat', data: null, url: `${ORIGIN}/chat` };
it('when the page claims it after the worker gave up', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 900 }));
await wait(1_000);
expect(open.navigate).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
expect(deliveries(worker, open, ran)).toBe(1);
});
it('when the page is already at the tap`s URL and answers late', async () => {
const open = windowClient(`${ORIGIN}/chat`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 700 }));
await wait(800);
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
});
it('when IndexedDB fails as the worker takes the tap back', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await worker.tap(TAP, () => {
worker.stores.broken = true;
});
warn.mockRestore();
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
});
it('taking it back and keeping it for the next page in one transaction', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
let posted = 0;
await worker.tap(TAP, () => {
posted = worker.stores.log.length;
});
await wait(10);
const after = worker.stores.log.slice(posted).filter((t) => t.name === 'clicks');
expect(after).toEqual([{ name: 'clicks', mode: 'readwrite' }]);
expect(open.navigate).toHaveBeenCalledWith(`${ORIGIN}/chat?puter.notification=n1`);
expect(worker.stores.clicks.get('n1')).toEqual({
uid: 'n1',
target: '/chat',
data: null,
at: expect.any(Number),
});
});
it('when the page claimed it but its ack came too late', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open] });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 100, ackAfter: 800 }));
await wait(900);
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
expect(worker.scope.clients.openWindow).not.toHaveBeenCalled();
expect(deliveries(worker, open, ran)).toBe(1);
});
it('waiting for the ack from the post, when finding the windows was slow', async () => {
const open = windowClient(`${ORIGIN}/inbox`);
const worker = loadWorker({ windows: [open], matchAllDelayMs: 300 });
const ran = [];
await worker.tap(TAP, slowPage(worker, ran, { claimAfter: 350 }));
expect(ran).toEqual(['n1']);
expect(open.navigate).not.toHaveBeenCalled();
});
});
it('ignores the app`s own notifications', async () => {
const worker = loadWorker();
await worker.fire('notificationclick', { notification: { data: { mine: true }, close: vi.fn() } });
@@ -582,14 +380,6 @@ describe('push-sw notifications a page shows', () => {
expect(worker.shown).toEqual([]);
});
it('shows it while only a nested frame of the app has focus', async () => {
const away = windowClient(`${ORIGIN}/`);
const worker = loadWorker({
windows: [away, windowClient(`${ORIGIN}/embed`, { focused: true, frameType: 'nested' })],
});
expect(await show(worker, away)).toEqual({ shown: true });
});
it('ignores other messages', async () => {
const worker = loadWorker();
await worker.fire('message', { data: { type: 'something-else' } });
@@ -25,13 +25,6 @@
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
// With ?push, a user known from an earlier load keeps push current.
if (new URLSearchParams(location.search).has('push')) {
puter.whoami = { uuid: 'boot-reauth-user' };
puter.notifications.onPush('boot', () => {}, { onError: () => {} });
}
</script>
<script>
const statusEl = document.getElementById('status');
@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>puter-js test fixture: showFontPicker</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; }
button { padding: 8px 12px; margin-right: 8px; font-size: 14px; }
#log { margin-top: 16px; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; min-height: 60px; font-family: monospace; white-space: pre-wrap; }
.ready #status { color: green; }
</style>
</head>
<body>
<h1>puter-js showFontPicker fixture</h1>
<p id="status">Loading puter.js…</p>
<button id="font-string" disabled>Font Picker (string)</button>
<button id="font-default-font" disabled>Font Picker (defaultFont)</button>
<button id="font-default-value" disabled>Font Picker (defaultValue)</button>
<button id="font-verdana" disabled>Font Picker (Verdana)</button>
<div id="log" data-testid="log"></div>
<script>
window.PUTER_ORIGIN = 'http://puter.localhost:4100';
// Must be the real api subdomain: the SDK sends credentialed CORS
// requests, and only the api host answers with
// Access-Control-Allow-Credentials — on the GUI host they all fail.
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
const logEl = document.getElementById('log');
const statusEl = document.getElementById('status');
function log(entry) {
const line = document.createElement('div');
line.textContent = entry;
line.dataset.entry = entry;
logEl.appendChild(line);
}
function ready() {
document.body.classList.add('ready');
statusEl.textContent = `Ready (env=${puter.env})`;
document.querySelectorAll('button').forEach((btn) => { btn.disabled = false; });
}
function waitForPuter() {
if (window.puter && puter.ui) ready();
else setTimeout(waitForPuter, 50);
}
waitForPuter();
async function pickFont(options) {
try {
const font = await puter.ui.showFontPicker(options);
log(font === false ? 'font:cancel' : font ? `font:${font.fontFamily}` : `font:${font}`);
} catch (e) {
log(`font:error:${e?.message ?? e}`);
}
}
document.getElementById('font-string').addEventListener('click', () => pickFont('Georgia'));
document.getElementById('font-default-font').addEventListener('click', () => pickFont({ defaultFont: 'Georgia' }));
document.getElementById('font-default-value').addEventListener('click', () => pickFont({ defaultValue: 'georgia' }));
document.getElementById('font-verdana').addEventListener('click', () => pickFont('Verdana'));
</script>
</body>
</html>
@@ -64,104 +64,6 @@ test.describe('a stale token does not raise sign-in UI at load', () => {
});
}
test.describe('with push support', () => {
// Playwright's WebKit has no PushManager or Notification.
test.skip(
({ browserName }) => browserName !== 'chromium',
'Needs the Push API, which only Chromium has here',
);
test('no popup or dialog on load from a page that keeps push current', async ({
playwright,
}) => {
// Headless shell keeps notifications denied, so use full Chromium.
const browser = await playwright.chromium.launch({
channel: 'chromium',
});
try {
const context = await browser.newContext();
const page = await context.newPage();
await keepsPushCurrent(page, context);
} finally {
await browser.close();
}
});
});
async function keepsPushCurrent(page, context) {
const popups = [];
page.on('popup', (p) => popups.push(p.url()));
const fixtureOrigin = new URL(FIXTURE).origin;
await context.grantPermissions(['notifications'], {
origin: fixtureOrigin,
});
// The opt-in the fixture's user recorded when they turned push on.
await page.addInitScript(() => {
const open = indexedDB.open('puter-push', 1);
open.onupgradeneeded = () => {
open.result.createObjectStore('handlers');
open.result.createObjectStore('clicks');
};
open.onsuccess = () => {
const tx = open.result.transaction('handlers', 'readwrite');
tx.objectStore('handlers').put(
true,
'puter:opt-in:boot-reauth-user',
);
tx.oncomplete = () => open.result.close();
};
});
// The push routes answer the stale token as Puter's API does.
let keyRequests = 0;
const cors = {
'Access-Control-Allow-Origin': fixtureOrigin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE',
};
await page.route('**/push/web/**', (route) => {
if (route.request().method() === 'OPTIONS')
return route.fulfill({ status: 204, headers: cors });
keyRequests++;
return route.fulfill({
status: 401,
headers: cors,
contentType: 'application/json',
body: JSON.stringify({
code: 'token_auth_failed',
message: 'Authentication failed',
}),
});
});
// Boot's own 401s come later, so the sync starts while signed in.
await page.route(/\/(whoami|rao)(\?|$)/, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.continue();
});
await seedStaleToken(page, 'puter.auth.token.v2');
await page.goto(`${FIXTURE}?push`);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const canPush = await page.evaluate(
() =>
'PushManager' in window &&
globalThis.Notification?.permission === 'granted',
);
test.skip(
!canPush,
'This browser has no Push API or no notification permission, so onPush() has no sync to run',
);
await page.waitForFunction(() => !window.puter?.authToken, null, {
timeout: 30_000,
});
await page.waitForTimeout(2000);
expect(keyRequests).toBeGreaterThan(0);
expect(popups).toEqual([]);
await expect(page.locator('puter-dialog')).toHaveCount(0);
}
test('a call the user makes still prompts', async ({ page }) => {
const popupPromise = page.waitForEvent('popup', { timeout: 30_000 });
@@ -1,73 +0,0 @@
import { test, expect } from '@playwright/test';
import { registerTestApp, deleteTestApp, gotoTestApp, FIXTURE_URL } from '../helpers/testApp.js';
const FONT_FIXTURE_URL = FIXTURE_URL.replace(
'menubar-contextmenu.html',
'font-picker.html',
);
test.describe('puter.ui.showFontPicker (env=app)', () => {
for ( const [buttonId, label] of [
['font-string', 'a string argument'],
['font-default-font', '{ defaultFont }'],
['font-default-value', '{ defaultValue }'],
] ) {
test(`preselects the default font with ${label}`, async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FONT_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator(`#${buttonId}`).click();
const dialog = page.locator('.window[data-app="font-picker"]');
await expect(dialog).toBeVisible({ timeout: 15_000 });
// Exactly one preselected entry, and it is the default we sent
// (defaultValue is passed lowercase on purpose: the match is
// case-insensitive).
const active = dialog.locator('.font-selector-active');
await expect(active).toHaveCount(1);
await expect(active).toHaveAttribute('data-font-family', 'Georgia');
await dialog.locator('.select-btn').click();
await expect(appFrame.locator('#log [data-entry="font:Georgia"]')).toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
}
test('scrolls the preselected font into view', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FONT_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#font-verdana').click();
const dialog = page.locator('.window[data-app="font-picker"]');
await expect(dialog).toBeVisible({ timeout: 15_000 });
const active = dialog.locator('.font-selector-active');
await expect(active).toHaveCount(1);
await expect(active).toHaveAttribute('data-font-family', 'Verdana');
await expect(active).toBeInViewport();
} finally {
await deleteTestApp(page, appName);
}
});
test('resolves false when the picker is closed', async ({ page }) => {
const appName = await registerTestApp(page, { fixtureURL: FONT_FIXTURE_URL });
try {
const appFrame = await gotoTestApp(page, appName);
await appFrame.locator('#font-string').click();
const dialog = page.locator('.window[data-app="font-picker"]');
await expect(dialog).toBeVisible({ timeout: 15_000 });
await dialog.locator('.window-close-btn').click();
await expect(dialog).toBeHidden();
await expect(appFrame.locator('#log [data-entry="font:cancel"]')).toBeVisible();
} finally {
await deleteTestApp(page, appName);
}
});
});