mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce24e63b2 | ||
|
|
fb9e7d70c6 | ||
|
|
819e77bb4c | ||
|
|
7355eaa77b | ||
|
|
d55aac1f98 |
No files matched your search
@@ -6,9 +6,16 @@ as the dashboard's Apps tab, without the desktop or dashboard navigation.
|
||||
It supports search, paging, saved folders, and saved ordering. It uses the same
|
||||
installed-app and recommendation data as the dashboard, including the user's
|
||||
removed recommendations. Editing, adding, reordering, renaming, and uninstalling
|
||||
are disabled. Selecting an app opens a new browser tab with no opener; the host
|
||||
page stays open. Normal apps use `/app/<name>` on the GUI origin, and website
|
||||
shortcuts use their external destination.
|
||||
are disabled. Selecting an app opens it in a new browser tab at its own URL, the
|
||||
same way the dashboard does, and the host page stays open. The app's token isn't
|
||||
put in the URL: the app's SDK asks the embed for it once the tab loads, so the
|
||||
embed must still be open then. If the iframe is removed first, the app falls back
|
||||
to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## Session and framing
|
||||
|
||||
@@ -83,7 +90,9 @@ frame labels using the host app's localization system.
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior. `TabApps.js` injects desktop actions. `appsEmbed.js` supplies
|
||||
new-tab launching and loads only the embed entry, its SDK, and shared styles.
|
||||
new-tab launching through `launchAppInTab` in `src/gui/src/helpers/appTabLaunch.js`,
|
||||
which builds the same app URL as `launch_app`, and loads only the embed entry, its
|
||||
SDK, and shared styles.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
No puter.js API or app-list endpoint changes are needed.
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export type EventMap = {
|
||||
model_used: string;
|
||||
service_used: string;
|
||||
intended_service: string;
|
||||
model_details: {
|
||||
model_details?: {
|
||||
id: string;
|
||||
provider: string;
|
||||
input_cost_key: string;
|
||||
|
||||
@@ -70,6 +70,21 @@ describe('app browser embed shell', () => {
|
||||
expect(html).not.toContain('gui(');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, '/dist/apps-embed.min.js'],
|
||||
[
|
||||
{ gui_bundle: 'https://gui-cdn.deployment.test/bundle.min.js' },
|
||||
'https://gui-cdn.deployment.test/apps-embed.min.js',
|
||||
],
|
||||
[
|
||||
{ gui_bundle: '/static/bundle.min.js?v=42' },
|
||||
'/static/apps-embed.min.js?v=42',
|
||||
],
|
||||
])('loads its script from beside the GUI bundle %j', (config, script) => {
|
||||
const html = makeService(config).renderAppBrowserEmbed();
|
||||
expect(html).toContain(`<script defer src="${script}"></script>`);
|
||||
});
|
||||
|
||||
it('escapes configured values without breaking its JSON bootstrap', () => {
|
||||
const sdkUrl = '/sdk.js?value=</script><script>unexpected()</script>';
|
||||
const html = makeService({
|
||||
|
||||
@@ -191,11 +191,28 @@ export class PuterHomepageService extends PuterService {
|
||||
<body>
|
||||
<main id="app-browser" class="dashboard"></main>
|
||||
<script type="application/json" id="app-browser-config">${configJson}</script>
|
||||
<script defer src="/dist/apps-embed.min.js"></script>
|
||||
<script defer src="${encode(this.#appBrowserEmbedScript())}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The embed's script is built beside the GUI bundle, so it is served from
|
||||
* wherever `gui_bundle` is (a CDN in production), with the same query.
|
||||
*/
|
||||
#appBrowserEmbedScript(): string {
|
||||
const base = 'http://relative.invalid';
|
||||
const bundle = new URL(
|
||||
this.config.gui_bundle ?? '/dist/bundle.min.js',
|
||||
base,
|
||||
);
|
||||
const script = new URL('apps-embed.min.js', bundle);
|
||||
script.search = bundle.search;
|
||||
return script.origin === base
|
||||
? `${script.pathname}${script.search}`
|
||||
: script.href;
|
||||
}
|
||||
|
||||
/** `scriptPath` is a same-origin absolute path, like `/ext/push/worker.js`. */
|
||||
registerServiceWorkerScript(scriptPath: string): void {
|
||||
if (
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
MAX_REALTIME_MESSAGE_BYTES,
|
||||
MAX_UPSTREAM_MESSAGE_BYTES,
|
||||
OpenAIRealtimeRelayService,
|
||||
promptMessagesOf,
|
||||
REALTIME_PATH,
|
||||
type RealtimeUsage,
|
||||
type RealtimeTranscriptionUsage,
|
||||
@@ -122,6 +123,65 @@ describe('realtimeUsageRows', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptMessagesOf', () => {
|
||||
it('reads message text, transcripts and function output from items', () => {
|
||||
expect(
|
||||
promptMessagesOf({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'Hi' },
|
||||
{ type: 'output_audio', transcript: 'Spoken' },
|
||||
{ type: 'input_audio', audio: 'AAAA' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: 'assistant', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Spoken' },
|
||||
]);
|
||||
expect(
|
||||
promptMessagesOf({
|
||||
type: 'conversation.item.create',
|
||||
item: { type: 'function_call_output', output: '{"ok":1}' },
|
||||
}),
|
||||
).toEqual([{ role: 'tool', content: '{"ok":1}' }]);
|
||||
});
|
||||
|
||||
it('reads response instructions and out-of-band input', () => {
|
||||
expect(
|
||||
promptMessagesOf({
|
||||
type: 'response.create',
|
||||
response: {
|
||||
instructions: 'Summarize.',
|
||||
input: [
|
||||
{ type: 'item_reference', id: 'item_1' },
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Text' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{ role: 'system', content: 'Summarize.' },
|
||||
{ role: 'user', content: 'Text' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns nothing for audio and other events', () => {
|
||||
expect(
|
||||
promptMessagesOf({ type: 'input_audio_buffer.append', audio: 'x' }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
promptMessagesOf({ type: 'session.update', session: {} }),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('realtimeTranscriptionRows', () => {
|
||||
it('prices token usage by transcription model', () => {
|
||||
const rows = realtimeTranscriptionRows('gpt-4o-mini-transcribe', {
|
||||
@@ -1380,6 +1440,139 @@ describe('OpenAIRealtimeRelayService relay', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('prompt checks', () => {
|
||||
type Check = {
|
||||
completionId: string;
|
||||
allow: boolean;
|
||||
parameters: { model: string; messages: unknown[] };
|
||||
};
|
||||
let checks: Check[];
|
||||
let costs: Record<string, unknown>[];
|
||||
let refuse: (check: Check) => boolean;
|
||||
const validate = (_key: string, data: Check) => {
|
||||
checks.push(structuredClone(data));
|
||||
if (refuse(data)) data.allow = false;
|
||||
};
|
||||
const costCalculated = (_key: string, data: Record<string, unknown>) =>
|
||||
void costs.push(data);
|
||||
|
||||
beforeEach(() => {
|
||||
checks = [];
|
||||
costs = [];
|
||||
refuse = () => false;
|
||||
server.clients.event.on('ai.prompt.validate', validate as never);
|
||||
server.clients.event.on(
|
||||
'ai.prompt.cost-calculated',
|
||||
costCalculated as never,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
server.clients.event.off('ai.prompt.validate', validate as never);
|
||||
server.clients.event.off(
|
||||
'ai.prompt.cost-calculated',
|
||||
costCalculated as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the text sent before each response and logs its cost under the same id', async () => {
|
||||
const c = open(
|
||||
`${relayUrl}?model=gpt-realtime-mini&auth_token=${user.token}`,
|
||||
);
|
||||
await c.waitFor('session.created');
|
||||
c.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'session.update',
|
||||
session: { instructions: 'Be brief.' },
|
||||
}),
|
||||
);
|
||||
c.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'Hello' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
c.ws.send(JSON.stringify({ type: 'response.create' }));
|
||||
await vi.waitFor(() => expect(costs).toHaveLength(1));
|
||||
expect(checks).toEqual([
|
||||
expect.objectContaining({
|
||||
allow: true,
|
||||
parameters: {
|
||||
model: 'gpt-realtime-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: 'Be brief.' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(costs[0]).toMatchObject({
|
||||
completionId: checks[0].completionId,
|
||||
input_tokens: 7,
|
||||
output_tokens: 3,
|
||||
input_ucents: 7 * 60,
|
||||
output_ucents: 3 * 240,
|
||||
total_ucents: 7 * 60 + 3 * 240,
|
||||
model_used: 'gpt-realtime-mini',
|
||||
});
|
||||
|
||||
// A response with no new text is still logged.
|
||||
c.ws.send(JSON.stringify({ type: 'response.create' }));
|
||||
await vi.waitFor(() => expect(costs).toHaveLength(2));
|
||||
expect(checks[1].parameters.messages).toEqual([]);
|
||||
expect(costs[1].completionId).toBe(checks[1].completionId);
|
||||
c.ws.close();
|
||||
});
|
||||
|
||||
it('closes the session when the check refuses the text', async () => {
|
||||
refuse = (check) =>
|
||||
JSON.stringify(check.parameters.messages).includes('refuse me');
|
||||
const c = open(`${relayUrl}?auth_token=${user.token}`);
|
||||
await c.waitFor('session.created');
|
||||
c.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'response.create',
|
||||
response: { instructions: 'refuse me' },
|
||||
}),
|
||||
);
|
||||
expect(await c.waitFor('error')).toMatchObject({
|
||||
error: { code: 'permission_denied' },
|
||||
});
|
||||
expect((await c.closed).code).toBe(1008);
|
||||
expect(costs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('checks pending text on its own once it passes 64 KiB', async () => {
|
||||
const c = open(`${relayUrl}?auth_token=${user.token}`);
|
||||
await c.waitFor('session.created');
|
||||
const item = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
},
|
||||
});
|
||||
c.ws.send(item('a'.repeat(40 * 1024)));
|
||||
c.ws.send(item('b'.repeat(40 * 1024)));
|
||||
c.ws.send(JSON.stringify({ type: 'response.create' }));
|
||||
await vi.waitFor(() => expect(costs).toHaveLength(1));
|
||||
expect(
|
||||
checks.map((check) =>
|
||||
(check.parameters.messages as { content: string }[]).map(
|
||||
(m) => m.content[0],
|
||||
),
|
||||
),
|
||||
).toEqual([['a'], ['b']]);
|
||||
expect(costs[0].completionId).toBe(checks[1].completionId);
|
||||
c.ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not allocate credential limiter buckets for many unique invalid signatures', async () => {
|
||||
const before = upstreamRequests.length;
|
||||
const auth = vi.spyOn(server.services.auth, 'authenticate');
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import express from 'express';
|
||||
import type { AnyTokenPayload } from '../auth/types.js';
|
||||
import type { EventMap } from '../../clients/event/types.js';
|
||||
import WebSocket, { WebSocketServer, type RawData } from 'ws';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import {
|
||||
@@ -201,6 +202,8 @@ const RECONNECT_NOTICE_INTERVAL_MS = 60_000;
|
||||
const TRANSCRIPTION_DONE =
|
||||
'conversation.item.input_audio_transcription.completed';
|
||||
const METERING_DRIVER = 'openai-realtime';
|
||||
// Text awaiting a prompt check; past this it is checked without a response.
|
||||
const MAX_PENDING_PROMPT_CHARS = 64 * 1024;
|
||||
// Provider organization IDs, as OpenAI puts them in some error messages.
|
||||
const ORG_ID = /\borg-[A-Za-z0-9]{8,}/g;
|
||||
|
||||
@@ -477,6 +480,50 @@ const clientEventRefusal = (event: RealtimeEvent): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
type PromptMessage = { role: string; content: string };
|
||||
|
||||
const itemPromptMessages = (item: unknown): PromptMessage[] => {
|
||||
if (!isRecord(item)) return [];
|
||||
if (item.type === 'function_call_output') {
|
||||
return typeof item.output === 'string' && item.output
|
||||
? [{ role: 'tool', content: item.output }]
|
||||
: [];
|
||||
}
|
||||
if (item.type !== 'message' || !Array.isArray(item.content)) return [];
|
||||
const role = typeof item.role === 'string' ? item.role : 'user';
|
||||
return item.content.flatMap((part) => {
|
||||
if (!isRecord(part)) return [];
|
||||
const text = part.text ?? part.transcript;
|
||||
return typeof text === 'string' && text
|
||||
? [{ role, content: text }]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
/** The text a client event adds to the model's context, as chat messages. */
|
||||
export const promptMessagesOf = (event: unknown): PromptMessage[] => {
|
||||
if (!isRecord(event)) return [];
|
||||
if (event.type === 'conversation.item.create') {
|
||||
return itemPromptMessages(event.item);
|
||||
}
|
||||
const config =
|
||||
event.type === 'session.update'
|
||||
? event.session
|
||||
: event.type === 'response.create'
|
||||
? event.response
|
||||
: undefined;
|
||||
if (!isRecord(config)) return [];
|
||||
const messages: PromptMessage[] =
|
||||
typeof config.instructions === 'string' && config.instructions
|
||||
? [{ role: 'system', content: config.instructions }]
|
||||
: [];
|
||||
if (Array.isArray(config.input)) {
|
||||
for (const item of config.input)
|
||||
messages.push(...itemPromptMessages(item));
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
/** Whichever of two pricings of the same usage costs more. */
|
||||
export const costlierRows = (
|
||||
a: UsageInput[],
|
||||
@@ -900,6 +947,14 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
let requested = 0;
|
||||
// Credit holds of responses whose usage isn't recorded yet.
|
||||
const responseHolds = new Map<string, Promise<CreditHold | null>>();
|
||||
// Text sent since the last prompt check, checked when the next
|
||||
// response starts. Each started response maps to the completion id
|
||||
// its check logged, or null if the check refused it.
|
||||
const checkPrompts =
|
||||
this.clients.event.hasListeners('ai.prompt.validate');
|
||||
let promptMessages: PromptMessage[] = [];
|
||||
let promptChars = 0;
|
||||
const responseCompletions = new Map<string, Promise<string | null>>();
|
||||
// Committed input items awaiting usage, each with the transcription
|
||||
// model in force at commit and the credit held for it.
|
||||
const pendingTranscriptions = new Map<
|
||||
@@ -1062,13 +1117,15 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
identity?.type === 'app-under-user'
|
||||
? identity.session
|
||||
: undefined;
|
||||
if (!(
|
||||
sessionUids?.has(sessionUid ?? '') ||
|
||||
sessionUids?.has(parentUid ?? '') ||
|
||||
(tokenUid &&
|
||||
identity?.type === 'access-token' &&
|
||||
identity.token_uid === tokenUid)
|
||||
))
|
||||
if (
|
||||
!(
|
||||
sessionUids?.has(sessionUid ?? '') ||
|
||||
sessionUids?.has(parentUid ?? '') ||
|
||||
(tokenUid &&
|
||||
identity?.type === 'access-token' &&
|
||||
identity.token_uid === tokenUid)
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (token) {
|
||||
kv.del(realtimeAuthKey(token));
|
||||
@@ -1430,6 +1487,82 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
}
|
||||
};
|
||||
|
||||
// Runs the host's prompt check on the pending text, which also logs
|
||||
// it. A refusal closes the session.
|
||||
const checkPrompt = async (): Promise<string | null> => {
|
||||
const completionId = randomUUID().replaceAll('-', '').slice(0, 25);
|
||||
const check: EventMap['ai.prompt.validate'] = {
|
||||
username: actor!.user?.username || '',
|
||||
actor,
|
||||
completionId,
|
||||
allow: true,
|
||||
intended_service: METERING_DRIVER,
|
||||
parameters: { model: billingModel, messages: promptMessages },
|
||||
};
|
||||
promptMessages = [];
|
||||
promptChars = 0;
|
||||
try {
|
||||
await this.clients.event.emitAndWait(
|
||||
'ai.prompt.validate',
|
||||
check,
|
||||
{},
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[realtime] prompt check failed', err);
|
||||
}
|
||||
if (check.allow !== false) return completionId;
|
||||
reject('permission_denied', 'Prompt blocked by policy');
|
||||
return null;
|
||||
};
|
||||
const notePrompt = (event: RealtimeEvent) => {
|
||||
for (const message of promptMessagesOf(event)) {
|
||||
if (
|
||||
promptChars + message.content.length >
|
||||
MAX_PENDING_PROMPT_CHARS
|
||||
)
|
||||
void checkPrompt();
|
||||
promptMessages.push(message);
|
||||
promptChars += message.content.length;
|
||||
}
|
||||
};
|
||||
// Adds a response's cost to the row its prompt check logged.
|
||||
const logResponseCost = async (
|
||||
id: string,
|
||||
rows: UsageInput[],
|
||||
usage: unknown,
|
||||
) => {
|
||||
const completion = responseCompletions.get(id);
|
||||
if (!completion) return;
|
||||
responseCompletions.delete(id);
|
||||
const completionId = await completion;
|
||||
if (!completionId) return;
|
||||
const input = { tokens: 0, ucents: 0 };
|
||||
const output = { tokens: 0, ucents: 0 };
|
||||
for (const row of rows) {
|
||||
const side = row.usageType.endsWith('_output') ? output : input;
|
||||
side.tokens += row.usageAmount;
|
||||
side.ucents += row.costOverride ?? 0;
|
||||
}
|
||||
this.clients.event.emit(
|
||||
'ai.prompt.cost-calculated',
|
||||
{
|
||||
completionId,
|
||||
username: actor!.user?.username || '',
|
||||
usage,
|
||||
input_tokens: input.tokens,
|
||||
output_tokens: output.tokens,
|
||||
input_ucents: input.ucents,
|
||||
output_ucents: output.ucents,
|
||||
total_ucents: input.ucents + output.ucents,
|
||||
costs_currency: 'usd-cents',
|
||||
model_used: billingModel,
|
||||
service_used: 'openai',
|
||||
intended_service: METERING_DRIVER,
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
// One transcription item's credit at `name`'s price and cost factor,
|
||||
// or null when the account can't cover it. Reservations run one at a
|
||||
// time, so a session's own holds don't race each other's balance check.
|
||||
@@ -1565,6 +1698,8 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
if (typeof id !== 'string') break;
|
||||
inFlightIds.add(id);
|
||||
holdResponse(id);
|
||||
if (checkPrompts)
|
||||
responseCompletions.set(id, checkPrompt());
|
||||
if (closed) {
|
||||
upstream?.send(
|
||||
JSON.stringify({
|
||||
@@ -1575,18 +1710,15 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'response.done':
|
||||
case 'response.done': {
|
||||
inFlight = Math.max(0, inFlight - 1);
|
||||
const usage = event.response?.usage ?? undefined;
|
||||
const rows = realtimeUsageRows(billingModel, usage);
|
||||
meter(rows, takeHold(event.response?.id));
|
||||
if (typeof event.response?.id === 'string') {
|
||||
inFlightIds.delete(event.response.id);
|
||||
void logResponseCost(event.response.id, rows, usage);
|
||||
}
|
||||
meter(
|
||||
realtimeUsageRows(
|
||||
billingModel,
|
||||
event.response?.usage ?? undefined,
|
||||
),
|
||||
takeHold(event.response?.id),
|
||||
);
|
||||
if (inFlight === 0) {
|
||||
onIdle?.();
|
||||
if (
|
||||
@@ -1598,6 +1730,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
endUpstream();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'input_audio_buffer.speech_started': {
|
||||
// Detected speech is committed when it stops, from where
|
||||
// the provider says it began.
|
||||
@@ -1839,6 +1972,7 @@ export class OpenAIRealtimeRelayService extends PuterService {
|
||||
pending.shift();
|
||||
pendingBytes -= (next.data as Buffer).length;
|
||||
if (event.type === 'response.create') requested++;
|
||||
if (checkPrompts) notePrompt(event);
|
||||
sendUpstream(next.data, next.isBinary);
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -105,11 +105,13 @@ excludes outstanding holds and accepts fractional positive credit. Admission of
|
||||
new billable work continues to use the atomic hold path, which includes every
|
||||
outstanding reservation.
|
||||
|
||||
Text, transcripts and raw audio are forwarded without new content logging.
|
||||
The relay does not invoke `ai.prompt.validate`. A realtime adapter needs the host
|
||||
listeners' chat payload and denial contracts before that hook can safely apply
|
||||
to instructions and conversation text. Raw audio and generated transcripts need
|
||||
separate policy decisions.
|
||||
When the host listens for `ai.prompt.validate`, each response that starts runs
|
||||
it on the text the client sent since the previous check: session and response
|
||||
instructions, message text and transcripts, and function call output, as chat
|
||||
messages. The response's cost follows as `ai.prompt.cost-calculated` under the
|
||||
same completion id. A refusal closes the session with `permission_denied`; the
|
||||
response may already be streaming by then. Pending text past 64 KiB is checked
|
||||
on its own. Audio is not checked.
|
||||
|
||||
Credential checks run in batches of at most 16 with no overlapping sweeps.
|
||||
Session and access-token revocation events close affected sockets and invalidate
|
||||
|
||||
@@ -256,12 +256,7 @@ export interface IPreludeConfig {
|
||||
* an RCS agent provisioned in the Prelude account to actually use RCS.
|
||||
*/
|
||||
preferredChannel?:
|
||||
| 'sms'
|
||||
| 'rcs'
|
||||
| 'whatsapp'
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1106,7 +1101,10 @@ interface IConfigOptional {
|
||||
builtin_apps: Record<string, string>;
|
||||
/** Force the bundled GUI even in dev. Default: false. */
|
||||
use_bundled_gui: boolean;
|
||||
/** Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. */
|
||||
/**
|
||||
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The app
|
||||
* browser embed loads `apps-embed.min.js` from the same directory.
|
||||
*/
|
||||
gui_bundle: string;
|
||||
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
|
||||
gui_css: string;
|
||||
@@ -1393,7 +1391,8 @@ export interface ServerDrainInfo {
|
||||
}
|
||||
|
||||
export interface WithCostsReporting extends WithLifecycle {
|
||||
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getReportedCosts?: () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[];
|
||||
|
||||
@@ -23,6 +23,8 @@ const kv = {
|
||||
dashboard_app_groups: JSON.stringify([{ id: 'work', name: 'Work', apps: ['app-1', 'app-2'] }]),
|
||||
dashboard_removed_apps: JSON.stringify(['removed']),
|
||||
};
|
||||
// What puter.apps.get returns for an app; set per test.
|
||||
let appRead = name => ({ uid: `uid-${name}`, name, index_url: `http://${name}.puter.localhost:${port}/index` });
|
||||
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'puter-app-browser-'));
|
||||
@@ -57,9 +59,21 @@ beforeAll(async () => {
|
||||
app.get('/cache/last-change-timestamp', (_req, res) => res.json({ timestamp: 0 }));
|
||||
app.post('/drivers/call', (req, res) => {
|
||||
if ( req.body.auth_token !== sessionToken ) return res.sendStatus(401);
|
||||
if ( req.body.interface === 'puter-apps' ) {
|
||||
return res.json({ success: true, result: appRead(req.body.args.id.name) });
|
||||
}
|
||||
res.json({ success: true, result: kv[req.body.args?.key] ?? null });
|
||||
});
|
||||
app.post('/auth/get-user-app-token', (req, res) => {
|
||||
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
|
||||
res.json({ token: `token-for-${req.body.app_uid}` });
|
||||
});
|
||||
app.post('/rao', (_req, res) => res.json({}));
|
||||
app.get('/app/:name', (_req, res) => res.send('<title>Opened app</title>'));
|
||||
// An app's own page, which signs in with the token the embed hands it.
|
||||
app.get('/index', (_req, res) => res.send(`<title>App</title>
|
||||
<script>window.PUTER_ORIGIN = '${origin}'; window.PUTER_API_ORIGIN = '${origin}';</script>
|
||||
<script src="/sdk.js"></script>`));
|
||||
app.get('/host', (_req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0">
|
||||
<iframe title="More apps" src="${origin}/embed/apps" style="border:0;width:100vw;height:100vh"></iframe>
|
||||
</body></html>`));
|
||||
@@ -127,11 +141,21 @@ describe('embedded app browser', () => {
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await frame.locator('.myapps-tile').click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForLoadState();
|
||||
expect(popup.url()).toBe(`${origin}/app/app-104`);
|
||||
await popup.waitForURL(/\/index\?/);
|
||||
const appUrl = new URL(popup.url());
|
||||
expect(appUrl.origin).toBe(`http://app-104.puter.localhost:${port}`);
|
||||
expect(appUrl.searchParams.get('puter.app.name')).toBe('app-104');
|
||||
expect(appUrl.searchParams.get('puter.origin')).toBe(origin);
|
||||
expect(appUrl.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(appUrl.searchParams.get('utm_campaign')).toBe('app_browser_embed');
|
||||
expect(page.url()).toBe(parentUrl);
|
||||
await popup.waitForFunction(() => window.puter?.authToken === 'token-for-uid-app-104');
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
expect(requests.filter(req => req.path === '/drivers/call').every(req => req.body.method === 'get')).toBe(true);
|
||||
// The app's own SDK reports its open too; this is the embed's.
|
||||
const embedOpens = requests.filter(req => req.path === '/rao' && req.auth === `Bearer ${sessionToken}`);
|
||||
expect(embedOpens.map(req => req.body.app_uid)).toEqual(['uid-app-104']);
|
||||
expect(requests.filter(req => req.path === '/drivers/call' && req.body.interface !== 'puter-apps')
|
||||
.every(req => req.body.method === 'get')).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
@@ -139,6 +163,28 @@ describe('embedded app browser', () => {
|
||||
}, 30000,
|
||||
);
|
||||
|
||||
it('opens the desktop landing, cut off from the embed, for an app it can\'t launch itself', async () => {
|
||||
const context = await browser.newContext({ storageState: {
|
||||
cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }],
|
||||
} });
|
||||
const previousRead = appRead;
|
||||
appRead = name => ({ ...previousRead(name), privateAccess: { hasAccess: false } });
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`${origin}/embed/apps`);
|
||||
await page.locator('.myapps-search').fill('App 104');
|
||||
const popupPromise = context.waitForEvent('page');
|
||||
await page.locator('.myapps-tile').click();
|
||||
const popup = await popupPromise;
|
||||
await popup.waitForURL(`${origin}/app/app-104`);
|
||||
expect(await popup.evaluate(() => window.opener)).toBeNull();
|
||||
} finally {
|
||||
appRead = previousRead;
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not start login or load the SDK when the existing session is absent', async () => {
|
||||
const page = await browser.newPage();
|
||||
requests.length = 0;
|
||||
|
||||
@@ -1069,7 +1069,8 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
|
||||
// Handle app tile clicks. External apps carry a target link (their
|
||||
// index_url) and open the app's website directly in a new browser tab
|
||||
// (an external site can't be reliably iframed); everything else
|
||||
// launches the app as a maximized window in this same page.
|
||||
// launches the app in a new tab at its index_url, or as a maximized
|
||||
// window in this same page when the browser refuses the tab.
|
||||
$el_window.on('click', '.myapps-tile', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1116,8 +1117,7 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
|
||||
}
|
||||
// Launching from inside a folder closes it once the app is up —
|
||||
// the folder was the way in, not the destination (iOS shuts it
|
||||
// behind the opening app). Deferred to the launch's settle so the
|
||||
// tile is still there for the window's morph to grow out of.
|
||||
// behind the opening app).
|
||||
const fromFolder = !! this.closest('.myapps-group-panel-grid');
|
||||
const closeFolder = () => {
|
||||
if ( fromFolder ) self._closeGroup($el_window);
|
||||
@@ -1126,38 +1126,42 @@ export function createAppBrowser ({ editable = true, onLaunch, actions = {} } =
|
||||
window.open(targetLink, '_blank', 'noopener,noreferrer');
|
||||
closeFolder();
|
||||
} else if ( appName ) {
|
||||
// One instance per app when launched from here: un-hide a
|
||||
// minimized instance / focus a visible one rather than
|
||||
// launching a duplicate.
|
||||
if ( focusExistingAppWindow(appName) ) {
|
||||
// A second click while the first launch's fetches are still in
|
||||
// flight would open a second tab for the same click intent.
|
||||
if ( self._launchingApps.has(appName) ) return;
|
||||
// The app opens in a new tab at its index_url, carrying the
|
||||
// same query params an in-page window's iframe gets. The tab
|
||||
// is opened now, inside the click, because popup blockers
|
||||
// refuse one opened after the launch's fetches.
|
||||
// Without 'noopener': the app's SDK asks this tab for its
|
||||
// token through the opener, then clears it.
|
||||
const tab = window.open('', '_blank');
|
||||
// Refused, the app opens in-page: one instance per app, so
|
||||
// an open window is surfaced rather than duplicated.
|
||||
if ( !tab && focusExistingAppWindow(appName) ) {
|
||||
closeFolder();
|
||||
return;
|
||||
}
|
||||
// A second click while the first launch's fetches are still in
|
||||
// flight has no window to find yet — swallow it instead of
|
||||
// spawning a duplicate instance.
|
||||
if ( self._launchingApps.has(appName) ) return;
|
||||
self._launchingApps.add(appName);
|
||||
const tile = this;
|
||||
// Acknowledge the click NOW: the icon's half of the open
|
||||
// morph starts immediately, while the app's fetches are
|
||||
// still in flight; the window's half claims it when the
|
||||
// window opens (morph_from_dashboard_tile → see UIWindow),
|
||||
// and settle puts the icon back if it never does (launch
|
||||
// failed, or the morph fell back to the plain fade).
|
||||
beginTileLaunch(tile);
|
||||
launchApp({
|
||||
name: appName,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
})
|
||||
// In-page, the icon's half of the open morph starts now and
|
||||
// the window's half claims it when it opens (see UIWindow).
|
||||
if ( ! tab ) beginTileLaunch(tile);
|
||||
// The launch owns the tab from here, and closes it on failure.
|
||||
launchApp(tab
|
||||
? { name: appName, newTab: tab }
|
||||
: {
|
||||
name: appName,
|
||||
maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
showAlert(i18n('something_went_wrong', [], false));
|
||||
})
|
||||
.finally(() => {
|
||||
self._launchingApps.delete(appName);
|
||||
settleTileLaunch(tile);
|
||||
if ( ! tab ) settleTileLaunch(tile);
|
||||
closeFolder();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ afterEach(() => {
|
||||
$(document).off('keydown.myapps-group');
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function mount (options = {}) {
|
||||
@@ -118,20 +119,38 @@ describe('shared app browser', () => {
|
||||
expect(other._launchingApps).not.toBe(browser._launchingApps);
|
||||
});
|
||||
|
||||
it('keeps dashboard launches routed through desktop actions', async () => {
|
||||
it('opens dashboard launches in a new tab through desktop actions', async () => {
|
||||
const tab = { close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab);
|
||||
const actions = {
|
||||
launchApp: vi.fn(async () => {}),
|
||||
launchApp: vi.fn(async () => ({ launchResult: { launched: true } })),
|
||||
};
|
||||
await mount({ editable: true, onLaunch: undefined, actions });
|
||||
$('.myapps-search').val('editor').trigger('input');
|
||||
$('.myapps-tile[data-app-name="editor"]').trigger('click');
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(actions.launchApp).toHaveBeenCalledWith({ name: 'editor', newTab: tab });
|
||||
await vi.waitFor(() => expect(browser._launchingApps.size).toBe(0));
|
||||
expect($('.window')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('launches in-page when the browser refuses the tab', async () => {
|
||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
const actions = {
|
||||
launchApp: vi.fn(async () => ({ launchResult: { launched: true } })),
|
||||
beginTileLaunch: vi.fn(),
|
||||
settleTileLaunch: vi.fn(),
|
||||
};
|
||||
await mount({ editable: true, onLaunch: undefined, actions });
|
||||
$('.myapps-search').val('editor').trigger('input');
|
||||
$('.myapps-tile[data-app-name="editor"]').trigger('click');
|
||||
const tile = $('.myapps-tile[data-app-name="editor"]').get(0);
|
||||
$(tile).trigger('click');
|
||||
expect(actions.launchApp).toHaveBeenCalledWith({
|
||||
name: 'editor', maximized: true,
|
||||
window_options: { morph_from_dashboard_tile: true },
|
||||
});
|
||||
await vi.waitFor(() => expect(actions.settleTileLaunch).toHaveBeenCalledOnce());
|
||||
expect(actions.beginTileLaunch).toHaveBeenCalledWith(tile);
|
||||
await vi.waitFor(() => expect(actions.settleTileLaunch).toHaveBeenCalledWith(tile));
|
||||
expect(browser._launchingApps.size).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -4394,15 +4394,26 @@ const TabFiles = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async openRow (row) {
|
||||
const isDir = row.getAttribute('data-is_dir') === '1';
|
||||
// A file opens in a new browser tab. The tab is opened now, inside
|
||||
// the user's gesture, because popup blockers refuse one opened after
|
||||
// the awaits below; open_item closes it if the file opens no app.
|
||||
// Without 'noopener': the app asks this tab for its token (see
|
||||
// launch_app). Blocked (e.g. the second file of a multi-select
|
||||
// Enter), the file opens in-page. A file in the Trash opens nothing
|
||||
// (open_item only says why), so it gets no tab to flash open and shut.
|
||||
const target = row.getAttribute('data-shortcut_to_path') || row.getAttribute('data-path') || '';
|
||||
const inTrash = target.startsWith(`${window.trash_path}/`);
|
||||
const newTab = isDir || inTrash ? null : window.open('', '_blank');
|
||||
// Not for ever: the rename settles in rename_file's last step, which
|
||||
// a failure after the rename itself (refreshing websites) never reaches.
|
||||
if ( row._pendingRename ) await Promise.race([row._pendingRename, new Promise(resolve => setTimeout(resolve, 5000))]);
|
||||
if ( row.getAttribute('data-is_dir') === '1' ) {
|
||||
if ( isDir ) {
|
||||
const folderPath = row.getAttribute('data-path');
|
||||
this.pushNavHistory(folderPath);
|
||||
this.renderDirectory(folderPath);
|
||||
} else {
|
||||
open_item({ item: row });
|
||||
open_item({ item: row, ...(newTab ? { newTab } : {}) });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5180,6 +5191,8 @@ const TabFiles = {
|
||||
},
|
||||
// Custom open handler for Dashboard (avoids window_nav_history issues)
|
||||
onOpen: (el) => _this.openRow(el),
|
||||
// Like opening the row, Open With opens the app in a new tab.
|
||||
openWithInNewTab: true,
|
||||
onShowProperties: ({ name, path: item_path, uid }) => {
|
||||
// Dashboard uses a responsive modal instead of the desktop UIWindow.
|
||||
UIItemPropertiesModal({
|
||||
|
||||
@@ -27,6 +27,11 @@ vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/generateFileContextMenu.js', () => ({ default: vi.fn(async () => []) }));
|
||||
// Loaded for its imports' side effects (i18n among them); only opening is stubbed.
|
||||
vi.mock('../../helpers/openItem.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
default: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../helpers/itemIcon.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { default: vi.fn(actual.default) };
|
||||
@@ -42,6 +47,7 @@ window.icons = {};
|
||||
|
||||
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
|
||||
const { default: generateFileContextMenu } = await import('../../helpers/generateFileContextMenu.js');
|
||||
const { default: open_item } = await import('../../helpers/openItem.js');
|
||||
const { default: itemIcon } = await import('../../helpers/itemIcon.js');
|
||||
|
||||
const TRASH = '/alice/Trash';
|
||||
@@ -1253,6 +1259,50 @@ describe('TabFiles opening a folder being renamed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles opening a file', () => {
|
||||
beforeEach(() => {
|
||||
window.trash_path = TRASH;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
open_item.mockClear();
|
||||
});
|
||||
|
||||
it('opens it in a new tab', async () => {
|
||||
const tab = { close: vi.fn() };
|
||||
vi.spyOn(window, 'open').mockReturnValue(tab);
|
||||
mountListing(['notes.txt'], '/alice/Documents');
|
||||
const row = $('.files .row')[0];
|
||||
|
||||
await TabFiles.openRow(row);
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(open_item).toHaveBeenCalledWith({ item: row, newTab: tab });
|
||||
});
|
||||
|
||||
it('opens no tab for a file in the Trash, which only explains why it can\'t open', async () => {
|
||||
vi.spyOn(window, 'open');
|
||||
mountListing(['notes.txt']);
|
||||
const row = $('.files .row')[0];
|
||||
|
||||
await TabFiles.openRow(row);
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(open_item).toHaveBeenCalledWith({ item: row });
|
||||
});
|
||||
|
||||
it('opens a tab for a shortcut in the Trash whose file is not', async () => {
|
||||
vi.spyOn(window, 'open').mockReturnValue({ close: vi.fn() });
|
||||
mountListing(['notes.txt']);
|
||||
const row = $('.files .row')[0];
|
||||
row.setAttribute('data-shortcut_to_path', '/alice/Documents/notes.txt');
|
||||
|
||||
await TabFiles.openRow(row);
|
||||
|
||||
expect(window.open).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.folderMoved', () => {
|
||||
beforeEach(() => {
|
||||
TabFiles.$el_window = $('<div>');
|
||||
|
||||
@@ -23,6 +23,7 @@ import { loadLocale } from './i18n/embedI18n.js';
|
||||
import { createAppBrowser } from './UI/Dashboard/AppBrowser.js';
|
||||
import { appTileLink } from './UI/Dashboard/appLink.js';
|
||||
import { installAppIconFallback } from './helpers/appIcon.js';
|
||||
import { launchAppInTab } from './helpers/appTabLaunch.js';
|
||||
|
||||
window.$ = window.jQuery = jQuery;
|
||||
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
|
||||
@@ -44,6 +45,45 @@ try {
|
||||
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
|
||||
} catch { /* Corrupt preferences must not prevent loading apps. */ }
|
||||
const localeReady = loadLocale(window.locale);
|
||||
// Apps whose launch is still fetching; a second click would open a second tab.
|
||||
const launching = new Set();
|
||||
|
||||
/**
|
||||
* Opens a tile's app in a new tab at its own URL, as the dashboard does.
|
||||
* Website shortcuts, and apps that need the desktop, open their tile link.
|
||||
*/
|
||||
function launchTile ({ appName, targetLink }) {
|
||||
const href = appTileLink({ appName, targetLink }, window.location.origin);
|
||||
if ( targetLink || !appName ) {
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
if ( launching.has(appName) ) return;
|
||||
// Opened inside the click so popup blockers allow it, and without
|
||||
// 'noopener' so the app's SDK can ask this page for its token.
|
||||
const tab = window.open('', '_blank');
|
||||
if ( ! tab ) return;
|
||||
launching.add(appName);
|
||||
launchAppInTab({
|
||||
name: appName,
|
||||
tab,
|
||||
guiOrigin: window.location.origin,
|
||||
apiOrigin: config.apiOrigin,
|
||||
authToken: window.auth_token,
|
||||
locale: window.locale,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to launch ${appName}:`, err);
|
||||
return false;
|
||||
})
|
||||
.then((opened) => {
|
||||
if ( opened || tab.closed ) return;
|
||||
// The desktop's /app/<name> landing handles access and errors.
|
||||
tab.opener = null;
|
||||
tab.location.href = href;
|
||||
})
|
||||
.finally(() => launching.delete(appName));
|
||||
}
|
||||
|
||||
async function mount () {
|
||||
window.auth_token = localStorage.getItem('auth_token_v2');
|
||||
@@ -61,10 +101,7 @@ async function mount () {
|
||||
|
||||
const browser = createAppBrowser({
|
||||
editable: false,
|
||||
onLaunch: app => {
|
||||
const href = appTileLink(app, window.location.origin);
|
||||
if ( href ) window.open(href, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
onLaunch: launchTile,
|
||||
});
|
||||
const $root = $(root);
|
||||
$root.html(`<section class="dashboard-section dashboard-section-apps active">${browser.html()}</section>`);
|
||||
|
||||
@@ -31,6 +31,7 @@ import update_username_in_gui from './helpers/updateUsernameInGui.js';
|
||||
import { notifyItemWatchers } from './helpers/watchItems.js';
|
||||
import { select_uploaded_items } from './helpers/uploadSelection.js';
|
||||
import { templateMenuItem } from './helpers/templateMenuItem.js';
|
||||
import { uuidv4 } from './helpers/uuidv4.js';
|
||||
import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
@@ -259,10 +260,7 @@ window.usage_bar_color = (percent) => {
|
||||
* let id = window.uuidv4(); // Generate a new UUID
|
||||
*
|
||||
*/
|
||||
window.uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
window.uuidv4 = uuidv4;
|
||||
|
||||
/**
|
||||
* Checks if the provided string is a valid email format.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Server calls an app launch makes, kept free of desktop globals for the app browser embed. */
|
||||
|
||||
/**
|
||||
* @typedef {object} AppLaunchServer
|
||||
* @property {string} [apiOrigin]
|
||||
* @property {string} [authToken] - the GUI session token
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches the token the app signs in with.
|
||||
*
|
||||
* @param {{ appUid?: string } & AppLaunchServer} options
|
||||
*/
|
||||
export const fetchUserAppTokenForLaunch = async ({
|
||||
appUid,
|
||||
apiOrigin = window.api_origin,
|
||||
authToken = window.auth_token,
|
||||
} = {}) => {
|
||||
if ( ! appUid ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'missing-app-uid',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiOrigin}/auth/get-user-app-token`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({ app_uid: appUid }),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch ( error ) {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
if ( response.ok && responseBody?.token ) {
|
||||
return {
|
||||
token: responseBody.token,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: null,
|
||||
reason: 'token-request-failed',
|
||||
status: response.status,
|
||||
responseCode: responseBody?.code ?? responseBody?.error?.code,
|
||||
responseMessage: responseBody?.message ?? responseBody?.error?.message,
|
||||
responseBody,
|
||||
attempts: 1,
|
||||
};
|
||||
} catch ( error ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'network-error',
|
||||
error,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Records the open with the server (`/rao`), which keeps the user's recent apps.
|
||||
*
|
||||
* @param {{ appUid: string, socketId?: string } & AppLaunchServer} options
|
||||
*/
|
||||
export const reportAppOpen = ({
|
||||
appUid,
|
||||
socketId,
|
||||
apiOrigin = window.api_origin,
|
||||
authToken = window.auth_token,
|
||||
}) => {
|
||||
$.ajax({
|
||||
url: `${apiOrigin}/rao`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
original_client_socket_id: socketId,
|
||||
app_uid: appUid,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The URL an app is launched at, in a desktop window or its own tab. Kept free
|
||||
* of desktop globals so the app browser embed builds the same URL.
|
||||
*/
|
||||
|
||||
// This can be any trusted URL that won't be used for other apps
|
||||
export const BUILTIN_PREFIX = 'https://builtins.namespaces.puter.com/';
|
||||
|
||||
// Newer IPC dialogs this GUI can answer, comma-separated. The SDK consults
|
||||
// this before posting a message an older GUI has no handler for: such a
|
||||
// message is never replied to, and a reply timeout can't stand in for the
|
||||
// check because legitimate replies only arrive when the user closes the dialog.
|
||||
const GUI_FEATURES = 'feedback-dialog,enable-push,custom-domains,push-status,disable-push';
|
||||
|
||||
/**
|
||||
* Where an app was launched from, sent to the app as `utm_campaign` so its
|
||||
* analytics can tell the GUI's launch surfaces apart.
|
||||
*
|
||||
* @typedef {'desktop' | 'dashboard' | 'app_browser_embed'} LaunchSurface
|
||||
*/
|
||||
|
||||
/**
|
||||
* The app's index URL with the params that name the launch.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {{ uid?: string, uuid?: string, name: string, index_url?: string }} options.app
|
||||
* @param {string} [options.name] - the name the app was launched by
|
||||
* @param {string} options.appInstanceID
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.appDomain
|
||||
* @returns {URL}
|
||||
*/
|
||||
export const appLaunchUrl = ({ app, name = app.name, appInstanceID, guiOrigin, appDomain }) => {
|
||||
let url;
|
||||
if ( ! app.index_url ) {
|
||||
url = new URL(`https://${name}.${appDomain}/index.html`);
|
||||
} else if ( app.index_url.startsWith(BUILTIN_PREFIX) ) {
|
||||
url = new URL(`${guiOrigin}/builtin/${app.index_url.slice(BUILTIN_PREFIX.length)}`);
|
||||
} else {
|
||||
url = new URL(app.index_url);
|
||||
}
|
||||
url.searchParams.append('puter.app_instance_id', appInstanceID);
|
||||
url.searchParams.append('puter.app.id', app.uuid ?? app.uid);
|
||||
url.searchParams.append('puter.app.name', app.name);
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends the params that tell the app where Puter is and how it was
|
||||
* launched.
|
||||
*
|
||||
* @param {URL} url
|
||||
* @param {object} options
|
||||
* @param {string} options.appDomain
|
||||
* @param {string} options.pageUrl - the GUI page launching the app
|
||||
* @param {string} [options.apiOrigin]
|
||||
* @param {Record<string, string>} [options.params] - extra params from the caller
|
||||
* @param {string} options.locale
|
||||
* @param {unknown} [options.args]
|
||||
* @param {LaunchSurface} options.surface
|
||||
*/
|
||||
export const appendLaunchContext = (url, { appDomain, pageUrl, apiOrigin, params, locale, args, surface }) => {
|
||||
url.searchParams.append('puter.domain', appDomain);
|
||||
|
||||
const page = new URL(pageUrl);
|
||||
url.searchParams.append('puter.origin', page.origin);
|
||||
url.searchParams.append('puter.hostname', page.hostname);
|
||||
url.searchParams.append('puter.port', page.port);
|
||||
url.searchParams.append('puter.protocol', page.protocol.slice(0, -1));
|
||||
|
||||
if ( apiOrigin ) {
|
||||
url.searchParams.append('puter.api_origin', apiOrigin);
|
||||
}
|
||||
for ( const property in params ?? {} ) {
|
||||
url.searchParams.append(property, params[property]);
|
||||
}
|
||||
url.searchParams.append('puter.locale', locale);
|
||||
url.searchParams.append('puter.gui_features', GUI_FEATURES);
|
||||
url.searchParams.append('puter.args', JSON.stringify(args ?? {}));
|
||||
url.searchParams.append('utm_source', 'puter.com');
|
||||
url.searchParams.append('utm_medium', 'referral');
|
||||
url.searchParams.append('utm_campaign', surface);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 { appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
|
||||
const base = { appInstanceID: 'instance-1', guiOrigin: 'https://puter.test', appDomain: 'puter.test' };
|
||||
|
||||
describe('appLaunchUrl', () => {
|
||||
it('keeps the index_url\'s own params and names the launch after them', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'notes', index_url: 'https://notes.example.com/?mode=1' } });
|
||||
expect([...url.searchParams]).toEqual([
|
||||
['mode', '1'],
|
||||
['puter.app_instance_id', 'instance-1'],
|
||||
['puter.app.id', 'app-1'],
|
||||
['puter.app.name', 'notes'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('serves a built-in app from the GUI', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'settings', index_url: 'https://builtins.namespaces.puter.com/settings' } });
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://puter.test/builtin/settings');
|
||||
});
|
||||
|
||||
it('falls back to the app\'s subdomain without an index_url', () => {
|
||||
const url = appLaunchUrl({ ...base, app: { uid: 'app-1', name: 'notes' } });
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.puter.test/index.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendLaunchContext', () => {
|
||||
it('tells the app where the GUI and API are', () => {
|
||||
const url = new URL('https://notes.example.com/');
|
||||
appendLaunchContext(url, {
|
||||
appDomain: 'puter.test',
|
||||
pageUrl: 'http://puter.test:4100/embed/apps',
|
||||
apiOrigin: 'http://api.puter.test:4100',
|
||||
params: { extra: 'x' },
|
||||
locale: 'en',
|
||||
args: { a: 1 },
|
||||
surface: 'dashboard',
|
||||
});
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'puter.domain': 'puter.test',
|
||||
'puter.origin': 'http://puter.test:4100',
|
||||
'puter.hostname': 'puter.test',
|
||||
'puter.port': '4100',
|
||||
'puter.protocol': 'http',
|
||||
'puter.api_origin': 'http://api.puter.test:4100',
|
||||
'extra': 'x',
|
||||
'puter.locale': 'en',
|
||||
'puter.args': '{"a":1}',
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': 'dashboard',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 { BUILTIN_PREFIX, appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
import { fetchUserAppTokenForLaunch, reportAppOpen } from './appLaunchApi.js';
|
||||
import { uuidv4 } from './uuidv4.js';
|
||||
|
||||
/**
|
||||
* Launch data for apps opened in their own browser tab: the app's token and
|
||||
* the item it was opened with (whose signed URLs don't expire). Both stay out
|
||||
* of the tab's URL, and so out of its history; the app's SDK asks this tab for
|
||||
* them once it loads (see requestLaunchFromOpener in puter.js).
|
||||
*/
|
||||
|
||||
// Long enough for a slow app to load; after that the app signs in on its own.
|
||||
const OFFER_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* @typedef {object} AppTabLaunch
|
||||
* @property {string} appInstanceID
|
||||
* @property {Window} tab - the tab the app was opened in
|
||||
* @property {string} origin - origin of the URL the tab was sent to
|
||||
* @property {string} token
|
||||
* @property {Record<string, string> | null} item - the opened item's `puter.item.*` params
|
||||
*/
|
||||
|
||||
/** @type {Map<string, AppTabLaunch & { timer: ReturnType<typeof setTimeout> }>} */
|
||||
const offers = new Map();
|
||||
let listening = false;
|
||||
|
||||
const withdraw = (appInstanceID) => {
|
||||
const offer = offers.get(appInstanceID);
|
||||
if ( ! offer ) return;
|
||||
clearTimeout(offer.timer);
|
||||
offers.delete(appInstanceID);
|
||||
};
|
||||
|
||||
const onMessage = (event) => {
|
||||
const data = event.data;
|
||||
if ( data?.msg !== 'puter.requestLaunch' || typeof data.appInstanceID !== 'string' ) return;
|
||||
const offer = offers.get(data.appInstanceID);
|
||||
// Only the tab this launch opened, still showing the app it was opened
|
||||
// for, gets the launch data — and only once.
|
||||
if ( !offer || event.source !== offer.tab || event.origin !== offer.origin ) return;
|
||||
withdraw(data.appInstanceID);
|
||||
offer.tab.postMessage({
|
||||
msg: 'puter.launch',
|
||||
appInstanceID: data.appInstanceID,
|
||||
token: offer.token,
|
||||
item: offer.item,
|
||||
}, offer.origin);
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds an app tab's launch data until the app asks for it from `origin`.
|
||||
*
|
||||
* @param {AppTabLaunch} offer
|
||||
*/
|
||||
export const offerAppTabLaunch = ({ appInstanceID, tab, origin, token, item }) => {
|
||||
if ( ! listening ) {
|
||||
window.addEventListener('message', onMessage);
|
||||
listening = true;
|
||||
}
|
||||
withdraw(appInstanceID);
|
||||
offers.set(appInstanceID, {
|
||||
appInstanceID,
|
||||
tab,
|
||||
origin,
|
||||
token,
|
||||
item,
|
||||
timer: setTimeout(() => withdraw(appInstanceID), OFFER_TTL_MS),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an app in `tab` at its own URL, the way launch_app's `newTab` does,
|
||||
* for pages without the desktop (the app browser embed). Resolves false,
|
||||
* leaving the tab as it is, for a launch that needs the desktop: a built-in
|
||||
* or godmode app, a private app the user can't open, or a token that
|
||||
* couldn't be fetched. Also false when the user closed the tab meanwhile.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.name - the app's name
|
||||
* @param {Window} options.tab - opened by the caller, inside the click and
|
||||
* without `noopener`, so the app can ask it for its token
|
||||
* @param {string} options.guiOrigin
|
||||
* @param {string} options.apiOrigin
|
||||
* @param {string} options.authToken - the GUI session token
|
||||
* @param {string} options.locale
|
||||
* @returns {Promise<boolean>} whether the tab was sent to the app
|
||||
*/
|
||||
export const launchAppInTab = async ({ name, tab, guiOrigin, apiOrigin, authToken, locale }) => {
|
||||
const app = await puter.apps.get(name);
|
||||
if ( app.index_url?.startsWith(BUILTIN_PREFIX)
|
||||
|| app.godmode === true || app.godmode === 1
|
||||
|| app.privateAccess?.hasAccess === false ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appUid = app.uid ?? app.uuid;
|
||||
const { token } = await fetchUserAppTokenForLaunch({ appUid, apiOrigin, authToken });
|
||||
if ( !token || tab.closed ) return false;
|
||||
|
||||
const appInstanceID = uuidv4();
|
||||
const appDomain = new URL(guiOrigin).hostname;
|
||||
const url = appLaunchUrl({ app, name, appInstanceID, guiOrigin, appDomain });
|
||||
appendLaunchContext(url, {
|
||||
appDomain,
|
||||
pageUrl: guiOrigin,
|
||||
apiOrigin,
|
||||
locale,
|
||||
surface: 'app_browser_embed',
|
||||
});
|
||||
|
||||
offerAppTabLaunch({ appInstanceID, tab, origin: url.origin, token, item: null });
|
||||
tab.location.href = url.href;
|
||||
reportAppOpen({ appUid, apiOrigin, authToken });
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { launchAppInTab, offerAppTabLaunch } from './appTabLaunch.js';
|
||||
|
||||
const ORIGIN = 'https://notes.example.com';
|
||||
const ITEM = { 'puter.item.uid': 'file-1', 'puter.item.read_url': 'https://api.puter.test/file?signature=r' };
|
||||
let nextId = 0;
|
||||
|
||||
const offer = () => {
|
||||
const appInstanceID = `instance-${++nextId}`;
|
||||
const tab = { postMessage: vi.fn() };
|
||||
offerAppTabLaunch({ appInstanceID, tab, origin: ORIGIN, token: 'app-token', item: ITEM });
|
||||
return { appInstanceID, tab };
|
||||
};
|
||||
|
||||
const request = ({ appInstanceID, source, origin = ORIGIN }) => {
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puter.requestLaunch', env: 'app-tab', appInstanceID },
|
||||
origin,
|
||||
source,
|
||||
}));
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('offerAppTabLaunch', () => {
|
||||
it('answers the tab it was offered to, at its origin, once', () => {
|
||||
const { appInstanceID, tab } = offer();
|
||||
request({ appInstanceID, source: tab });
|
||||
request({ appInstanceID, source: tab });
|
||||
expect(tab.postMessage).toHaveBeenCalledOnce();
|
||||
expect(tab.postMessage).toHaveBeenCalledWith({
|
||||
msg: 'puter.launch',
|
||||
appInstanceID,
|
||||
token: 'app-token',
|
||||
item: ITEM,
|
||||
}, ORIGIN);
|
||||
});
|
||||
|
||||
it('ignores another window that knows the instance id', () => {
|
||||
const { appInstanceID, tab } = offer();
|
||||
const other = { postMessage: vi.fn() };
|
||||
request({ appInstanceID, source: other });
|
||||
expect(other.postMessage).not.toHaveBeenCalled();
|
||||
expect(tab.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores the tab once it has navigated to another origin', () => {
|
||||
const { appInstanceID, tab } = offer();
|
||||
request({ appInstanceID, source: tab, origin: 'https://elsewhere.example' });
|
||||
expect(tab.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('withdraws an offer nobody asked for in time', () => {
|
||||
vi.useFakeTimers();
|
||||
const { appInstanceID, tab } = offer();
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
request({ appInstanceID, source: tab });
|
||||
expect(tab.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('launchAppInTab', () => {
|
||||
const app = { uid: 'app-123', name: 'notes', index_url: 'https://notes.example.com/start?mode=1' };
|
||||
const launch = (tab, overrides = {}) => launchAppInTab({
|
||||
name: 'notes',
|
||||
tab,
|
||||
guiOrigin: 'https://puter.test',
|
||||
apiOrigin: 'https://api.puter.test',
|
||||
authToken: 'session',
|
||||
locale: 'fr',
|
||||
...overrides,
|
||||
});
|
||||
const arrange = ({ appRow = app, tokenResponse = { ok: true, json: async () => ({ token: 'app-token' }) } } = {}) => {
|
||||
vi.stubGlobal('puter', { apps: { get: vi.fn(async () => appRow) } });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => tokenResponse));
|
||||
vi.stubGlobal('$', { ajax: vi.fn() });
|
||||
};
|
||||
const newTab = () => ({ location: { href: '' }, postMessage: vi.fn(), closed: false });
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends the tab to the app\'s URL and answers its request for the token', async () => {
|
||||
arrange();
|
||||
const tab = newTab();
|
||||
expect(await launch(tab)).toBe(true);
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.example.com/start');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'mode': '1',
|
||||
'puter.app.id': 'app-123',
|
||||
'puter.app.name': 'notes',
|
||||
'puter.domain': 'puter.test',
|
||||
'puter.origin': 'https://puter.test',
|
||||
'puter.api_origin': 'https://api.puter.test',
|
||||
'puter.locale': 'fr',
|
||||
'puter.args': '{}',
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': 'app_browser_embed',
|
||||
});
|
||||
expect(url.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.puter.test/auth/get-user-app-token', expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'Authorization': 'Bearer session' }),
|
||||
}));
|
||||
expect($.ajax).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.puter.test/rao' }));
|
||||
|
||||
const appInstanceID = url.searchParams.get('puter.app_instance_id');
|
||||
request({ appInstanceID, source: tab, origin: 'https://notes.example.com' });
|
||||
expect(tab.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ msg: 'puter.launch', appInstanceID, token: 'app-token', item: null }),
|
||||
'https://notes.example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a built-in app', { ...app, index_url: 'https://builtins.namespaces.puter.com/settings' }],
|
||||
['a godmode app', { ...app, godmode: 1 }],
|
||||
['a private app the user can\'t open', { ...app, privateAccess: { hasAccess: false } }],
|
||||
])('leaves %s to the desktop', async (_label, appRow) => {
|
||||
arrange({ appRow });
|
||||
const tab = newTab();
|
||||
expect(await launch(tab)).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the tab alone when the token can\'t be fetched', async () => {
|
||||
arrange({ tokenResponse: { ok: false, status: 500, json: async () => ({}) } });
|
||||
const tab = newTab();
|
||||
expect(await launch(tab)).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the launch off when the user closed the tab meanwhile', async () => {
|
||||
arrange();
|
||||
const tab = { ...newTab(), closed: true };
|
||||
expect(await launch(tab)).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Array} options.suggested_apps - Optional pre-loaded suggested apps
|
||||
* @param {string} options.associated_app_name - Optional associated app
|
||||
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
|
||||
* @param {boolean} options.openWithInNewTab - Open the "Open With" apps in a new browser tab instead of an in-page window (used by Dashboard)
|
||||
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
|
||||
@@ -118,7 +119,9 @@ const generate_file_context_menu = async function (options) {
|
||||
// Open With
|
||||
// -------------------------------------------
|
||||
if ( !is_trashed && !is_trash && (options.associated_app_name === null || options.associated_app_name === undefined) ) {
|
||||
const openWithItems = await generateOpenWithItems(el_item, fsentry, options.suggested_apps);
|
||||
const openWithItems = await generateOpenWithItems(el_item, fsentry, options.suggested_apps, {
|
||||
inNewTab: !! options.openWithInNewTab,
|
||||
});
|
||||
menu_items.push({
|
||||
html: i18n('open_with'),
|
||||
items: openWithItems,
|
||||
@@ -504,7 +507,7 @@ const generate_file_context_menu = async function (options) {
|
||||
* @param {Array} suggested_apps - Optional pre-loaded suggested apps
|
||||
* @returns {Promise<Array>} Array of menu items for "Open With" submenu
|
||||
*/
|
||||
async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
async function generateOpenWithItems (el_item, fsentry, suggested_apps, { inNewTab = false } = {}) {
|
||||
let items = [];
|
||||
|
||||
// Try to find suitable apps if not provided
|
||||
@@ -530,15 +533,21 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
icon: `<img src="${html_encode(suggested_app.icon ?? window.icons['app.svg'])}" style="width:16px; height: 16px; margin-bottom: -4px;">`,
|
||||
onClick: async function () {
|
||||
var extension = path.extname($(el_item).attr('data-path')).toLowerCase();
|
||||
if (
|
||||
const asks_to_make_default =
|
||||
! is_default_app(window.user_preferences[`default_apps${extension}`], suggested_app)
|
||||
&&
|
||||
(
|
||||
(!window.user_preferences[`default_apps${extension}`] && index > 0)
|
||||
||
|
||||
(window.user_preferences[`default_apps${extension}`])
|
||||
)
|
||||
) {
|
||||
);
|
||||
// A tab has to be opened while a click still counts as a
|
||||
// user gesture, or the popup blocker refuses it: this one,
|
||||
// or the answer to the prompt below. Refused, the app
|
||||
// opens in-page. Without 'noopener': the app asks this
|
||||
// tab for its token (see launch_app).
|
||||
let newTab = inNewTab && ! asks_to_make_default ? window.open('', '_blank') : null;
|
||||
if ( asks_to_make_default ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: `${i18n('change_always_open_with')} ${html_encode(suggested_app.title)}?`,
|
||||
body_icon: suggested_app.icon,
|
||||
@@ -557,12 +566,14 @@ async function generateOpenWithItems (el_item, fsentry, suggested_apps) {
|
||||
window.user_preferences[`default_apps${extension}`] = suggested_app.uuid;
|
||||
window.mutate_user_preferences(window.user_preferences);
|
||||
}
|
||||
if ( inNewTab ) newTab = window.open('', '_blank');
|
||||
}
|
||||
launch_app({
|
||||
name: suggested_app.name,
|
||||
file_path: $(el_item).attr('data-path'),
|
||||
window_title: $(el_item).attr('data-name'),
|
||||
file_uid: $(el_item).attr('data-uid'),
|
||||
...(newTab ? { newTab } : {}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
@@ -40,14 +40,18 @@ vi.mock('./sharedAccess.js', () => ({
|
||||
|
||||
globalThis.$ = globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => jQuery('<div>').text(value).html();
|
||||
globalThis.def = globalThis.def ?? ((value) => value);
|
||||
globalThis.use = globalThis.use ?? (() => class {});
|
||||
window.feature_flags = {};
|
||||
window.clipboard = [];
|
||||
window.trash_path = '/me/Trash';
|
||||
window.icons = {};
|
||||
|
||||
const { default: generate_file_context_menu } =
|
||||
await import('./generateFileContextMenu.js');
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
|
||||
const sharedRow = () => {
|
||||
const row = document.createElement('div');
|
||||
@@ -84,3 +88,74 @@ it('does not offer it on a share held through a team', async () => {
|
||||
'share_remove_from_shared',
|
||||
);
|
||||
});
|
||||
|
||||
describe('Open With', () => {
|
||||
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
|
||||
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
|
||||
|
||||
/** Clicks the Open With entry for the app at `index`. */
|
||||
const openWith = async (index, options = {}) => {
|
||||
const row = sharedRow();
|
||||
row.setAttribute('data-uid', 'file-1');
|
||||
const items = await generate_file_context_menu({
|
||||
element: row,
|
||||
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt' },
|
||||
suggested_apps: [editor, viewer],
|
||||
...options,
|
||||
});
|
||||
const submenu = items.find((item) => item.html === 'open_with').items;
|
||||
await submenu[index].onClick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.user_preferences = {};
|
||||
window.mutate_user_preferences = vi.fn();
|
||||
vi.spyOn(window, 'open').mockImplementation(() => tab);
|
||||
});
|
||||
|
||||
let tab;
|
||||
beforeEach(() => {
|
||||
tab = { close: vi.fn() };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
launch_app.mockClear();
|
||||
UIAlert.mockReset();
|
||||
});
|
||||
|
||||
it('opens the app in a new tab when asked to', async () => {
|
||||
await openWith(0, { openWithInNewTab: true });
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'editor',
|
||||
file_uid: 'file-1',
|
||||
newTab: tab,
|
||||
}));
|
||||
});
|
||||
|
||||
it('opens the tab after the make-default prompt is answered', async () => {
|
||||
let openedBeforeAnswer = null;
|
||||
UIAlert.mockImplementation(async () => {
|
||||
openedBeforeAnswer = window.open.mock.calls.length > 0;
|
||||
return 'yes';
|
||||
});
|
||||
await openWith(1, { openWithInNewTab: true });
|
||||
expect(openedBeforeAnswer).toBe(false);
|
||||
expect(window.open).toHaveBeenCalledOnce();
|
||||
expect(window.mutate_user_preferences).toHaveBeenCalled();
|
||||
expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({ name: 'viewer', newTab: tab }));
|
||||
});
|
||||
|
||||
it('opens in-page when the popup blocker refuses the tab', async () => {
|
||||
window.open.mockReturnValue(null);
|
||||
await openWith(0, { openWithInNewTab: true });
|
||||
expect(launch_app.mock.calls[0][0]).not.toHaveProperty('newTab');
|
||||
});
|
||||
|
||||
it('opens in-page on the desktop', async () => {
|
||||
await openWith(0);
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(launch_app.mock.calls[0][0]).not.toHaveProperty('newTab');
|
||||
});
|
||||
});
|
||||
+149
-150
@@ -28,6 +28,9 @@ import { confirmUrlFileAccess } from './confirmUrlFileAccess.js';
|
||||
import { mintGodmodeToken, reusableGodmodeToken, trackGodmodeToken } from './godmodeTokens.js';
|
||||
import { withoutNotificationClick } from './notificationTap.js';
|
||||
import { syncBrowserPush } from './pushSync.js';
|
||||
import { offerAppTabLaunch } from './appTabLaunch.js';
|
||||
import { BUILTIN_PREFIX, appLaunchUrl, appendLaunchContext } from './appLaunchUrl.js';
|
||||
import { fetchUserAppTokenForLaunch, reportAppOpen } from './appLaunchApi.js';
|
||||
|
||||
const normalizePrivateAccessDecision = (privateAccess) => {
|
||||
if ( !privateAccess || typeof privateAccess !== 'object' ) {
|
||||
@@ -67,62 +70,26 @@ const endLaunchTransaction = (transaction, outcome) => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserAppTokenForLaunch = async ({ appUid } = {}) => {
|
||||
if ( ! appUid ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'missing-app-uid',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Moves the app to the front of the recent apps and records the open with
|
||||
* the server (`/rao`).
|
||||
*/
|
||||
const record_app_open = (app_info) => {
|
||||
window.launch_apps.recent.unshift(app_info);
|
||||
// dedupe by name, then cap at window.launch_recent_apps_count
|
||||
window.launch_apps.recent = [...new Map(window.launch_apps.recent.map(v => [v.name, v])).values()];
|
||||
window.launch_apps.recent = window.launch_apps.recent.slice(0, window.launch_recent_apps_count);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.api_origin }/auth/get-user-app-token`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${ window.auth_token}`,
|
||||
},
|
||||
body: JSON.stringify({ app_uid: appUid }),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch ( error ) {
|
||||
responseBody = null;
|
||||
}
|
||||
|
||||
if ( response.ok && responseBody?.token ) {
|
||||
return {
|
||||
token: responseBody.token,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
token: null,
|
||||
reason: 'token-request-failed',
|
||||
status: response.status,
|
||||
responseCode: responseBody?.code ?? responseBody?.error?.code,
|
||||
responseMessage: responseBody?.message ?? responseBody?.error?.message,
|
||||
responseBody,
|
||||
attempts: 1,
|
||||
};
|
||||
} catch ( error ) {
|
||||
return {
|
||||
token: null,
|
||||
reason: 'network-error',
|
||||
error,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
reportAppOpen({ appUid: app_info.uid ?? app_info.uuid, socketId: window.socket?.id });
|
||||
};
|
||||
|
||||
/**
|
||||
* Launches an app.
|
||||
*
|
||||
* @param {*} options.name - The name of the app to launch.
|
||||
*/
|
||||
const launch_app = async (options) => {
|
||||
/** The `puter.item.*` params held back from a tab's URL, or null when none. */
|
||||
const heldBackItem = (item_params) => {
|
||||
const item = Object.fromEntries(item_params);
|
||||
return Object.keys(item).length ? item : null;
|
||||
};
|
||||
|
||||
const runLaunch = async (options) => {
|
||||
let transaction;
|
||||
// Ends once the app signals over IPC, i.e. when it can actually be used.
|
||||
// Only apps built on the SDK ever signal, so this one is deliberately
|
||||
@@ -195,6 +162,13 @@ const launch_app = async (options) => {
|
||||
app_info.uuid = app_info.uuid ?? app_info.uid;
|
||||
app_info.uid = app_info.uid ?? app_info.uuid;
|
||||
|
||||
if ( options.newTab && (options.name === 'explorer' || options.name === 'trash'
|
||||
|| app_info.index_url?.startsWith(BUILTIN_PREFIX)) ) {
|
||||
options.newTab.close();
|
||||
options.newTab = undefined;
|
||||
}
|
||||
const newTab = options.newTab;
|
||||
|
||||
// If no `options.name` is provided, use the app name from the app_info
|
||||
options.name = options.name ?? app_info.name;
|
||||
const resolvedAppName = { 'launch.app': options.name ?? 'unknown' };
|
||||
@@ -208,7 +182,7 @@ const launch_app = async (options) => {
|
||||
const fallbackArgs = privateAccessDecision.fallbackArgs ?? {};
|
||||
|
||||
if ( fallbackAppName && privateLaunchRedirectDepth < 1 && fallbackAppName !== options.name ) {
|
||||
const fallbackLaunchOutcome = await launch_app({
|
||||
const fallbackLaunchOutcome = await runLaunch({
|
||||
...options,
|
||||
name: fallbackAppName,
|
||||
args: fallbackArgs,
|
||||
@@ -242,6 +216,8 @@ const launch_app = async (options) => {
|
||||
return fallbackLaunchOutcome ?? { launchResult: redirectedLaunchResult };
|
||||
}
|
||||
|
||||
// The alert shows on this page, which a blank tab in front would hide.
|
||||
newTab?.close();
|
||||
// Unencoded: UIAlert encodes its message itself.
|
||||
const deniedAppTitle = app_info.title ?? app_info.name ?? options.name;
|
||||
await UIAlert(deniedAppTitle
|
||||
@@ -442,41 +418,31 @@ const launch_app = async (options) => {
|
||||
// All other apps
|
||||
//------------------------------------
|
||||
else {
|
||||
process = new PortalProcess({
|
||||
uuid,
|
||||
name: app_info.name,
|
||||
parent: options.parent_instance_id,
|
||||
meta: {
|
||||
launch_options: options,
|
||||
app_info: app_info,
|
||||
},
|
||||
});
|
||||
const svc_process = globalThis.services.get('process');
|
||||
svc_process.register(process);
|
||||
// A new tab is outside the desktop: no process for it to track.
|
||||
if ( ! newTab ) {
|
||||
process = new PortalProcess({
|
||||
uuid,
|
||||
name: app_info.name,
|
||||
parent: options.parent_instance_id,
|
||||
meta: {
|
||||
launch_options: options,
|
||||
app_info: app_info,
|
||||
},
|
||||
});
|
||||
const svc_process = globalThis.services.get('process');
|
||||
svc_process.register(process);
|
||||
}
|
||||
|
||||
//-----------------------------------
|
||||
// iframe_url
|
||||
//-----------------------------------
|
||||
let iframe_url;
|
||||
|
||||
// This can be any trusted URL that won't be used for other apps
|
||||
const BUILTIN_PREFIX = 'https://builtins.namespaces.puter.com/';
|
||||
|
||||
if ( ! app_info.index_url ) {
|
||||
iframe_url = new URL(`https://${options.name}.${ window.app_domain }/index.html`);
|
||||
} else if ( app_info.index_url.startsWith(BUILTIN_PREFIX) ) {
|
||||
const name = app_info.index_url.slice(BUILTIN_PREFIX.length);
|
||||
iframe_url = new URL(`${window.gui_origin}/builtin/${name}`);
|
||||
} else {
|
||||
iframe_url = new URL(app_info.index_url);
|
||||
}
|
||||
|
||||
// add app_instance_id to URL
|
||||
iframe_url.searchParams.append('puter.app_instance_id', uuid);
|
||||
|
||||
// add app_id to URL
|
||||
iframe_url.searchParams.append('puter.app.id', app_info.uuid);
|
||||
iframe_url.searchParams.append('puter.app.name', app_info.name);
|
||||
const iframe_url = appLaunchUrl({
|
||||
app: app_info,
|
||||
name: options.name,
|
||||
appInstanceID: uuid,
|
||||
guiOrigin: window.gui_origin,
|
||||
appDomain: window.app_domain,
|
||||
});
|
||||
|
||||
// add parent_app_instance_id to URL
|
||||
if ( options.parent_instance_id ) {
|
||||
@@ -497,17 +463,20 @@ const launch_app = async (options) => {
|
||||
iframe_url.searchParams.append('puter.source_app.name', options.source_app_name);
|
||||
}
|
||||
|
||||
// A tab's URL would land the item's signed read/write URLs (which
|
||||
// don't expire) in browser history, so a tab gets them with its token.
|
||||
const item_params = newTab ? new URLSearchParams() : iframe_url.searchParams;
|
||||
if ( file_signature ) {
|
||||
append_signed_item_params(
|
||||
iframe_url.searchParams,
|
||||
item_params,
|
||||
file_signature,
|
||||
options.file_path ? privacy_aware_path(options.file_path) : file_signature.path,
|
||||
);
|
||||
}
|
||||
else if ( options.readURL ) {
|
||||
iframe_url.searchParams.append('puter.item.name', options.filename);
|
||||
iframe_url.searchParams.append('puter.item.path', privacy_aware_path(options.file_path));
|
||||
iframe_url.searchParams.append('puter.item.read_url', options.readURL);
|
||||
item_params.append('puter.item.name', options.filename);
|
||||
item_params.append('puter.item.path', privacy_aware_path(options.file_path));
|
||||
item_params.append('puter.item.read_url', options.readURL);
|
||||
}
|
||||
|
||||
const is_godmode = app_info.godmode === true || app_info.godmode === 1;
|
||||
@@ -522,10 +491,13 @@ const launch_app = async (options) => {
|
||||
?? await mintGodmodeToken(launch_app_uid);
|
||||
if ( godmode_token.ok ) {
|
||||
launch_token = godmode_token.token;
|
||||
trackGodmodeToken(
|
||||
uuid, launch_app_uid, godmode_token.token,
|
||||
godmode_token.expiresAt, godmode_token.mintedAt, iframe_url.href,
|
||||
);
|
||||
// Renewal is posted to the app's window, which a tab doesn't have.
|
||||
if ( ! newTab ) {
|
||||
trackGodmodeToken(
|
||||
uuid, launch_app_uid, godmode_token.token,
|
||||
godmode_token.expiresAt, godmode_token.mintedAt, iframe_url.href,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tokenResult = { token: null, reason: 'godmode-token-request-failed', ...godmode_token };
|
||||
}
|
||||
@@ -537,7 +509,11 @@ const launch_app = async (options) => {
|
||||
}
|
||||
|
||||
if ( launch_token ) {
|
||||
iframe_url.searchParams.append('puter.auth.token', launch_token);
|
||||
// A tab's URL would land the token in browser history; the tab
|
||||
// asks for it instead (offered below).
|
||||
if ( ! newTab ) {
|
||||
iframe_url.searchParams.append('puter.auth.token', launch_token);
|
||||
}
|
||||
if ( is_godmode ) {
|
||||
iframe_url.searchParams.append('puter.auth.username', window.user.username);
|
||||
}
|
||||
@@ -548,6 +524,8 @@ const launch_app = async (options) => {
|
||||
tokenResult,
|
||||
});
|
||||
|
||||
// The alert shows on this page, which a blank tab in front would hide.
|
||||
newTab?.close();
|
||||
const tokenErrorAppTitle = app_info?.title ?? app_info?.name ?? options?.name;
|
||||
await UIAlert(tokenErrorAppTitle
|
||||
? i18n('app_launch_failed_retry', { app: tokenErrorAppTitle }, false)
|
||||
@@ -568,44 +546,65 @@ const launch_app = async (options) => {
|
||||
return { launchResult: tokenFailureLaunchResult };
|
||||
}
|
||||
|
||||
iframe_url.searchParams.append('puter.domain', window.app_domain);
|
||||
appendLaunchContext(iframe_url, {
|
||||
appDomain: window.app_domain,
|
||||
pageUrl: window.location.href,
|
||||
apiOrigin: window.api_origin,
|
||||
params: options.params,
|
||||
locale: window.locale,
|
||||
args: withoutNotificationClick(options.args),
|
||||
surface: window.is_dashboard_mode ? 'dashboard' : 'desktop',
|
||||
});
|
||||
|
||||
// get URL parts
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
iframe_url.searchParams.append('puter.origin', url.origin);
|
||||
iframe_url.searchParams.append('puter.hostname', url.hostname);
|
||||
iframe_url.searchParams.append('puter.port', url.port);
|
||||
iframe_url.searchParams.append('puter.protocol', url.protocol.slice(0, -1));
|
||||
|
||||
if ( window.api_origin )
|
||||
{
|
||||
iframe_url.searchParams.append('puter.api_origin', window.api_origin);
|
||||
// Closed by the user while the launch was on its way: they called it off.
|
||||
if ( newTab?.closed ) {
|
||||
endLaunchTransaction(transaction, 'new-tab-closed');
|
||||
return {
|
||||
launchResult: {
|
||||
launched: false,
|
||||
requestedAppName,
|
||||
openedAppName: null,
|
||||
appInstanceID: null,
|
||||
appUid: launch_app_uid ?? null,
|
||||
redirectedToFallback: privateLaunchRedirectDepth > 0,
|
||||
deniedPrivateAccess: false,
|
||||
privateAccess: privateAccessDecision ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add options.params to URL
|
||||
if ( options.params ) {
|
||||
for ( const property in options.params ) {
|
||||
iframe_url.searchParams.append(property, options.params[property]);
|
||||
if ( newTab ) {
|
||||
// Marks the opened item (the uid isn't a credential), so the SDK
|
||||
// can tell it's coming before the rest arrives.
|
||||
if ( item_params.has('puter.item.uid') ) {
|
||||
iframe_url.searchParams.append('puter.item.uid', item_params.get('puter.item.uid'));
|
||||
}
|
||||
offerAppTabLaunch({
|
||||
appInstanceID: uuid,
|
||||
tab: newTab,
|
||||
origin: iframe_url.origin,
|
||||
token: launch_token,
|
||||
item: heldBackItem(item_params),
|
||||
});
|
||||
newTab.location.href = iframe_url.href;
|
||||
record_app_open(app_info);
|
||||
// A tab never signals over IPC, so it has no interactive span to count.
|
||||
transaction?.annotate({ 'launch.ipc_tracked': false });
|
||||
endLaunchTransaction(transaction, 'opened-in-new-tab');
|
||||
return {
|
||||
launchResult: {
|
||||
launched: true,
|
||||
requestedAppName,
|
||||
openedAppName: options.name,
|
||||
appInstanceID: uuid,
|
||||
appUid: launch_app_uid ?? null,
|
||||
redirectedToFallback: privateLaunchRedirectDepth > 0,
|
||||
deniedPrivateAccess: false,
|
||||
privateAccess: privateAccessDecision ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Add locale to URL
|
||||
iframe_url.searchParams.append('puter.locale', window.locale);
|
||||
|
||||
// Newer IPC dialogs this GUI can answer, comma-separated. The SDK
|
||||
// consults this before posting a message an older GUI has no handler
|
||||
// for: such a message is never replied to, and a reply timeout can't
|
||||
// stand in for the check because legitimate replies only arrive when
|
||||
// the user closes the dialog.
|
||||
iframe_url.searchParams.append('puter.gui_features', 'feedback-dialog,enable-push,custom-domains,push-status,disable-push');
|
||||
|
||||
// Add options.args to URL
|
||||
iframe_url.searchParams.append('puter.args', JSON.stringify(withoutNotificationClick(options.args) ?? {}));
|
||||
|
||||
// ...and finally append utm_source=puter.com to the URL
|
||||
iframe_url.searchParams.append('utm_source', 'puter.com');
|
||||
|
||||
// register app_instance_uid
|
||||
window.app_instance_ids.add(uuid);
|
||||
|
||||
@@ -763,31 +762,8 @@ const launch_app = async (options) => {
|
||||
void syncBrowserPush({ appUid: launch_app_uid });
|
||||
}
|
||||
|
||||
// send post request to /rao to record app open
|
||||
if ( options.name !== 'explorer' ) {
|
||||
// add the app to the beginning of the array
|
||||
window.launch_apps.recent.unshift(app_info);
|
||||
|
||||
// dedupe the array by uuid, uid, and id
|
||||
window.launch_apps.recent = [...new Map(window.launch_apps.recent.map(v => [v.name, v])).values()];
|
||||
|
||||
// limit to window.launch_recent_apps_count
|
||||
window.launch_apps.recent = window.launch_apps.recent.slice(0, window.launch_recent_apps_count);
|
||||
|
||||
// send post request to /rao to record app open
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/rao`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
original_client_socket_id: window.socket?.id,
|
||||
app_uid: app_info.uid ?? app_info.uuid,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
});
|
||||
record_app_open(app_info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,4 +842,27 @@ const launch_app = async (options) => {
|
||||
return process;
|
||||
};
|
||||
|
||||
/**
|
||||
* Launches an app.
|
||||
*
|
||||
* @param {*} options.name - The name of the app to launch.
|
||||
* @param {Window} [options.newTab] - A tab the caller opened (synchronously,
|
||||
* so popup blockers allow it, and without `noopener`, so the app can ask it
|
||||
* for its token and opened item) to send the app's URL to, instead of
|
||||
* opening an in-page window. The launch owns the tab: it is closed when the
|
||||
* launch fails. Explorer and built-in apps are part of the desktop, so for
|
||||
* them the tab is closed and the app opens in-page.
|
||||
*/
|
||||
const launch_app = async (options) => {
|
||||
const tab = options.newTab;
|
||||
try {
|
||||
const outcome = await runLaunch(options);
|
||||
if ( tab && getLaunchResult(outcome)?.launched === false ) tab.close();
|
||||
return outcome;
|
||||
} catch ( err ) {
|
||||
tab?.close();
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export default launch_app;
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./pushSync.js', () => ({ syncBrowserPush: vi.fn() }));
|
||||
vi.mock('./appTabLaunch.js', () => ({ offerAppTabLaunch: vi.fn() }));
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { default: UIWindow } = await import('../UI/UIWindow.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
const { offerAppTabLaunch } = await import('./appTabLaunch.js');
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
|
||||
const app = {
|
||||
uid: 'app-123',
|
||||
name: 'notes',
|
||||
title: 'Notes',
|
||||
index_url: 'https://notes.example.com/start?mode=1',
|
||||
};
|
||||
|
||||
let register;
|
||||
let transactions;
|
||||
|
||||
beforeEach(() => {
|
||||
register = vi.fn();
|
||||
globalThis.i18n = key => key;
|
||||
globalThis.$ = { ajax: vi.fn() };
|
||||
transactions = [];
|
||||
window.Transaction = class {
|
||||
constructor (name, attributes) {
|
||||
this.name = name;
|
||||
this.attributes = { ...attributes };
|
||||
transactions.push(this);
|
||||
}
|
||||
start () {}
|
||||
annotate (attributes) {
|
||||
Object.assign(this.attributes, attributes);
|
||||
}
|
||||
end () {}
|
||||
};
|
||||
window.uuidv4 = () => 'instance-1';
|
||||
window.icons = {};
|
||||
window.app_domain = 'puter.app';
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'session';
|
||||
window.locale = 'en';
|
||||
window.launch_apps = { recent: [] };
|
||||
window.launch_recent_apps_count = 5;
|
||||
window.app_instance_ids = new Set();
|
||||
globalThis.services = { get: () => ({ register }) };
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'app-token' }),
|
||||
})));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.is_dashboard_mode;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('launch_app with a new tab', () => {
|
||||
it('sends the tab to index_url with the params an in-page iframe gets, minus the token', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
const outcome = await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://notes.example.com/start');
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'mode': '1',
|
||||
'puter.app_instance_id': 'instance-1',
|
||||
'puter.app.id': 'app-123',
|
||||
'puter.app.name': 'notes',
|
||||
'puter.domain': 'puter.app',
|
||||
'puter.api_origin': 'https://api.puter.test',
|
||||
'puter.locale': 'en',
|
||||
'puter.args': '{}',
|
||||
'utm_source': 'puter.com',
|
||||
});
|
||||
expect(url.searchParams.has('puter.auth.token')).toBe(false);
|
||||
expect(offerAppTabLaunch).toHaveBeenCalledWith({
|
||||
appInstanceID: 'instance-1',
|
||||
tab,
|
||||
origin: 'https://notes.example.com',
|
||||
token: 'app-token',
|
||||
item: null,
|
||||
});
|
||||
expect(outcome.launchResult).toMatchObject({ launched: true, openedAppName: 'notes' });
|
||||
expect(UIWindow).not.toHaveBeenCalled();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(window.app_instance_ids.size).toBe(0);
|
||||
expect(window.launch_apps.recent[0].name).toBe('notes');
|
||||
expect($.ajax).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.puter.test/rao' }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, 'desktop'],
|
||||
[true, 'dashboard'],
|
||||
])('tags the launch with the surface it came from (dashboard mode: %s)', async (dashboardMode, campaign) => {
|
||||
window.is_dashboard_mode = dashboardMode;
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect(Object.fromEntries(url.searchParams)).toMatchObject({
|
||||
'utm_source': 'puter.com',
|
||||
'utm_medium': 'referral',
|
||||
'utm_campaign': campaign,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts the launch as one with no interactive span to wait for', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
const ready = transactions.find(t => t.name === 'app-is-ready');
|
||||
expect(ready.attributes).toMatchObject({
|
||||
'launch.ipc_tracked': false,
|
||||
outcome: 'opened-in-new-tab',
|
||||
});
|
||||
});
|
||||
|
||||
it('hands the opened file over with the token, keeping its signed URLs out of the tab URL', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
window.privacy_aware_path = p => p;
|
||||
await launch_app({
|
||||
name: 'notes',
|
||||
app_obj: { ...app },
|
||||
newTab: tab,
|
||||
file_path: '~/notes.txt',
|
||||
token: 'file-token',
|
||||
file_signature: {
|
||||
uid: 'file-1',
|
||||
fsentry_name: 'notes.txt',
|
||||
read_url: 'https://api.puter.test/file?signature=r',
|
||||
write_url: 'https://api.puter.test/writeFile?signature=w',
|
||||
metadata_url: 'https://api.puter.test/itemMetadata?signature=r',
|
||||
},
|
||||
});
|
||||
|
||||
const url = new URL(tab.location.href);
|
||||
expect([...url.searchParams.keys()].filter(k => k.startsWith('puter.item.'))).toEqual(['puter.item.uid']);
|
||||
expect(url.searchParams.get('puter.item.uid')).toBe('file-1');
|
||||
expect(offerAppTabLaunch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
token: 'file-token',
|
||||
item: expect.objectContaining({
|
||||
'puter.item.uid': 'file-1',
|
||||
'puter.item.name': 'notes.txt',
|
||||
'puter.item.path': '~/notes.txt',
|
||||
'puter.item.read_url': 'https://api.puter.test/file?signature=r',
|
||||
'puter.item.write_url': 'https://api.puter.test/writeFile?signature=w',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('closes the tab when the app token is unavailable', async () => {
|
||||
fetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const outcome = await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
log.mockRestore();
|
||||
|
||||
expect(outcome.launchResult.launched).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(tab.close).toHaveBeenCalled();
|
||||
expect(offerAppTabLaunch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the launch off when the user closed the tab meanwhile', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn(), closed: true };
|
||||
const outcome = await launch_app({ name: 'notes', app_obj: { ...app }, newTab: tab });
|
||||
|
||||
expect(outcome.launchResult.launched).toBe(false);
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(offerAppTabLaunch).not.toHaveBeenCalled();
|
||||
expect(window.launch_apps.recent).toEqual([]);
|
||||
expect($.ajax).not.toHaveBeenCalled();
|
||||
expect(UIAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the app token is unavailable', { ...app }, () => {
|
||||
fetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
|
||||
}],
|
||||
['access to a private app is denied', {
|
||||
...app,
|
||||
privateAccess: { hasAccess: false },
|
||||
}, () => {}],
|
||||
])('closes the tab before alerting that %s', async (_label, app_obj, arrange) => {
|
||||
arrange();
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
let tabClosedAtAlert;
|
||||
UIAlert.mockImplementation(async () => {
|
||||
tabClosedAtAlert = tab.close.mock.calls.length > 0;
|
||||
});
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const outcome = await launch_app({ name: 'notes', app_obj, newTab: tab });
|
||||
log.mockRestore();
|
||||
|
||||
expect(outcome.launchResult.launched).toBe(false);
|
||||
expect(UIAlert).toHaveBeenCalledOnce();
|
||||
expect(tabClosedAtAlert).toBe(true);
|
||||
});
|
||||
|
||||
it('closes the tab and opens a built-in app in-page', async () => {
|
||||
const tab = { location: { href: '' }, close: vi.fn() };
|
||||
const builtin = { ...app, index_url: 'https://builtins.namespaces.puter.com/dev-center' };
|
||||
window.gui_origin = 'https://puter.test';
|
||||
// Everything after the window opens isn't what this test is about.
|
||||
UIWindow.mockImplementation(() => {
|
||||
throw new Error('in-page window');
|
||||
});
|
||||
await expect(launch_app({ name: 'notes', app_obj: builtin, newTab: tab }))
|
||||
.rejects.toThrow('in-page window');
|
||||
|
||||
expect(tab.close).toHaveBeenCalled();
|
||||
expect(tab.location.href).toBe('');
|
||||
expect(UIWindow).toHaveBeenCalledWith(expect.objectContaining({
|
||||
iframe_url: expect.stringContaining('puter.auth.token=app-token'),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,14 @@ const file_launch_in_flight = (uid) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const open_item = async function (options) {
|
||||
const runOpen = async function (options) {
|
||||
// The tab goes to the first launch that takes it; open_item closes it if
|
||||
// nothing does.
|
||||
const take_tab = () => {
|
||||
const tab = options.newTab;
|
||||
options.newTab = undefined;
|
||||
return tab;
|
||||
};
|
||||
let el_item = options.item;
|
||||
const $el_parent_window = $(el_item).closest('.window');
|
||||
const parent_win_id = $($el_parent_window).attr('data-id');
|
||||
@@ -65,7 +72,7 @@ const open_item = async function (options) {
|
||||
//----------------------------------------------------------------
|
||||
const app_name = $(el_item).attr('data-app');
|
||||
if ( app_name ) {
|
||||
launch_app({ name: app_name });
|
||||
launch_app({ name: app_name, newTab: take_tab() });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +85,7 @@ const open_item = async function (options) {
|
||||
const text = typeof content === 'string' ? content : await content.text();
|
||||
const data = JSON.parse(text);
|
||||
if ( data.app ) {
|
||||
launch_app({ name: data.app });
|
||||
launch_app({ name: data.app, newTab: take_tab() });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -160,7 +167,14 @@ const open_item = async function (options) {
|
||||
|
||||
// If we have a valid URL, open it
|
||||
if ( url && (url.startsWith('http://') || url.startsWith('https://')) ) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
const tab = take_tab();
|
||||
if ( tab ) {
|
||||
// A link the user saved, not an app: it gets no way back here.
|
||||
tab.opener = null;
|
||||
tab.location.href = url;
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
} else {
|
||||
// Show a more detailed error message
|
||||
UIAlert(`Could not determine the URL for this web shortcut.
|
||||
@@ -269,13 +283,15 @@ Please try recreating the link.`);
|
||||
// The user's own windows only: a file opened in an instance another app
|
||||
// launched in the background is not a window the row can switch to, so the
|
||||
// click launches the file the normal way instead of surfacing that one.
|
||||
// Opening in a tab only swallows the re-click: the tab is where the
|
||||
// file goes, whatever windows have it open in the page.
|
||||
else if ( window.is_dashboard_mode && !associated_app_name && target_file_uid
|
||||
&& (user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`)).length
|
||||
&& ((!options.newTab && user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`)).length)
|
||||
|| file_launch_in_flight(target_file_uid)) ) {
|
||||
const $win = $(user_facing_windows($(`.window[data-file_uid="${html_encode(target_file_uid)}"]`))).last();
|
||||
// No window yet means the first click's launch is still in
|
||||
// flight — swallow the re-click instead of duplicating it.
|
||||
if ( $win.length ) {
|
||||
if ( $win.length && !options.newTab ) {
|
||||
if ( is_window_on_screen($win.get(0)) ) {
|
||||
$win.focusWindow();
|
||||
} else {
|
||||
@@ -290,6 +306,7 @@ Please try recreating the link.`);
|
||||
launch_app({
|
||||
name: associated_app_name,
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
}
|
||||
//----------------------------------------------------------------
|
||||
@@ -342,6 +359,7 @@ Please try recreating the link.`);
|
||||
window_title: path.basename(item_path),
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
file_uid: file_uid,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
if ( target_file_uid ) launch_promise.finally(() => launching_file_uids.delete(target_file_uid));
|
||||
return;
|
||||
@@ -378,8 +396,10 @@ Please try recreating the link.`);
|
||||
// download
|
||||
//---------------------------------------------
|
||||
if ( suggested_apps.length === 0 ) {
|
||||
// Not launching after all — lift the in-flight guard.
|
||||
// Not launching after all — lift the in-flight guard, and don't
|
||||
// leave a blank tab open behind the prompt.
|
||||
if ( target_file_uid ) launching_file_uids.delete(target_file_uid);
|
||||
take_tab()?.close();
|
||||
//---------------------------------------------
|
||||
// If .zip file, unzip it
|
||||
//---------------------------------------------
|
||||
@@ -424,10 +444,30 @@ Please try recreating the link.`);
|
||||
file_uid: fsuid,
|
||||
maximized: options.maximized ?? window.is_dashboard_mode,
|
||||
file_signature: open_item_meta.signature,
|
||||
newTab: take_tab(),
|
||||
});
|
||||
if ( target_file_uid ) launch_promise.finally(() => launching_file_uids.delete(target_file_uid));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an item: enters a folder, or opens a file or app.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.item - the item's element
|
||||
* @param {Window} [options.newTab] - a blank tab the caller opened inside the
|
||||
* user's gesture; an app the item opens in goes there instead of an in-page
|
||||
* window (see launch_app). Closed when the item opens no app.
|
||||
* @param {boolean} [options.maximized]
|
||||
* @param {boolean} [options.new_window]
|
||||
*/
|
||||
const open_item = async function (options) {
|
||||
try {
|
||||
return await runOpen(options);
|
||||
} finally {
|
||||
options.newTab?.close();
|
||||
}
|
||||
};
|
||||
|
||||
export default open_item;
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
|
||||
vi.mock('../i18n/i18n.js', () => ({ default: key => key }));
|
||||
vi.mock('./launchApp.js', () => ({ default: vi.fn(async () => ({ launchResult: { launched: true } })) }));
|
||||
vi.mock('./itemIcon.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./defaultApp.js', () => ({ resolve_default_app: vi.fn(async () => null) }));
|
||||
|
||||
globalThis.$ = globalThis.jQuery = jQuery;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
|
||||
const { default: launch_app } = await import('./launchApp.js');
|
||||
const { default: UIAlert } = await import('../UI/UIAlert.js');
|
||||
const { resolve_default_app } = await import('./defaultApp.js');
|
||||
const { default: open_item } = await import('./openItem.js');
|
||||
|
||||
const row = ({ name = 'notes.txt', path = '/user/notes.txt', uid = 'file-1' } = {}) => {
|
||||
const el = document.createElement('div');
|
||||
for ( const [key, value] of Object.entries({
|
||||
'data-uid': uid,
|
||||
'data-path': path,
|
||||
'data-name': name,
|
||||
'data-is_dir': '0',
|
||||
'data-shortcut_to': '',
|
||||
'data-shortcut_to_path': '',
|
||||
'data-associated_app_name': '',
|
||||
}) ) {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
const newTab = () => ({ opener: window, location: { href: '' }, close: vi.fn() });
|
||||
|
||||
beforeEach(() => {
|
||||
window.trash_path = '/user/Trash';
|
||||
window.is_dashboard_mode = true;
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
jQuery.ajax = vi.fn(async () => null);
|
||||
window.suggest_apps_for_fsentry = vi.fn(async () => []);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('open_item with a new tab', () => {
|
||||
it("hands the tab to the file's default app", async () => {
|
||||
const app = { name: 'editor' };
|
||||
resolve_default_app.mockResolvedValueOnce(app);
|
||||
const tab = newTab();
|
||||
await open_item({ item: row(), newTab: tab });
|
||||
|
||||
expect(launch_app).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'editor',
|
||||
file_uid: 'file-1',
|
||||
newTab: tab,
|
||||
}));
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the tab before offering a download when no app opens the file', async () => {
|
||||
const tab = newTab();
|
||||
let closedBeforeAlert = false;
|
||||
UIAlert.mockImplementation(async () => {
|
||||
closedBeforeAlert = tab.close.mock.calls.length === 1;
|
||||
});
|
||||
await open_item({ item: row({ name: 'data.bin', path: '/user/data.bin' }), newTab: tab });
|
||||
|
||||
expect(launch_app).not.toHaveBeenCalled();
|
||||
expect(closedBeforeAlert).toBe(true);
|
||||
expect(tab.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('closes the tab for a file in the trash', async () => {
|
||||
const tab = newTab();
|
||||
await open_item({ item: row({ path: '/user/Trash/notes.txt' }), newTab: tab });
|
||||
expect(tab.close).toHaveBeenCalledOnce();
|
||||
expect(launch_app).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends the tab to a web link, cut off from the dashboard', async () => {
|
||||
const tab = newTab();
|
||||
localStorage.setItem('weblink_link-1', 'https://example.com/page');
|
||||
await open_item({ item: row({ name: 'site.weblink', path: '/user/site.weblink', uid: 'link-1' }), newTab: tab });
|
||||
|
||||
expect(tab.opener).toBeNull();
|
||||
expect(tab.location.href).toBe('https://example.com/page');
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A random UUID v4. Unlike `crypto.randomUUID`, works outside a secure context
|
||||
* (a self-hosted GUI on plain http).
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export const uuidv4 = () => {
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
|
||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { isFramedDocument } from './lib/appModeGate.js';
|
||||
import {
|
||||
isGodmodeTokenPayload,
|
||||
isUserSessionTokenPayload,
|
||||
requestLaunchFromOpener,
|
||||
urlWithoutQueryParam,
|
||||
} from './lib/launchToken.js';
|
||||
import path from 'path-browserify';
|
||||
@@ -258,6 +259,25 @@ export class Puter {
|
||||
resolvers: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Pending while a tab the GUI opened for this app waits for its launch
|
||||
* (token and opened item); sign-in and the launched-with-items handlers
|
||||
* wait on it. Null otherwise.
|
||||
*
|
||||
* @internal
|
||||
* @type {Promise<void> | null}
|
||||
*/
|
||||
openerLaunch_ = null;
|
||||
|
||||
/**
|
||||
* `puter.item.*` params of the item the GUI opened this tab with, standing
|
||||
* in for the URL params a framed launch carries.
|
||||
*
|
||||
* @internal
|
||||
* @type {URLSearchParams | null}
|
||||
*/
|
||||
openerLaunchItem_ = null;
|
||||
|
||||
// Holds the unique app instance ID that is provided by the host environment
|
||||
appInstanceID;
|
||||
|
||||
@@ -761,6 +781,28 @@ export class Puter {
|
||||
|
||||
this.initSubmodules();
|
||||
|
||||
// A top-level tab the GUI opened for an app launch. Its URL params
|
||||
// aren't trusted here (see isFramedDocument), and secrets stay out
|
||||
// of its URL, so the token and opened item come from the GUI tab
|
||||
// that opened it instead.
|
||||
const openerLaunch = this.appInstanceID
|
||||
? requestLaunchFromOpener({
|
||||
guiOrigin: this.defaultGUIOrigin,
|
||||
appInstanceID: this.appInstanceID,
|
||||
})
|
||||
: null;
|
||||
if (openerLaunch) {
|
||||
this.openerLaunch_ = openerLaunch.then((launch) => {
|
||||
this.openerLaunch_ = null;
|
||||
if (launch?.item) {
|
||||
this.openerLaunchItem_ = new URLSearchParams(
|
||||
launch.item,
|
||||
);
|
||||
}
|
||||
if (launch?.token) this.setAuthToken(launch.token);
|
||||
});
|
||||
}
|
||||
|
||||
// Print a CTA for developers to publish their app on the Puter App Store
|
||||
this.printDevCTA();
|
||||
|
||||
|
||||
@@ -48,6 +48,43 @@ afterEach(() => {
|
||||
globalThis.XMLHttpRequest = origXHR;
|
||||
});
|
||||
|
||||
/** Boots `Puter` as on a top-level page, where `process` is undefined. */
|
||||
const constructWeb = (Puter) => {
|
||||
const realProcess = globalThis.process;
|
||||
Object.defineProperty(globalThis, 'process', {
|
||||
configurable: true,
|
||||
get() {
|
||||
const caller = (new Error().stack ?? '').split('\n')[2] ?? '';
|
||||
return /src\/index\.js:\d+/.test(caller) ? undefined : realProcess;
|
||||
},
|
||||
});
|
||||
try {
|
||||
return new Puter();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'process', {
|
||||
value: realProcess,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('web-env boot of a reloaded app tab', () => {
|
||||
// The opener is cut off once the first load is handed its launch, and a
|
||||
// reload keeps the URL that marks the opened item. App code can run in the
|
||||
// same task as the boot, so this is read with no await in between.
|
||||
it('reports no launch item to wait for, from the first synchronous read', async () => {
|
||||
history.replaceState(null, '', '/app/?puter.app_instance_id=i-1&puter.item.uid=f-1');
|
||||
vi.resetModules();
|
||||
const { Puter } = await import('./index.js');
|
||||
const puter = constructWeb(Puter);
|
||||
|
||||
expect(puter.env).toBe('web');
|
||||
expect(puter.openerLaunch_).toBeNull();
|
||||
expect(puter.ui.wasLaunchedWithItems()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web-env boot with a stored token', () => {
|
||||
it('opens only one authenticated socket', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, APP_TOKEN);
|
||||
|
||||
@@ -55,3 +55,108 @@ export const isUserSessionTokenPayload = (payload) => {
|
||||
const kind = payload.t ?? payload.type;
|
||||
return kind === 'gui' || kind === 'session' || kind === 's';
|
||||
};
|
||||
|
||||
// How long a tab waits for the GUI that opened it before signing in on its own.
|
||||
export const OPENER_LAUNCH_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* What the GUI hands a tab it opened for an app launch.
|
||||
*
|
||||
* @typedef {object} OpenerLaunch
|
||||
* @property {string | null} token - the app's token
|
||||
* @property {Record<string, string> | null} item - `puter.item.*` params of
|
||||
* the item the app was opened with, as a framed launch has in its URL
|
||||
*/
|
||||
|
||||
/** Keeps only `puter.item.*` string params. */
|
||||
const launchItemParams = (item) => {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(item).filter(
|
||||
([key, value]) =>
|
||||
key.startsWith('puter.item.') && typeof value === 'string',
|
||||
),
|
||||
);
|
||||
return Object.keys(params).length ? params : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the Puter GUI tab that opened this one for the launch it opened the
|
||||
* tab for (the app's token and opened item), so neither has to travel in the
|
||||
* tab's URL. Only a reply from the opener itself at `guiOrigin` counts: a
|
||||
* crafted link can name an instance id but can't make the GUI answer for it.
|
||||
* Whatever the outcome, the opener is then cut off so the app can't reach the
|
||||
* GUI tab.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.guiOrigin - the configured GUI origin, never one from the URL
|
||||
* @param {string} options.appInstanceID
|
||||
* @param {typeof globalThis} [options.scope] - Global to use; injectable for tests.
|
||||
* @param {number} [options.timeoutMs]
|
||||
* @returns {Promise<OpenerLaunch | null> | null} null when there is no GUI
|
||||
* opener to ask, or (resolved) when no answer came
|
||||
*/
|
||||
export const requestLaunchFromOpener = ({
|
||||
guiOrigin,
|
||||
appInstanceID,
|
||||
scope = globalThis,
|
||||
timeoutMs = OPENER_LAUNCH_TIMEOUT_MS,
|
||||
}) => {
|
||||
let opener = null;
|
||||
try {
|
||||
opener = scope.opener ?? null;
|
||||
} catch {
|
||||
opener = null;
|
||||
}
|
||||
// `closed` is readable cross-origin: a GUI tab that's gone can't answer.
|
||||
// Synchronous, so a caller has nothing to wait on (a reloaded tab, say).
|
||||
if (!opener || opener.closed || !guiOrigin || !appInstanceID) {
|
||||
return null;
|
||||
}
|
||||
// An opener of this page's own origin is the app itself (a pop-out, say),
|
||||
// not the GUI, and is the app's to keep. A cross-origin one throws here.
|
||||
try {
|
||||
const origin = scope.location.origin;
|
||||
if (origin !== guiOrigin && opener.location.origin === origin) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
// Possibly the GUI: ask it.
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timer = null;
|
||||
const finish = (launch) => {
|
||||
scope.removeEventListener('message', onMessage);
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
scope.opener = null;
|
||||
} catch {
|
||||
// Nothing more to protect if it can't be cleared.
|
||||
}
|
||||
resolve(launch);
|
||||
};
|
||||
const onMessage = (event) => {
|
||||
if (event.origin !== guiOrigin || event.source !== opener) return;
|
||||
if (event.data?.msg !== 'puter.launch') return;
|
||||
if (event.data.appInstanceID !== appInstanceID) return;
|
||||
const token = event.data.token;
|
||||
finish({
|
||||
token: typeof token === 'string' && token ? token : null,
|
||||
item: launchItemParams(event.data.item),
|
||||
});
|
||||
};
|
||||
scope.addEventListener('message', onMessage);
|
||||
timer = setTimeout(() => finish(null), timeoutMs);
|
||||
try {
|
||||
// Delivered only if the opener is at guiOrigin. `env` keeps the
|
||||
// GUI's app IPC from treating this as a message from a framed app.
|
||||
opener.postMessage(
|
||||
{ msg: 'puter.requestLaunch', env: 'app-tab', appInstanceID },
|
||||
guiOrigin,
|
||||
);
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
isGodmodeTokenPayload,
|
||||
isUserSessionTokenPayload,
|
||||
requestLaunchFromOpener,
|
||||
urlWithoutQueryParam,
|
||||
} from './launchToken.js';
|
||||
|
||||
@@ -117,3 +118,108 @@ describe('isGodmodeTokenPayload', () => {
|
||||
expect(isGodmodeTokenPayload(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestLaunchFromOpener', () => {
|
||||
const GUI = 'https://puter.com';
|
||||
|
||||
/** A top-level tab whose opener answers with `reply` (or not at all). */
|
||||
const makeScope = ({ reply } = {}) => {
|
||||
const listeners = new Set();
|
||||
const scope = {
|
||||
addEventListener: (_type, fn) => listeners.add(fn),
|
||||
removeEventListener: (_type, fn) => listeners.delete(fn),
|
||||
listeners,
|
||||
};
|
||||
scope.emit = (event) => {
|
||||
for (const fn of [...listeners]) fn(event);
|
||||
};
|
||||
scope.opener = {
|
||||
postMessage: vi.fn((data, targetOrigin) => {
|
||||
if (reply) queueMicrotask(() => reply(scope, data, targetOrigin));
|
||||
}),
|
||||
};
|
||||
return scope;
|
||||
};
|
||||
|
||||
it('takes the launch the GUI opener sends for this instance, then cuts the opener off', async () => {
|
||||
const scope = makeScope({
|
||||
reply: (s, data) => s.emit({
|
||||
origin: GUI,
|
||||
source: opener,
|
||||
data: {
|
||||
msg: 'puter.launch',
|
||||
appInstanceID: data.appInstanceID,
|
||||
token: 'tok',
|
||||
item: { 'puter.item.uid': 'f-1', 'puter.item.read_url': 'r', other: 'x', 'puter.item.size': 5 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const opener = scope.opener;
|
||||
const launch = await requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope });
|
||||
expect(launch).toEqual({
|
||||
token: 'tok',
|
||||
item: { 'puter.item.uid': 'f-1', 'puter.item.read_url': 'r' },
|
||||
});
|
||||
expect(opener.postMessage).toHaveBeenCalledWith(
|
||||
{ msg: 'puter.requestLaunch', env: 'app-tab', appInstanceID: 'i-1' },
|
||||
GUI,
|
||||
);
|
||||
expect(scope.opener).toBeNull();
|
||||
expect(scope.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores replies from another origin, another window, or another instance', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const scope = makeScope();
|
||||
const opener = scope.opener;
|
||||
const pending = requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope, timeoutMs: 1000 });
|
||||
const data = { msg: 'puter.launch', appInstanceID: 'i-1', token: 'bad' };
|
||||
scope.emit({ origin: 'https://evil.example', source: opener, data });
|
||||
scope.emit({ origin: GUI, source: {}, data });
|
||||
scope.emit({ origin: GUI, source: opener, data: { ...data, appInstanceID: 'i-2' } });
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(await pending).toBeNull();
|
||||
expect(scope.opener).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves alone a page this app opened from its own origin', () => {
|
||||
const scope = makeScope();
|
||||
scope.location = { origin: 'https://app.example' };
|
||||
scope.opener.location = { origin: 'https://app.example' };
|
||||
const opener = scope.opener;
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeNull();
|
||||
expect(opener.postMessage).not.toHaveBeenCalled();
|
||||
expect(scope.opener).toBe(opener);
|
||||
});
|
||||
|
||||
it('asks a cross-origin opener, as the GUI is', () => {
|
||||
const scope = makeScope();
|
||||
scope.location = { origin: 'https://app.example' };
|
||||
Object.defineProperty(scope.opener, 'location', {
|
||||
get () {
|
||||
throw new DOMException('Blocked a frame', 'SecurityError');
|
||||
},
|
||||
});
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeInstanceOf(Promise);
|
||||
expect(scope.opener.postMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// Synchronously, so there is no pending launch for callers to wait on.
|
||||
it('returns null without asking when there is no opener', () => {
|
||||
const scope = makeScope();
|
||||
scope.opener = null;
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without waiting when the GUI tab was closed', () => {
|
||||
const scope = makeScope();
|
||||
scope.opener.closed = true;
|
||||
expect(requestLaunchFromOpener({ guiOrigin: GUI, appInstanceID: 'i-1', scope })).toBeNull();
|
||||
expect(scope.opener.postMessage).not.toHaveBeenCalled();
|
||||
expect(scope.listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,14 @@ export class AuthModule extends PuterModule {
|
||||
});
|
||||
}
|
||||
|
||||
// A tab the GUI opened is signed in by the token the GUI hands over.
|
||||
if (puter.openerLaunch_) {
|
||||
return puter.openerLaunch_.then(() =>
|
||||
puter.authToken
|
||||
? { success: true, token: puter.authToken }
|
||||
: this.signIn(options));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const signinsession = crypto.randomUUID();
|
||||
const msg_id = signinsession;
|
||||
@@ -389,6 +397,10 @@ export class AuthModule extends PuterModule {
|
||||
* }}
|
||||
*/
|
||||
getUser = function (...args) {
|
||||
// A tab the GUI opened gets its token moments after loading.
|
||||
if (!puter.authToken && puter.openerLaunch_) {
|
||||
return puter.openerLaunch_.then(() => puter.auth.getUser(...args));
|
||||
}
|
||||
if (!puter.authToken) {
|
||||
// Fake the server response for backwards compatibility
|
||||
// We already know this will fail
|
||||
@@ -442,6 +454,9 @@ export class AuthModule extends PuterModule {
|
||||
*/
|
||||
async getProfile(username) {
|
||||
try {
|
||||
if (!this.authToken && this.puter.openerLaunch_) {
|
||||
await this.puter.openerLaunch_;
|
||||
}
|
||||
if (username === undefined) {
|
||||
if (!this.authToken) return null;
|
||||
} else if (
|
||||
@@ -550,6 +565,9 @@ export class AuthModule extends PuterModule {
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async whoami() {
|
||||
if (!this.authToken && this.puter.openerLaunch_) {
|
||||
await this.puter.openerLaunch_;
|
||||
}
|
||||
if (!this.authToken) {
|
||||
// Fake the server response for backwards compatibility
|
||||
// We already know this will fail
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchUrlMock } = vi.hoisted(() => ({ fetchUrlMock: vi.fn() }));
|
||||
const { fetchUrlMock, xhrMocks } = vi.hoisted(() => ({
|
||||
fetchUrlMock: vi.fn(),
|
||||
xhrMocks: { initXhr: vi.fn(), setupXhrEventHandlers: vi.fn() },
|
||||
}));
|
||||
vi.mock('../lib/networkUtils.js', () => ({ fetchUrl: fetchUrlMock }));
|
||||
vi.mock('../lib/utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
...xhrMocks,
|
||||
}));
|
||||
|
||||
const { AuthModule } = await import('./Auth.js');
|
||||
|
||||
@@ -306,3 +313,68 @@ describe('signOut', () => {
|
||||
expect(puter.resetAuthToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('in a tab the GUI opened, before its token arrives', () => {
|
||||
/** A puter whose token lands when `handOver` is called. */
|
||||
const awaitingHandOver = () => {
|
||||
const puter = { authToken: null, APIOrigin: API_ORIGIN };
|
||||
let handOver;
|
||||
puter.openerLaunch_ = new Promise((resolve) => {
|
||||
handOver = () => {
|
||||
puter.authToken = 'app-token';
|
||||
puter.openerLaunch_ = null;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
return { puter, handOver };
|
||||
};
|
||||
|
||||
it('whoami waits for the token instead of failing', async () => {
|
||||
fetchUrlMock.mockResolvedValue(respond({ username: 'alice' }));
|
||||
const { puter, handOver } = awaitingHandOver();
|
||||
const user = new AuthModule(puter).whoami();
|
||||
handOver();
|
||||
await expect(user).resolves.toEqual({ username: 'alice' });
|
||||
});
|
||||
|
||||
it('getProfile waits for the token instead of answering null', async () => {
|
||||
const { puter, handOver } = awaitingHandOver();
|
||||
const result = new AuthModule(puter).getProfile();
|
||||
handOver();
|
||||
expect(await result).toEqual(profile);
|
||||
});
|
||||
|
||||
it('getUser waits for the token instead of throwing', async () => {
|
||||
const { puter, handOver } = awaitingHandOver();
|
||||
const auth = new AuthModule(puter);
|
||||
puter.auth = auth;
|
||||
const xhr = {};
|
||||
xhrMocks.initXhr.mockReturnValue(xhr);
|
||||
xhrMocks.setupXhrEventHandlers.mockImplementation(
|
||||
(target, _success, _error, resolve) => {
|
||||
target.send = () => resolve({ username: 'alice' });
|
||||
},
|
||||
);
|
||||
vi.stubGlobal('puter', puter);
|
||||
try {
|
||||
const user = auth.getUser();
|
||||
handOver();
|
||||
await expect(user).resolves.toEqual({ username: 'alice' });
|
||||
expect(xhrMocks.initXhr).toHaveBeenCalledWith(
|
||||
'/whoami', API_ORIGIN, 'app-token', 'get',
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('still fails once no token came', async () => {
|
||||
const puter = { authToken: null, APIOrigin: API_ORIGIN };
|
||||
puter.openerLaunch_ = Promise.resolve().then(() => {
|
||||
puter.openerLaunch_ = null;
|
||||
});
|
||||
await expect(new AuthModule(puter).whoami()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,3 +83,31 @@ describe('authenticateWithPuter with more than one concurrent caller', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticateWithPuter in a tab the GUI opened', () => {
|
||||
it('waits for the handed-over token instead of prompting', async () => {
|
||||
const puterArg = { env: 'web', authToken: null, appID: undefined, util: {} };
|
||||
const ui = new UIModule(puterArg, {});
|
||||
let handOver;
|
||||
globalThis.puter.openerLaunch_ = new Promise((resolve) => {
|
||||
handOver = resolve;
|
||||
});
|
||||
const auth = ui.authenticateWithPuter();
|
||||
|
||||
puterArg.authToken = 'app-token';
|
||||
globalThis.puter.openerLaunch_ = null;
|
||||
handOver();
|
||||
await auth;
|
||||
expect(globalThis.puter.auth.signIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts as usual when no token came', async () => {
|
||||
const ui = makeUI();
|
||||
globalThis.puter.auth.signIn = vi.fn(async () => {});
|
||||
globalThis.puter.openerLaunch_ = Promise.resolve().then(() => {
|
||||
globalThis.puter.openerLaunch_ = null;
|
||||
});
|
||||
await ui.authenticateWithPuter();
|
||||
expect(globalThis.puter.auth.signIn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -293,6 +293,12 @@ const getGUIOrigin = (origin) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether `params` carry a launch item: its name, uid and read URL. */
|
||||
const hasLaunchItemParams = (params) =>
|
||||
params.has('puter.item.name') &&
|
||||
params.has('puter.item.uid') &&
|
||||
params.has('puter.item.read_url');
|
||||
|
||||
const MAX_REQUESTED_PERMISSIONS = 16;
|
||||
|
||||
// Short on purpose: a request usually spends a user gesture while it waits.
|
||||
@@ -1076,13 +1082,17 @@ export class UIModule extends EventListener {
|
||||
*/
|
||||
/**
|
||||
* The item the app was launched with, read from the `puter.item.*` URL
|
||||
* params the host appends, or null when there is none.
|
||||
* params the host appends (or, in a tab the GUI opened, the same params
|
||||
* it handed over), or null when there is none.
|
||||
*
|
||||
* @returns {FSItem|null}
|
||||
*/
|
||||
#launchItemFromURL () {
|
||||
const URLParams = new URLSearchParams(globalThis.location.search);
|
||||
if ( !URLParams.has('puter.item.name') || !URLParams.has('puter.item.uid') || !URLParams.has('puter.item.read_url') ) {
|
||||
#launchItem () {
|
||||
let URLParams = new URLSearchParams(globalThis.location.search);
|
||||
if ( !hasLaunchItemParams(URLParams) && puter.openerLaunchItem_ ) {
|
||||
URLParams = puter.openerLaunchItem_;
|
||||
}
|
||||
if ( ! hasLaunchItemParams(URLParams) ) {
|
||||
return null;
|
||||
}
|
||||
let fpath = URLParams.get('puter.item.path');
|
||||
@@ -1104,6 +1114,26 @@ export class UIModule extends EventListener {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `callback` with the launch item, once a tab the GUI opened has
|
||||
* received it. Nothing when the app wasn't launched with one.
|
||||
*
|
||||
* @param {(items: FSItem[]) => void} callback
|
||||
*/
|
||||
#withLaunchItem (callback) {
|
||||
const deliver = () => {
|
||||
const launchItem = this.#launchItem();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
};
|
||||
if ( puter.openerLaunch_ ) {
|
||||
puter.openerLaunch_.then(deliver);
|
||||
} else {
|
||||
deliver();
|
||||
}
|
||||
}
|
||||
|
||||
onItemsOpened (callback) {
|
||||
// DEPRECATED - this is also called when items are dropped on the app, which in new versions should be handled
|
||||
// with the 'drop' event.
|
||||
@@ -1113,10 +1143,7 @@ export class UIModule extends EventListener {
|
||||
// This should also be done only the very first time the callback is set (hence the if(!this.#onItemsOpened) check) since
|
||||
// the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times.
|
||||
if ( ! this.#onItemsOpened ) {
|
||||
const launchItem = this.#launchItemFromURL();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
this.#withLaunchItem(callback);
|
||||
}
|
||||
|
||||
this.#onItemsOpened = callback;
|
||||
@@ -1132,9 +1159,12 @@ export class UIModule extends EventListener {
|
||||
*/
|
||||
wasLaunchedWithItems () {
|
||||
const URLParams = new URLSearchParams(globalThis.location.search);
|
||||
return URLParams.has('puter.item.name') &&
|
||||
URLParams.has('puter.item.uid') &&
|
||||
URLParams.has('puter.item.read_url');
|
||||
if ( hasLaunchItemParams(URLParams) || puter.openerLaunchItem_ ) {
|
||||
return true;
|
||||
}
|
||||
// A tab the GUI opened marks an opened item with its uid alone; the
|
||||
// rest is still on its way from the GUI.
|
||||
return !!puter.openerLaunch_ && URLParams.has('puter.item.uid');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1151,10 +1181,7 @@ export class UIModule extends EventListener {
|
||||
// This should also be done only the very first time the callback is set (hence the if(!this.#onLaunchedWithItems) check) since
|
||||
// the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times.
|
||||
if ( ! this.#onLaunchedWithItems ) {
|
||||
const launchItem = this.#launchItemFromURL();
|
||||
if ( launchItem ) {
|
||||
callback([launchItem]);
|
||||
}
|
||||
this.#withLaunchItem(callback);
|
||||
}
|
||||
|
||||
this.#onLaunchedWithItems = callback;
|
||||
@@ -2735,6 +2762,11 @@ export class UIModule extends EventListener {
|
||||
return;
|
||||
}
|
||||
|
||||
// The GUI that opened this tab may be about to hand over a token.
|
||||
if ( puter.openerLaunch_ ) {
|
||||
return puter.openerLaunch_.then(() => this.authenticateWithPuter());
|
||||
}
|
||||
|
||||
// if authToken is already present, resolve immediately
|
||||
if ( this.authToken ) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -356,4 +356,33 @@ describe('launch items', () => {
|
||||
|
||||
expect(launched).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the item a tab the GUI opened is handed', async () => {
|
||||
vi.stubGlobal('location', { search: '?puter.item.uid=uid-1' });
|
||||
let handOver;
|
||||
globalThis.puter.openerLaunch_ = new Promise((resolve) => {
|
||||
handOver = resolve;
|
||||
});
|
||||
const ui = makeUI();
|
||||
const launched = vi.fn();
|
||||
ui.onLaunchedWithItems(launched);
|
||||
expect(ui.wasLaunchedWithItems()).toBe(true);
|
||||
expect(launched).not.toHaveBeenCalled();
|
||||
|
||||
globalThis.puter.openerLaunchItem_ = new URLSearchParams(launchSearch().slice(1));
|
||||
globalThis.puter.openerLaunch_ = null;
|
||||
handOver();
|
||||
await vi.waitFor(() => expect(launched).toHaveBeenCalledOnce());
|
||||
expect(launched.mock.calls[0][0][0].readURL).toBe('https://api.test/file?uid=uid-1');
|
||||
expect(ui.wasLaunchedWithItems()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an item uid alone in the URL when nothing is being handed over', () => {
|
||||
vi.stubGlobal('location', { search: '?puter.item.uid=uid-1' });
|
||||
const ui = makeUI();
|
||||
const launched = vi.fn();
|
||||
ui.onLaunchedWithItems(launched);
|
||||
expect(ui.wasLaunchedWithItems()).toBe(false);
|
||||
expect(launched).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user