mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-13 16:56:14 +00:00
Drop the delayed fade-out when a logged-in user reaches the GUI and remove the landing overlay immediately instead. This avoids the overlay sitting above the email/phone/card verification gates because of its z-index.
2724 lines
122 KiB
JavaScript
2724 lines
122 KiB
JavaScript
/*
|
|
* 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 UIDashboard from './UI/Dashboard/UIDashboard.js';
|
|
import TabApps from './UI/Dashboard/TabApps.js';
|
|
import UIAlert from './UI/UIAlert.js';
|
|
import UIComponentWindow from './UI/UIComponentWindow.js';
|
|
import UIDesktop from './UI/UIDesktop.js';
|
|
import UIWindow from './UI/UIWindow.js';
|
|
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
|
|
import UIWindowAuthMe from './UI/UIWindowAuthMe.js';
|
|
import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js';
|
|
import UIWindowCopyToken from './UI/UIWindowCopyToken.js';
|
|
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
|
|
import UIWindowPhoneVerificationRequired from './UI/UIWindowPhoneVerificationRequired.js';
|
|
import UIWindowCardVerificationRequired from './UI/UIWindowCardVerificationRequired.js';
|
|
import UIWindowLogin from './UI/UIWindowLogin.js';
|
|
import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js';
|
|
import UIWindowNewPassword from './UI/UIWindowNewPassword.js';
|
|
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
|
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
|
import UIWindowSessionList from './UI/UIWindowSessionList.js';
|
|
import UIWindowSignup from './UI/UIWindowSignup.js';
|
|
import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js';
|
|
import { PROCESS_RUNNING } from './definitions.js';
|
|
import create_access_token from './helpers/createAccessToken.js';
|
|
import create_gui_token from './helpers/createGuiToken.js';
|
|
import {
|
|
authmeRequestUrl,
|
|
authmeReturnUrl,
|
|
fullTokenAllowed,
|
|
isDeliverableRedirect,
|
|
shouldUseRemoteAuthme,
|
|
urlTokenParam,
|
|
wantsFullToken,
|
|
} from './util/authmeGrant.js';
|
|
import init_device_signals from './helpers/deviceSignals.js';
|
|
import { holdsPermissions } from './helpers/holdsPermissions.js';
|
|
import item_icon from './helpers/itemIcon.js';
|
|
import launch_app from './helpers/launchApp.js';
|
|
import { parse_url_paths } from './helpers/urlPaths.js';
|
|
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
|
|
import update_mouse_position from './helpers/updateMousePosition.js';
|
|
import update_title_based_on_uploads from './helpers/updateTitleBasedOnUploads.js';
|
|
import path from './lib/path.js';
|
|
import { AntiCSRFService } from './services/AntiCSRFService.js';
|
|
import { BroadcastService } from './services/BroadcastService.js';
|
|
import { DebugService } from './services/DebugService.js';
|
|
import { ExecService } from './services/ExecService.js';
|
|
import { PictureInPictureService } from './services/PictureInPictureService.js';
|
|
import { IPCService } from './services/IPCService.js';
|
|
import { LaunchOnInitService } from './services/LaunchOnInitService.js';
|
|
import { LocaleService } from './services/LocaleService.js';
|
|
import { ProcessService } from './services/ProcessService.js';
|
|
import { ThemeService } from './services/ThemeService.js';
|
|
// Curried: takes `{ window }` and returns the path mapper. Import under a
|
|
// factory name so a bare `privacy_aware_path(path)` call in this module can't
|
|
// silently resolve to the factory — use `window.privacy_aware_path` instead.
|
|
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
|
|
import { resolveAPIOrigin } from './util/apiOrigin.js';
|
|
import { deliversTokenToOpener, runsUserAppTokenExchange } from './util/popupAuth.js';
|
|
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
|
|
|
|
const postAuthActions = async (action) => {
|
|
// Set when a popup's user-app token exchange fails. The exchange is what
|
|
// bootstraps the app row a permission grant is written against, so an
|
|
// action that depends on it has to report failure rather than prompt.
|
|
let token_exchange_failed = false;
|
|
// The token the exchange minted for the opener's app, kept only to ask what
|
|
// that app holds — a question this window's own token cannot answer.
|
|
let user_app_token = null;
|
|
// -------------------------------------------------------------------------------------
|
|
// Action: AuthMe — redirect to a third-party URL with the user's auth token
|
|
// -------------------------------------------------------------------------------------
|
|
if ( action === 'authme' ) {
|
|
const redirectURL = window.url_query_params.get('redirectURL');
|
|
// Refuse an undeliverable destination before the dialog, not after:
|
|
// `javascript:` and `data:` targets execute in *this* document when
|
|
// assigned to `location`, which would be script execution on the
|
|
// account origin rather than a redirect. See `isDeliverableRedirect`.
|
|
if ( redirectURL && ! isDeliverableRedirect(redirectURL) ) {
|
|
await UIAlert({ message: i18n('authme_bad_redirect_url') });
|
|
return;
|
|
}
|
|
// A full account session is only ever *offered* when the caller names
|
|
// it. Without `token_type=session` this flow can hand over nothing but
|
|
// the restricted API token, so an ordinary AuthMe link can't be
|
|
// dressed up into a session grant by whoever crafted it.
|
|
//
|
|
// Naming it isn't sufficient either: the session grade is only offered
|
|
// to a loopback destination, the one setup it exists for. Anywhere
|
|
// else falls back to the restricted token — see `fullTokenAllowed`.
|
|
const wants_full_token = fullTokenAllowed(
|
|
window.url_query_params,
|
|
redirectURL,
|
|
);
|
|
if ( wantsFullToken(window.url_query_params) && ! wants_full_token ) {
|
|
console.warn(
|
|
'[authme] token_type=session ignored for a non-loopback ' +
|
|
'destination; granting the restricted API token instead.',
|
|
);
|
|
}
|
|
if ( redirectURL ) {
|
|
const approved = await UIWindowAuthMe({
|
|
redirect_url: redirectURL,
|
|
full_token: wants_full_token,
|
|
});
|
|
if ( approved ) {
|
|
// Default: a named, revocable full-API-access token rather
|
|
// than the raw GUI/session token — it can use the whole API
|
|
// but can't manage the account.
|
|
//
|
|
// `token_type=session` (approved via type-to-confirm) hands
|
|
// over a GUI token instead. That exists for a locally served
|
|
// GUI pointed at a remote backend: `/login` only accepts its
|
|
// own origin, so this is the sanctioned way for a dev GUI to
|
|
// get a session — the password is still only ever typed here.
|
|
let host = '';
|
|
try { host = new URL(redirectURL).host; } catch ( e ) { /* ignore */ }
|
|
let token;
|
|
try {
|
|
token = wants_full_token
|
|
? await create_gui_token()
|
|
: await create_access_token({
|
|
label: host
|
|
? `${i18n('token_label_external_app')} (${host})`
|
|
: i18n('token_label_external_app'),
|
|
});
|
|
} catch ( e ) {
|
|
await UIAlert({ message: e?.message ?? String(e) });
|
|
return;
|
|
}
|
|
window.location.href = authmeReturnUrl(
|
|
redirectURL,
|
|
token,
|
|
).href;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Action: CopyAuth — show dialog to copy auth token
|
|
// -------------------------------------------------------------------------------------
|
|
if ( action === 'copyauth' ) {
|
|
await UIWindowCopyToken({ show_header: true });
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Load desktop, only if we're not embedded in a popup and not on the dashboard page
|
|
// -------------------------------------------------------------------------------------
|
|
if ( !window.embedded_in_popup && !window.is_dashboard_mode ) {
|
|
if ( window.is_fullpage_mode ) {
|
|
// In fullpage mode, skip loading desktop items and background
|
|
UIDesktop({});
|
|
} else {
|
|
await window.get_auto_arrange_data();
|
|
puter.fs.stat({ path: window.desktop_path, consistency: 'eventual' }).then(desktop_fsentry => {
|
|
UIDesktop({ desktop_fsentry: desktop_fsentry });
|
|
});
|
|
}
|
|
}
|
|
// -------------------------------------------------------------------------------------
|
|
// Dashboard mode
|
|
// -------------------------------------------------------------------------------------
|
|
else if ( window.is_dashboard_mode ) {
|
|
const el_dashboard_promise = UIDashboard();
|
|
// Direct landing on /app/<name>: open the app in the dashboard the
|
|
// same way a tile launch does. The dashboard's route is slotted
|
|
// underneath first (replaceState) and the launch re-claims
|
|
// /app/<name> as a real history entry, so Back minimizes to the
|
|
// dashboard exactly like an in-dashboard launch. (`?c` suppresses
|
|
// the auto-launch, mirroring the desktop URL-launch flow.)
|
|
if ( window.url_paths[0]?.toLocaleLowerCase() === 'app'
|
|
&& window.url_paths[1]
|
|
&& ! window.url_query_params.has('c') ) {
|
|
const app_name = window.url_paths[1];
|
|
// any query param that doesn't start with 'puter.' is passed
|
|
// through to the app (mirrors the desktop URL-launch flow)
|
|
const app_query_params = {};
|
|
for ( const [key, value] of window.url_query_params ) {
|
|
if ( ! key.startsWith('puter.') ) {
|
|
app_query_params[key] = value;
|
|
}
|
|
}
|
|
let posargs;
|
|
if ( app_query_params.posargs ) {
|
|
try {
|
|
posargs = JSON.parse(app_query_params.posargs);
|
|
} catch (e) {
|
|
// malformed posargs: launch without them
|
|
}
|
|
}
|
|
// The server titles /app/<name> pages after the app, so the
|
|
// launch's lazy base-title capture would keep the app's name
|
|
// forever — preset the title to fall back to when the app's
|
|
// history entry is popped.
|
|
window.dashboard_base_title = i18n('window_title_puter');
|
|
// ...and make it the DOCUMENT title before the replaceState
|
|
// below commits the dashboard's own entry. Chrome stamps a
|
|
// session entry with the document title current at commit and
|
|
// shows that stored title in the tab strip whenever a traversal
|
|
// lands on the entry — so with the server's app-name title
|
|
// still in place, closing the app (whose close consumes the
|
|
// /app/<name> entry via history.back()) left the tab named
|
|
// after an app that was no longer on screen: the popstate
|
|
// handler's document.title reset updates the DOM title, but the
|
|
// tab strip keeps displaying the entry's stored one.
|
|
document.title = window.dashboard_base_title;
|
|
window.history.replaceState(null, '', '/');
|
|
// Resolve the app's info NOW, in parallel with the tile wait
|
|
// below, so the intro never delays the launch's own server
|
|
// round-trip; the result is handed to launch_app as app_obj (the
|
|
// same object its own fetch would produce, at the grid tiles'
|
|
// 128px icon size — the intro may have to DRAW a tile from it,
|
|
// when the landing is what installs the app). A failed prefetch
|
|
// hands nothing over — launch_app refetches and fails exactly
|
|
// the way it always did.
|
|
const app_info_promise = puter.apps.get(app_name, { icon_size: 128 })
|
|
.catch(() => null);
|
|
(async () => {
|
|
// If the app already has a tile in the Apps tab, play the
|
|
// whole click→morph→open sequence a real tile click plays —
|
|
// paced so it can be followed: the grid appears, a beat, the
|
|
// tile visibly acknowledges (icon ghost), a beat, and the
|
|
// window grows out of its slot — so the landing tells the
|
|
// user what is being opened and where minimize puts it back.
|
|
// No tile (not installed, grid too slow, apps fetch failed,
|
|
// animations off): the launch proceeds immediately with the
|
|
// plain fade, as before. The intro also steps aside on its
|
|
// own: user input skips its remaining beats, and once this
|
|
// account has watched it a few times the beats collapse for
|
|
// good (see beginDeepLinkLaunch).
|
|
let tile = null;
|
|
try {
|
|
const el_dashboard = await el_dashboard_promise;
|
|
// The app-info promise lets the intro materialize a tile
|
|
// for an app the dashboard doesn't have yet — landing on
|
|
// an app is what installs it (see _spliceDeepLinkApp).
|
|
tile = await TabApps.beginDeepLinkLaunch(app_name, $(el_dashboard), app_info_promise);
|
|
} catch ( _e ) {
|
|
// No dashboard window — no intro; still launch.
|
|
}
|
|
const app_obj = await app_info_promise;
|
|
launch_app({
|
|
name: app_name,
|
|
maximized: true,
|
|
params: app_query_params,
|
|
readURL: window.url_query_params.get('readURL'),
|
|
...(app_obj ? { app_obj } : {}),
|
|
...(posargs ? {
|
|
args: {
|
|
command_line: { args: posargs },
|
|
},
|
|
} : {}),
|
|
window_options: { morph_from_dashboard_tile: true },
|
|
}).catch((err) => {
|
|
console.error(`Failed to launch ${app_name} from URL:`, err);
|
|
}).finally(() => {
|
|
TabApps.settleDeepLinkLaunch(app_name, tile);
|
|
});
|
|
})();
|
|
}
|
|
}
|
|
// -------------------------------------------------------------------------------------
|
|
// If embedded in a popup, send the token to the opener and close the popup
|
|
// -------------------------------------------------------------------------------------
|
|
else {
|
|
let msg_id = window.url_query_params.get('msg_id');
|
|
// `cross_origin_isolated` routes the token to the opener through
|
|
// `/login/set`, which `/login/wait` then hands to anyone holding the
|
|
// session id. That is a sign-in mechanism, so it is gated by the same
|
|
// rule as the postMessage hand-off below — otherwise adding one query
|
|
// parameter to a `request-permission` URL turns the permission prompt
|
|
// into a token grant, and skips the prompt entirely.
|
|
let isolated = window.url_query_params.get("cross_origin_isolated") === 'true'
|
|
&& deliversTokenToOpener(action);
|
|
let session = window.url_query_params.get('signin_session');
|
|
|
|
// Signing the opener in is something the user has to have asked for.
|
|
// The gates upstream record that decision — picking an account,
|
|
// finishing signup, or already holding a token for this opener — and
|
|
// a first visit that mints a throwaway temp user has no existing
|
|
// account to hand over. Without this check the hand-off below runs
|
|
// unconditionally, so a popup that showed the user nothing still
|
|
// ended in a token: dismissing the account picker skipped only the
|
|
// early exchange, not the delivery.
|
|
//
|
|
// Scoped to the popups whose whole purpose is signing in. The
|
|
// file-picker actions also reach the hand-off, but they answer for
|
|
// themselves — they have their own dialogs and never show an account
|
|
// picker, so requiring one here would just break them.
|
|
const is_signin_popup = !action || action === 'sign-in';
|
|
const consented =
|
|
window.popup_signin_consent ||
|
|
(window.attempt_temp_user_creation && window.first_visit_ever);
|
|
if (is_signin_popup && !consented) {
|
|
console.error(
|
|
'popup sign-in was not consented to; not delivering a token',
|
|
);
|
|
if (isolated) {
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
return;
|
|
}
|
|
window.opener?.postMessage({
|
|
msg: 'puter.token',
|
|
success: false,
|
|
token: null,
|
|
msg_id: msg_id,
|
|
}, window.openerOrigin);
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
return;
|
|
}
|
|
|
|
if (isolated) {
|
|
try {
|
|
const data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
|
|
// Same two failure modes the postMessage path below guards
|
|
// against: `getUserAppToken` reports a network failure by
|
|
// returning null, and an HTTP failure (a blocked origin, an
|
|
// unparseable origin, a 5xx) by returning the parsed *error*
|
|
// body — truthy, but carrying no token. Without this check the
|
|
// missing token is handed to `/login/set`, which rejects it as
|
|
// a 400, and every distinct cause — including the ones that
|
|
// only occur on a deployment with a populated origin blocklist
|
|
// — collapses into the same unattributable alert below.
|
|
if ( ! data?.token ) {
|
|
const detail = data?.code
|
|
? `${data.code}: ${data.message ?? ''}`
|
|
: 'no response';
|
|
throw new Error(
|
|
`user-app token exchange returned no token (${detail})`,
|
|
);
|
|
}
|
|
const resp = await fetch(`${window.api_origin}/login/set`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
auth_token: data.token,
|
|
session: session,
|
|
}),
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`/login/set failed: ${resp.status} ${resp.statusText}`);
|
|
}
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
} catch (err) {
|
|
console.error(err);
|
|
await UIAlert({
|
|
message: "Couldn't sign you in. Please try again.",
|
|
});
|
|
}
|
|
return;
|
|
} else if ( runsUserAppTokenExchange(action) ) {
|
|
const deliver_token_to_opener = deliversTokenToOpener(action);
|
|
try {
|
|
let data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
|
|
// `getUserAppToken` reports a network failure by returning
|
|
// null, and an HTTP failure (a blocked origin, a 5xx) by
|
|
// returning the parsed *error* body — which is truthy but
|
|
// carries no token. Both mean the exchange did not happen, so
|
|
// say what went wrong instead of handing the opener an
|
|
// `undefined` token and, for actions that depend on the app row
|
|
// this bootstraps, prompting for a grant that could only fail.
|
|
if ( ! data?.token ) {
|
|
throw new Error('user-app token exchange returned no token');
|
|
}
|
|
// This is an implicit app and the app_uid is sent back from the server
|
|
// we cache it here so that we can use it later
|
|
window.host_app_uid = data.app_uid;
|
|
user_app_token = data.token;
|
|
// send token to parent. The opener is unreachable when it is
|
|
// cross-origin isolated (COOP severs the relationship); those
|
|
// flows learn the outcome server-side instead.
|
|
if ( deliver_token_to_opener ) {
|
|
window.opener?.postMessage({
|
|
msg: 'puter.token',
|
|
success: true,
|
|
token: data.token,
|
|
app_uid: data.app_uid,
|
|
username: window.user.username,
|
|
msg_id: msg_id,
|
|
}, window.openerOrigin);
|
|
}
|
|
// close popup
|
|
if ( !action || action === 'sign-in' ) {
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
}
|
|
} catch ( err ) {
|
|
// send error to parent
|
|
if ( deliver_token_to_opener ) {
|
|
window.opener?.postMessage({
|
|
msg: 'puter.token',
|
|
success: false,
|
|
token: null,
|
|
msg_id: msg_id,
|
|
}, window.openerOrigin);
|
|
// close popup
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
} else {
|
|
// The requester is waiting on a decision, not a token, so
|
|
// closing here would leave it with no answer at all. Record
|
|
// the failure and let the action below report a denial and
|
|
// close — the exchange is what bootstraps the app row a
|
|
// grant needs, so there is nothing to prompt about.
|
|
console.error('token exchange failed before permission prompt', err);
|
|
token_exchange_failed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
let app_uid;
|
|
|
|
if ( window.openerOrigin ) {
|
|
try {
|
|
// `getAppUIDFromOrigin` reports failure by resolving to a
|
|
// null/undefined uid, not by throwing — so check the value,
|
|
// and on either failure mode keep the host_app_uid set by
|
|
// the token exchange above, which resolved the same origin.
|
|
const resolved_app_uid = await window.getAppUIDFromOrigin(window.openerOrigin);
|
|
app_uid = resolved_app_uid ?? window.host_app_uid;
|
|
} catch (e) {
|
|
console.error('getAppUIDFromOrigin failed', e);
|
|
app_uid = window.host_app_uid;
|
|
}
|
|
window.host_app_uid = app_uid;
|
|
}
|
|
|
|
if ( action === 'show-open-file-picker' ) {
|
|
let options = window.url_query_params.get('options');
|
|
options = JSON.parse(options ?? '{}');
|
|
|
|
// Open dialog
|
|
UIWindow({
|
|
allowed_file_types: options?.accept,
|
|
selectable_body: options?.multiple,
|
|
path: `/${ window.user.username }/Desktop`,
|
|
// this is the uuid of the window to which this dialog will return
|
|
return_to_parent_window: true,
|
|
show_maximize_button: false,
|
|
show_minimize_button: false,
|
|
title: 'Open',
|
|
is_dir: true,
|
|
is_openFileDialog: true,
|
|
is_resizable: false,
|
|
has_head: false,
|
|
cover_page: true,
|
|
// selectable_body: is_selectable_body,
|
|
iframe_msg_uid: msg_id,
|
|
center: true,
|
|
initiating_app_uuid: app_uid,
|
|
on_close: function () {
|
|
window.opener.postMessage({
|
|
msg: 'fileOpenCanceled',
|
|
original_msg_id: msg_id,
|
|
}, '*');
|
|
},
|
|
});
|
|
}
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Show Directory Picker
|
|
//--------------------------------------------------------------------------------------
|
|
else if ( action === 'show-directory-picker' ) {
|
|
// open directory picker dialog
|
|
UIWindow({
|
|
path: `/${ window.user.username }/Desktop`,
|
|
// this is the uuid of the window to which this dialog will return
|
|
// parent_uuid: event.data.appInstanceID,
|
|
return_to_parent_window: true,
|
|
show_maximize_button: false,
|
|
show_minimize_button: false,
|
|
title: 'Open',
|
|
is_dir: true,
|
|
is_directoryPicker: true,
|
|
is_resizable: false,
|
|
has_head: false,
|
|
cover_page: true,
|
|
// selectable_body: is_selectable_body,
|
|
iframe_msg_uid: msg_id,
|
|
center: true,
|
|
initiating_app_uuid: app_uid,
|
|
on_close: function () {
|
|
window.opener.postMessage({
|
|
msg: 'directoryOpenCanceled',
|
|
original_msg_id: msg_id,
|
|
}, '*');
|
|
},
|
|
});
|
|
}
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Show Save File Dialog
|
|
//--------------------------------------------------------------------------------------
|
|
else if ( action === 'show-save-file-picker' ) {
|
|
let allowed_file_types = window.url_query_params.get('allowed_file_types');
|
|
|
|
// send 'sendMeFileData' event to parent
|
|
window.opener.postMessage({
|
|
msg: 'sendMeFileData',
|
|
}, '*');
|
|
|
|
// listen for 'showSaveFilePickerPopup' event from parent
|
|
window.addEventListener('message', async (event) => {
|
|
if ( event.data.msg !== 'showSaveFilePickerPopup' )
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Open dialog
|
|
UIWindow({
|
|
allowed_file_types: allowed_file_types,
|
|
path: `/${ window.user.username }/Desktop`,
|
|
// this is the uuid of the window to which this dialog will return
|
|
return_to_parent_window: true,
|
|
show_maximize_button: false,
|
|
show_minimize_button: false,
|
|
title: 'Save',
|
|
is_dir: true,
|
|
is_saveFileDialog: true,
|
|
is_resizable: false,
|
|
has_head: false,
|
|
cover_page: true,
|
|
// selectable_body: is_selectable_body,
|
|
iframe_msg_uid: msg_id,
|
|
center: true,
|
|
initiating_app_uuid: app_uid,
|
|
on_close: function () {
|
|
window.opener.postMessage({
|
|
msg: 'fileSaveCanceled',
|
|
original_msg_id: msg_id,
|
|
}, '*');
|
|
},
|
|
onSaveFileDialogSave: async function (target_path, el_filedialog_window) {
|
|
$(el_filedialog_window).find('.window-disable-mask, .busy-indicator').show();
|
|
let busy_init_ts = Date.now();
|
|
|
|
let overwrite = false;
|
|
let file_to_upload = new File([event.data.content], path.basename(target_path));
|
|
let item_with_same_name_already_exists = true;
|
|
while ( item_with_same_name_already_exists ) {
|
|
// overwrite?
|
|
if ( overwrite )
|
|
{
|
|
item_with_same_name_already_exists = false;
|
|
}
|
|
// upload
|
|
try {
|
|
const res = await puter.fs.write(
|
|
target_path,
|
|
file_to_upload,
|
|
{
|
|
dedupeName: false,
|
|
overwrite: overwrite,
|
|
},
|
|
);
|
|
|
|
let file_signature = await puter.fs.sign(app_uid, { uid: res.uid, action: 'write' });
|
|
file_signature = file_signature.items;
|
|
|
|
item_with_same_name_already_exists = false;
|
|
window.opener.postMessage({
|
|
msg: 'fileSaved',
|
|
original_msg_id: msg_id,
|
|
filename: res.name,
|
|
saved_file: {
|
|
name: file_signature.fsentry_name,
|
|
readURL: file_signature.read_url,
|
|
writeURL: file_signature.write_url,
|
|
metadataURL: file_signature.metadata_url,
|
|
type: file_signature.type,
|
|
uid: file_signature.uid,
|
|
path: window.privacy_aware_path(res.path),
|
|
},
|
|
}, '*');
|
|
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
}
|
|
catch ( err ) {
|
|
// item with same name exists
|
|
if ( err.code === 'item_with_same_name_exists' ) {
|
|
const alert_resp = await UIAlert({
|
|
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
|
|
buttons: [
|
|
{
|
|
label: i18n('replace'),
|
|
value: 'replace',
|
|
type: 'primary',
|
|
},
|
|
{
|
|
label: i18n('cancel'),
|
|
value: 'cancel',
|
|
},
|
|
],
|
|
parent_uuid: $(el_filedialog_window).attr('data-element_uuid'),
|
|
});
|
|
if ( alert_resp === 'replace' ) {
|
|
overwrite = true;
|
|
} else if ( alert_resp === 'cancel' ) {
|
|
// enable parent window
|
|
$(el_filedialog_window).find('.window-disable-mask, .busy-indicator').hide();
|
|
return;
|
|
}
|
|
}
|
|
else {
|
|
console.log(err);
|
|
// show error
|
|
await UIAlert({
|
|
message: err.message ?? 'Upload failed.',
|
|
parent_uuid: $(el_filedialog_window).attr('data-element_uuid'),
|
|
});
|
|
// enable parent window
|
|
$(el_filedialog_window).find('.window-disable-mask, .busy-indicator').hide();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// done
|
|
let busy_duration = (Date.now() - busy_init_ts);
|
|
if ( busy_duration >= window.busy_indicator_hide_delay ) {
|
|
$(el_filedialog_window).close();
|
|
} else {
|
|
setTimeout(() => {
|
|
// close this dialog
|
|
$(el_filedialog_window).close();
|
|
}, Math.abs(window.busy_indicator_hide_delay - busy_duration));
|
|
}
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Action: Request Permission — show the permission dialog and report the user's
|
|
// decision back to the opener (popup flow) or the parent frame (iframe embed).
|
|
// Runs post-auth so signed-out users go through sign-in/signup first.
|
|
// -------------------------------------------------------------------------------------
|
|
if ( action === 'request-permission' ) {
|
|
// Repeated `permission=` params: one prompt can cover several scopes.
|
|
// Capped because this URL is supplied by whoever opened the popup, and an
|
|
// unbounded list would put an unreadable consent prompt in front of the
|
|
// user. Over the cap we drop the request rather than truncate it, since a
|
|
// silently shortened list would grant less than the dialog described.
|
|
const MAX_REQUESTED_PERMISSIONS = 16;
|
|
const requested_permissions = window.url_query_params
|
|
.getAll('permission')
|
|
.filter(Boolean);
|
|
const permissions = requested_permissions.length <= MAX_REQUESTED_PERMISSIONS
|
|
? requested_permissions
|
|
: [];
|
|
const msg_id = window.url_query_params.get('msg_id');
|
|
// Browser-attested only: `openerOrigin` is the referrer, or the opener's
|
|
// own reply to the `requestOrigin` handshake. There is deliberately no
|
|
// query-string fallback — the origin is the requester's identity, naming
|
|
// who the dialog attributes the request to and picking the app the grant
|
|
// is written against, so a link must not get to state it. That is the
|
|
// same rule that keeps `app_uid` out of this URL, and the SDK never sends
|
|
// an origin either. Without one there is nothing to prompt about and the
|
|
// denial below is reported as usual.
|
|
const origin = window.openerOrigin;
|
|
|
|
// Whatever happens, the requester must get an answer and the popup
|
|
// must close — otherwise the popup wedges open with the caller's
|
|
// promise pending until the user closes it by hand.
|
|
let granted = false;
|
|
try {
|
|
// Prompting is pointless when the app row a grant needs was never
|
|
// bootstrapped — "Allow" could only fail. Report the denial.
|
|
if ( token_exchange_failed ) {
|
|
throw new Error('token exchange failed; not prompting');
|
|
}
|
|
// A signed-out opener holds no token to settle this for itself, so
|
|
// ask here, as the app, with the token the exchange just minted.
|
|
const already_held = await holdsPermissions(permissions, user_app_token);
|
|
// The requesting app is identified by its origin, and only the
|
|
// server turns that origin into a grant target. No uid is sent
|
|
// from here: a uid from the query string is chosen by whoever
|
|
// opened this page, and even a uid resolved through
|
|
// `getAppUIDFromOrigin` is unsafe to forward, because an origin
|
|
// with no app row of its own resolves to a *synthetic*
|
|
// `app-<uuidv5(origin)>`. The grant endpoint resolves `app_uid`
|
|
// as uid-or-name, so forwarding that synthetic uid would hand
|
|
// the grant to whoever registered an app under that literal
|
|
// name. Passing the origin instead makes the server resolve the
|
|
// same origin the dialog displayed, and reject it outright
|
|
// unless it names an app that really exists.
|
|
granted = already_held || await UIPermissionDialog({
|
|
// See IPC.js: both forms, so a single scope still works with a
|
|
// dialog that only understands the scalar.
|
|
permissions,
|
|
permission: permissions.length === 1 ? permissions[0] : undefined,
|
|
origin: origin,
|
|
});
|
|
} catch (e) {
|
|
console.error('request-permission action failed', e);
|
|
}
|
|
|
|
// `postMessage` throws a SyntaxError on a targetOrigin that isn't a
|
|
// parseable URL, and `origin` is caller-supplied — an unparseable one
|
|
// would take out the answer *and* the close below.
|
|
let target_origin = '*';
|
|
try {
|
|
target_origin = origin ? new URL(origin).origin : '*';
|
|
} catch (e) {
|
|
console.error('request-permission: unusable origin', origin);
|
|
}
|
|
const messageTarget = window.embedded_in_popup ? window.opener : window.parent;
|
|
try {
|
|
messageTarget?.postMessage({
|
|
msg: 'permissionGranted',
|
|
granted: granted === true,
|
|
original_msg_id: msg_id,
|
|
}, target_origin);
|
|
} catch (e) {
|
|
console.error('request-permission: could not answer the requester', e);
|
|
}
|
|
|
|
// The popup exists only to host this dialog; close it once answered.
|
|
if ( window.embedded_in_popup ) {
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Action: Send Feedback — show the app-feedback dialog for the site that opened this
|
|
// popup and report whether feedback was sent back to the opener. Runs post-auth so
|
|
// signed-out users go through sign-in/signup first.
|
|
// -------------------------------------------------------------------------------------
|
|
if ( action === 'send-feedback' ) {
|
|
const msg_id = window.url_query_params.get('msg_id');
|
|
// Browser-attested only, same rule as request-permission above: the
|
|
// origin names the app the feedback is recorded against (the server
|
|
// resolves origin → app), so a link must not get to state it. The
|
|
// dialog itself refuses when the resolved app hasn't opted in.
|
|
const origin = window.openerOrigin;
|
|
|
|
// Whatever happens, the requester must get an answer and the popup
|
|
// must close — otherwise it wedges open with the caller's promise
|
|
// pending until the user closes it by hand.
|
|
let sent = false;
|
|
try {
|
|
if ( ! origin ) {
|
|
throw new Error('no opener origin; not prompting');
|
|
}
|
|
sent = await UIWindowAppFeedback({
|
|
origin,
|
|
source: 'web',
|
|
});
|
|
} catch (e) {
|
|
console.error('send-feedback action failed', e);
|
|
}
|
|
|
|
// `postMessage` throws a SyntaxError on a targetOrigin that isn't a
|
|
// parseable URL — an unparseable one would take out the answer *and*
|
|
// the close below.
|
|
let target_origin = '*';
|
|
try {
|
|
target_origin = origin ? new URL(origin).origin : '*';
|
|
} catch (e) {
|
|
console.error('send-feedback: unusable origin', origin);
|
|
}
|
|
const messageTarget = window.embedded_in_popup ? window.opener : window.parent;
|
|
try {
|
|
messageTarget?.postMessage({
|
|
msg: 'feedbackDialogClosed',
|
|
sent: sent === true,
|
|
original_msg_id: msg_id,
|
|
}, target_origin);
|
|
} catch (e) {
|
|
console.error('send-feedback: could not answer the requester', e);
|
|
}
|
|
|
|
// The popup exists only to host this dialog; close it once answered.
|
|
if ( window.embedded_in_popup ) {
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
}
|
|
}
|
|
};
|
|
|
|
const launch_services = async function (options) {
|
|
// === Services Data Structures ===
|
|
const services_l_ = [];
|
|
const services_m_ = {};
|
|
globalThis.services = {
|
|
get: (name) => services_m_[name],
|
|
emit: (id, args) => {
|
|
for (const [_, instance] of services_l_) {
|
|
instance.__on(id, args ?? []);
|
|
}
|
|
},
|
|
};
|
|
const register = (name, instance) => {
|
|
services_l_.push([name, instance]);
|
|
services_m_[name] = instance;
|
|
};
|
|
|
|
globalThis.def(UIComponentWindow, 'ui.UIComponentWindow');
|
|
|
|
// === Hooks for Service Scripts from Backend ===
|
|
const service_script_deferred = { services: [], on_ready: [] };
|
|
const service_script_api = {
|
|
register: (...a) => service_script_deferred.services.push(a),
|
|
on_ready: (fn) => service_script_deferred.on_ready.push(fn),
|
|
// Some files can't be imported by service scripts,
|
|
// so this hack makes that possible.
|
|
def: globalThis.def,
|
|
use: globalThis.use,
|
|
// use: name => ({ UIWindow, UIComponentWindow })[name],
|
|
};
|
|
globalThis.service_script_api_promise.resolve(service_script_api);
|
|
|
|
// === Builtin Services ===
|
|
register('ipc', new IPCService());
|
|
register('exec', new ExecService());
|
|
register('pip', new PictureInPictureService());
|
|
register('debug', new DebugService());
|
|
register('broadcast', new BroadcastService());
|
|
register('theme', new ThemeService());
|
|
register('process', new ProcessService());
|
|
register('locale', new LocaleService());
|
|
register('anti-csrf', new AntiCSRFService());
|
|
register('__launch-on-init', new LaunchOnInitService());
|
|
|
|
// === Service-Script Services ===
|
|
for (const [name, script] of service_script_deferred.services) {
|
|
register(name, script);
|
|
}
|
|
|
|
for (const [_, instance] of services_l_) {
|
|
await instance.construct({
|
|
gui_params: options,
|
|
});
|
|
}
|
|
|
|
for (const [_, instance] of services_l_) {
|
|
await instance.init({
|
|
services: globalThis.services,
|
|
});
|
|
}
|
|
|
|
// === Service-Script Ready ===
|
|
for (const fn of service_script_deferred.on_ready) {
|
|
await fn();
|
|
}
|
|
|
|
// Set init process status
|
|
{
|
|
const svc_process = globalThis.services.get('process');
|
|
svc_process.get_init().chstatus(PROCESS_RUNNING);
|
|
}
|
|
};
|
|
|
|
// This code snippet addresses the issue flagged by Lighthouse regarding the use of
|
|
// passive event listeners to enhance scrolling performance. It provides custom
|
|
// implementations for touchstart, touchmove, wheel, and mousewheel events in jQuery.
|
|
// By setting the 'passive' option appropriately, it ensures that default browser
|
|
// behavior is prevented when necessary, thereby improving page scroll performance.
|
|
// More info: https://stackoverflow.com/a/62177358
|
|
if (jQuery) {
|
|
jQuery.event.special.touchstart = {
|
|
setup: function (_, ns, handle) {
|
|
this.addEventListener('touchstart', handle, {
|
|
passive: !ns.includes('noPreventDefault'),
|
|
});
|
|
},
|
|
};
|
|
jQuery.event.special.touchmove = {
|
|
setup: function (_, ns, handle) {
|
|
this.addEventListener('touchmove', handle, {
|
|
passive: !ns.includes('noPreventDefault'),
|
|
});
|
|
},
|
|
};
|
|
jQuery.event.special.wheel = {
|
|
setup: function (_, ns, handle) {
|
|
this.addEventListener('wheel', handle, { passive: true });
|
|
},
|
|
};
|
|
jQuery.event.special.mousewheel = {
|
|
setup: function (_, ns, handle) {
|
|
this.addEventListener('mousewheel', handle, { passive: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
// are we in dashboard mode?
|
|
// The dashboard is the default interface at the root path; `/dashboard` is kept as an
|
|
// alias, and `/desktop` loads the desktop instead. Direct app landings (`/app/<name>`)
|
|
// open in the dashboard too: the app comes up maximized in-page with the dashboard
|
|
// route slotted underneath (see postAuthActions), so Back minimizes to the dashboard.
|
|
// To land the same app on the desktop instead, prefix the path:
|
|
// `/desktop/app/<name>` doesn't match the dashboard paths below, so it falls
|
|
// through to the desktop.
|
|
// URLs that carry a desktop-only flow keep booting the desktop: auth popups
|
|
// (`?embedded_in_popup=`), app deep links (`?app=`), direct downloads (`?download=`),
|
|
// fullpage mode (`?puter.fullpage=`), and iframe embeds. A share link (`?shared=`,
|
|
// from an email) lands in the dashboard: Files, on Shared, with the items it names
|
|
// picked out; `/desktop?shared=` opens the item on the desktop instead. App metadata
|
|
// like fullpage_on_landing does NOT opt a landing out of the dashboard; it only
|
|
// affects boots that still go through the desktop flow.
|
|
{
|
|
const pathname = window.location.pathname;
|
|
const search_params = new URLSearchParams(window.location.search);
|
|
if (['true', '1'].includes(search_params.get('embedded_in_popup'))) {
|
|
window.embedded_in_popup = true;
|
|
}
|
|
// note: iframe detection is inlined because globals.js (window.is_embedded) loads after this module
|
|
const in_iframe = window.location !== window.parent.location;
|
|
const needs_desktop_at_root =
|
|
window.embedded_in_popup ||
|
|
in_iframe ||
|
|
search_params.has('puter.fullpage') ||
|
|
search_params.has('app') ||
|
|
search_params.has('download');
|
|
const is_dashboard_alias =
|
|
pathname === '/dashboard' || pathname === '/dashboard/';
|
|
const is_app_landing = /^\/app\/[^/]+\/?$/.test(pathname);
|
|
if (is_dashboard_alias || ((pathname === '/' || is_app_landing) && !needs_desktop_at_root)) {
|
|
window.is_dashboard_mode = true;
|
|
window.dashboard_initial_route = parseDashboardRoute();
|
|
// A share link outranks the hash: Files, on Shared, with what it names
|
|
// picked out. The values go through raw — the Files tab validates them.
|
|
if (search_params.has('shared')) {
|
|
window.dashboard_initial_route = {
|
|
tab: 'files',
|
|
path: null,
|
|
shared: search_params.getAll('shared'),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses the dashboard URL hash into a route object.
|
|
* Apps is the default tab (root URL / no hash); Home is reached via #home.
|
|
* Hash format: #files/username/Documents or #home or #usage or #account etc.
|
|
* @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path
|
|
*/
|
|
function parseDashboardRoute() {
|
|
// decodeURIComponent throws URIError on a malformed percent-sequence (e.g.
|
|
// `#100%`). This runs at module load, so an unguarded throw blanks the whole
|
|
// GUI — fall back to the raw hash instead.
|
|
const rawHash = window.location.hash.slice(1); // Remove '#'
|
|
let hash;
|
|
try {
|
|
hash = decodeURIComponent(rawHash);
|
|
} catch {
|
|
hash = rawHash;
|
|
}
|
|
if (!hash) return { tab: 'apps', path: null };
|
|
|
|
const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents']
|
|
const tab = parts[0]; // 'files', 'usage', 'account', 'security'
|
|
|
|
if (tab === 'files' && parts.length > 1) {
|
|
const filePath = `/${parts.slice(1).join('/')}`; // /username/Documents
|
|
return { tab: 'files', path: filePath };
|
|
}
|
|
return { tab: tab || 'apps', path: null };
|
|
}
|
|
|
|
// Make parseDashboardRoute available globally for hashchange handler
|
|
window.parseDashboardRoute = parseDashboardRoute;
|
|
|
|
/**
|
|
* Display text for an auth error redirect (`?auth_error=1&message=<code>`).
|
|
* The backend sends a fixed set of codes — never free text — plus, for a
|
|
* blocked signup, a `request_code` the user can quote to support. Codes map
|
|
* to translated messages here; anything unrecognized (including text crafted
|
|
* directly into the URL) gets the generic message rather than being shown.
|
|
*/
|
|
function authErrorDisplayMessage() {
|
|
const code = window.url_query_params.get('message');
|
|
const requestCode = window.url_query_params.get('request_code');
|
|
if (code === 'signup_blocked') {
|
|
const contact = requestCode
|
|
? i18n('contact_support_with_code', { id: requestCode }, false)
|
|
: i18n('contact_support', [], false);
|
|
return `${i18n('signup_blocked_message', [], false)} ${contact}`;
|
|
}
|
|
if (code === 'account_suspended') {
|
|
return i18n('account_suspended_message', [], false);
|
|
}
|
|
return i18n('auth_error_generic', [], false);
|
|
}
|
|
|
|
/**
|
|
* Shows a Turnstile challenge modal for first-time temp user creation
|
|
* @param {Object} options - Configuration options
|
|
* @param {Function} options.onSuccess - Callback when challenge is completed successfully
|
|
* @param {Function} options.onError - Callback when challenge fails
|
|
*/
|
|
window.showTurnstileChallenge = function (options) {
|
|
return new Promise((resolve) => {
|
|
const modalId = 'turnstile-challenge-modal';
|
|
const siteKey = window.gui_params?.turnstileSiteKey;
|
|
|
|
if (!siteKey) {
|
|
options.onError('Turnstile site key not configured');
|
|
return resolve();
|
|
}
|
|
|
|
// message
|
|
let message = 'Setting up your account...';
|
|
if (window.embedded_in_popup) {
|
|
message =
|
|
'Setting up your <a href="https://puter.com" target="_blank">Puter.com</a> account...';
|
|
}
|
|
// Create modal HTML
|
|
let modalHtml = `
|
|
<div id="${modalId}" class="captcha-modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header" style="margin-bottom: 20px;">
|
|
<img src="${window.icons['logo-white.svg']}" class="captcha-logo">
|
|
<h2 class="captcha-title">Welcome to Puter!</h2>
|
|
</div>
|
|
|
|
<div class="captcha-container">
|
|
<div id="captcha-widget-${modalId}" data-sitekey="${siteKey}"></div>
|
|
</div>
|
|
|
|
<div class="loading-state">
|
|
<div class="loading-state-icon"></div>
|
|
${message}
|
|
</div>
|
|
|
|
<div class="error-message"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add modal to DOM
|
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
const modal = document.getElementById(modalId);
|
|
const errorMessage = modal.querySelector('.error-message');
|
|
const loadingState = modal.querySelector('.loading-state');
|
|
const turnstileContainer = modal.querySelector('.captcha-container');
|
|
|
|
// Initialize Turnstile widget
|
|
const initTurnstile = () => {
|
|
if (!window.turnstile) {
|
|
setTimeout(initTurnstile, 100);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.turnstile.render(`#captcha-widget-${modalId}`, {
|
|
sitekey: siteKey,
|
|
callback: function (token) {
|
|
window.turnstile_success_ts = Date.now();
|
|
|
|
// Show loading state
|
|
$(turnstileContainer).hide();
|
|
$(loadingState).show();
|
|
|
|
// Call success callback
|
|
options.onSuccess(token);
|
|
|
|
// resolve the promise
|
|
resolve();
|
|
},
|
|
'expired-callback': function () {
|
|
showError('Verification expired. Please try again.');
|
|
},
|
|
'error-callback': function () {
|
|
showError(
|
|
'Verification failed. Please refresh the page and try again.',
|
|
);
|
|
options.onError('Turnstile verification failed');
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to initialize Turnstile:', error);
|
|
showError(
|
|
'Failed to load security verification. Please refresh the page.',
|
|
);
|
|
options.onError(error);
|
|
}
|
|
};
|
|
|
|
const showError = (message) => {
|
|
errorMessage.textContent = message;
|
|
errorMessage.style.display = 'block';
|
|
};
|
|
|
|
// Start initialization
|
|
initTurnstile();
|
|
|
|
// Prevent modal from closing by clicking outside
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
// Don't close - force users to complete verification
|
|
turnstileContainer.style.transform = 'scale(1.05)';
|
|
setTimeout(() => {
|
|
if (turnstileContainer) {
|
|
turnstileContainer.style.transform = 'scale(1)';
|
|
}
|
|
}, 200);
|
|
}
|
|
});
|
|
|
|
// Add transition styles
|
|
modal.style.opacity = '0';
|
|
modal.style.transition = 'opacity 0.3s ease';
|
|
|
|
// Fade in
|
|
requestAnimationFrame(() => {
|
|
modal.style.opacity = '1';
|
|
});
|
|
});
|
|
};
|
|
|
|
window.initgui = async function (options) {
|
|
const url = new URL(window.location).href;
|
|
window.url = url;
|
|
// Route segments with a leading `/desktop` dropped, so the route checks
|
|
// downstream (app landings, actions) never have to know about the prefix:
|
|
// `/desktop/app/<name>` opens the app on the desktop the same way
|
|
// `/app/<name>` opens it in the dashboard.
|
|
const url_paths = parse_url_paths(window.location.pathname);
|
|
window.url_paths = url_paths;
|
|
|
|
// GET query params provided
|
|
window.url_query_params = new URLSearchParams(window.location.search);
|
|
|
|
// Install device signal helpers; collection is lazy. The fingerprint is
|
|
// on by default (gui_params.thumbmarkEnabled = false kills it); the Prelude
|
|
// dispatch id needs gui_params.preludeSdkKey.
|
|
init_device_signals();
|
|
|
|
let picked_a_user_for_sdk_login = false;
|
|
|
|
// update SDK if auth_token is different from the one in the SDK
|
|
if (window.auth_token && puter.authToken !== window.auth_token) {
|
|
puter.setAuthToken(window.auth_token);
|
|
}
|
|
// Point the SDK at this deployment's own API. Anywhere but a Puter served
|
|
// off the developer's own machine, that is the only origin we'll use — see
|
|
// resolveAPIOrigin for why, and for the dev flow that is the exception.
|
|
if (window.api_origin) {
|
|
const api_origin = resolveAPIOrigin({
|
|
configuredOrigin: window.api_origin,
|
|
guiOrigin: window.location.origin,
|
|
urlOrigin: window.url_query_params.get('api_origin'),
|
|
storedOrigin: localStorage.getItem('api_origin'),
|
|
});
|
|
if (api_origin === window.api_origin) {
|
|
localStorage.removeItem('api_origin');
|
|
} else {
|
|
// Sticks for the rest of the session: only the boot that carried
|
|
// the parameter has it in the URL.
|
|
localStorage.setItem('api_origin', api_origin);
|
|
window.api_origin = api_origin;
|
|
}
|
|
if (puter.APIOrigin !== api_origin) puter.setAPIOrigin(api_origin);
|
|
}
|
|
|
|
// Print the version to the console
|
|
puter.os
|
|
.version()
|
|
.then((res) => {
|
|
const deployed_date = new Date(res.deploy_timestamp);
|
|
console.log(
|
|
`Your Puter information:\n• Version: ${res.version}\n• Server: ${res.location}\n• Deployed: ${deployed_date}`,
|
|
);
|
|
})
|
|
.catch((error) => {
|
|
console.error('Failed to fetch server info:', error);
|
|
});
|
|
|
|
// Checks the type of device the user is on (phone, tablet, or desktop).
|
|
// Depending on the device type, it sets a class attribute on the body tag
|
|
// to style or script the page differently for each device type.
|
|
|
|
if (isMobile.phone) {
|
|
$('body').attr('class', 'device-phone');
|
|
} else if (isMobile.tablet) {
|
|
// This is our new, smarter check for tablets
|
|
if (
|
|
window.matchMedia &&
|
|
typeof window.matchMedia === 'function' &&
|
|
window.matchMedia('(hover: hover)').matches
|
|
) {
|
|
// The user has a mouse/trackpad, so give them the desktop UI
|
|
$('body').attr('class', 'device-desktop');
|
|
} else {
|
|
// The user is on a touch-only tablet, so give them the mobile UI
|
|
$('body').attr('class', 'device-tablet');
|
|
}
|
|
} else {
|
|
$('body').attr('class', 'device-desktop');
|
|
}
|
|
|
|
// Appends a meta tag to the head of the document specifying the character encoding to be UTF-8.
|
|
// This ensures that special characters and symbols display correctly across various platforms and browsers.
|
|
$('head').append('<meta charset="utf-8">');
|
|
|
|
// Appends a viewport meta tag to the head of the document, ensuring optimal display on mobile devices.
|
|
// This tag sets the width of the viewport to the device width, and locks the zoom level to 1 (prevents user scaling).
|
|
$('head').append(
|
|
'<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
|
|
);
|
|
|
|
// will hold the result of the whoami API call
|
|
let whoami;
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Extract 'action' from URL
|
|
//--------------------------------------------------------------------------------------
|
|
let action;
|
|
if (
|
|
window.url_paths[0]?.toLocaleLowerCase() === 'action' &&
|
|
window.url_paths[1]
|
|
) {
|
|
action = window.url_paths[1].toLowerCase();
|
|
} else if (window.url_query_params.has('action')) {
|
|
action = window.url_query_params.get('action').toLowerCase();
|
|
}
|
|
// Published for the windows that open mid-flow and have to know what this
|
|
// page is for — the login/signup windows consult it before offering a
|
|
// federated sign-in hop that would navigate the popup away and lose the
|
|
// action. See util/popupAuth.js.
|
|
window.gui_action = action;
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Determine if we are in full-page mode
|
|
// i.e. https://puter.com/app/<app_name>/?puter.fullpage=true
|
|
//--------------------------------------------------------------------------------------
|
|
if (
|
|
window.url_query_params.has('puter.fullpage') &&
|
|
(window.url_query_params.get('puter.fullpage') === 'false' ||
|
|
window.url_query_params.get('puter.fullpage') === '0')
|
|
) {
|
|
window.is_fullpage_mode = false;
|
|
} else if (
|
|
window.url_query_params.has('puter.fullpage') &&
|
|
(window.url_query_params.get('puter.fullpage') === 'true' ||
|
|
window.url_query_params.get('puter.fullpage') === '1')
|
|
) {
|
|
// In fullpage mode, we want to hide the taskbar for better UX
|
|
window.taskbar_height = 0;
|
|
|
|
// Puter is in fullpage mode.
|
|
window.is_fullpage_mode = true;
|
|
} else if (window.is_dashboard_mode) {
|
|
window.is_fullpage_mode = true;
|
|
}
|
|
|
|
// Launch services before any UI is rendered
|
|
await launch_services(options);
|
|
|
|
// If no token in storage but we have a session cookie (e.g. after OIDC redirect), fetch GUI token
|
|
try {
|
|
const r = await fetch(`${window.gui_origin}/get-gui-token`, {
|
|
credentials: 'include',
|
|
});
|
|
if (r.ok) {
|
|
const { token } = await r.json();
|
|
window.auth_token = token;
|
|
// Write the current key; drop any value under the retired one.
|
|
localStorage.setItem(
|
|
window.AUTH_TOKEN_KEY_V2 || 'auth_token_v2',
|
|
token,
|
|
);
|
|
try {
|
|
localStorage.removeItem(
|
|
window.AUTH_TOKEN_KEY_RETIRED || 'auth_token',
|
|
);
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
if (typeof puter !== 'undefined')
|
|
puter.setAuthToken(token, window.api_origin);
|
|
const tokenChanged = token !== window.auth_token;
|
|
if (tokenChanged) {
|
|
// This will update the list of logged in users and set the current one
|
|
try {
|
|
const whoami = await puter.os.user({
|
|
query: 'icon_size=64',
|
|
});
|
|
if (whoami) await window.update_auth_data(token, whoami);
|
|
} catch (e) {
|
|
console.error(
|
|
'get-gui-token follow-up whoami/update_auth_data',
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('get-gui-token', e);
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Is attempt_temp_user_creation?
|
|
// i.e. https://puter.com/?attempt_temp_user_creation=true
|
|
//--------------------------------------------------------------------------------------
|
|
if (
|
|
window.url_query_params.has('attempt_temp_user_creation') &&
|
|
(window.url_query_params.get('attempt_temp_user_creation') === 'true' ||
|
|
window.url_query_params.get('attempt_temp_user_creation') === '1')
|
|
) {
|
|
window.attempt_temp_user_creation = true;
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Is GUI embedded in a popup?
|
|
// i.e. https://puter.com/?embedded_in_popup=true
|
|
// (the flag itself is parsed once at module level, alongside the dashboard-mode check)
|
|
//--------------------------------------------------------------------------------------
|
|
if (window.embedded_in_popup) {
|
|
$('body').addClass('embedded-in-popup');
|
|
|
|
// Determine the origin of the opener. This is the one assignment that
|
|
// matters: the token exchange, `checkUserSiteRelationship`,
|
|
// `getAppUIDFromOrigin` and both `postMessage` targets all read
|
|
// `window.openerOrigin`, so every one of them is only as trustworthy
|
|
// as this line.
|
|
//
|
|
// An OIDC redirect drops `document.referrer` — it returns the popup
|
|
// with the *provider* as referrer — so the opener's origin has to
|
|
// survive the hop. It does, inside the signed `state`, but the backend
|
|
// used to flatten it into a bare `opener_origin` parameter: a URL
|
|
// built from a verified state is byte-identical to one anybody can
|
|
// type, and the popup believed both. Now the return leg carries a
|
|
// signed proof, redeemed here for the value the server actually
|
|
// attested. Everything else falls back to browser-attested sources.
|
|
window.oidcPopupReturn = await verifyOidcPopupReturn(
|
|
window.url_query_params.get('opener_state'),
|
|
window.url_query_params.get('msg_id'),
|
|
);
|
|
window.openerOrigin =
|
|
window.oidcPopupReturn?.opener_origin || document.referrer;
|
|
if (!window.openerOrigin) {
|
|
try {
|
|
window.openerOrigin = await requestOpenerOrigin();
|
|
} catch (e) {
|
|
throw new Error('No referrer found');
|
|
}
|
|
}
|
|
|
|
// this is the referrer in terms of user acquisition
|
|
window.referrerStr = window.openerOrigin;
|
|
|
|
// Tell a request-permission opener that this popup can reach it. Sent
|
|
// here, before any sign-in gate, because the SDK reads its absence as
|
|
// "the opener link was severed by COOP" — where a close means the
|
|
// prompt is still live in a window that cannot answer, and the decision
|
|
// has to be read back from the server rather than reported as a denial.
|
|
// A gate that delayed this would make abandoning sign-in look severed.
|
|
// Carries no token: see util/popupAuth.js for what a permission popup
|
|
// may hand its opener.
|
|
if (action === 'request-permission') {
|
|
try {
|
|
window.opener?.postMessage(
|
|
{
|
|
msg: 'permissionPromptReady',
|
|
original_msg_id:
|
|
window.url_query_params.get('msg_id'),
|
|
},
|
|
new URL(window.openerOrigin).origin,
|
|
);
|
|
} catch (e) {
|
|
// An unparseable opener origin, or an opener that went away.
|
|
// The requester falls back to reading the decision from the
|
|
// server, so this must not take the popup down with it.
|
|
console.error(
|
|
'request-permission: could not announce the popup',
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
action === 'sign-in' &&
|
|
!window.is_auth() &&
|
|
!(window.attempt_temp_user_creation && window.first_visit_ever)
|
|
) {
|
|
// show signup window
|
|
if (
|
|
await UIWindowSignup({
|
|
reload_on_success: false,
|
|
send_confirmation_code: true,
|
|
show_close_button: false,
|
|
window_options: {
|
|
has_head: false,
|
|
cover_page: true,
|
|
},
|
|
})
|
|
) {
|
|
// Completing signup in a sign-in popup is the user asking to
|
|
// be signed in to the opener.
|
|
window.popup_signin_consent = true;
|
|
await window.getUserAppToken(window.openerOrigin);
|
|
}
|
|
} else if (
|
|
// An action-less popup is a sign-in popup — `postAuthActions`
|
|
// already treats it as one when it decides to close the window,
|
|
// and it ends in the same token hand-off. It has to reach the
|
|
// same account picker too: leaving it out meant the one popup
|
|
// shape that shows the user nothing was also the one that minted
|
|
// a token for the opener without being asked.
|
|
(action === 'sign-in' || !action) &&
|
|
window.is_auth() &&
|
|
!(window.attempt_temp_user_creation && window.first_visit_ever)
|
|
) {
|
|
// Ensure current user is in logged_in_users (e.g. after OIDC redirect we have token but no user in list)
|
|
try {
|
|
const whoami_popup = await puter.os.user({
|
|
query: 'icon_size=64',
|
|
});
|
|
await window.update_auth_data(
|
|
whoami_popup.token || window.auth_token,
|
|
whoami_popup,
|
|
);
|
|
} catch (e) {
|
|
// session/auth errors will be handled further ahead;
|
|
// let's log the error for now in case a change in state occurred.
|
|
console.error("error in 'sign-in' flow", e);
|
|
}
|
|
|
|
// An OIDC login that just completed may skip the account picker —
|
|
// the user chose their account at the provider moments ago. That
|
|
// comes from the same signed proof as the opener's origin, rather
|
|
// than the `oidc_login` query parameter it used to be read from:
|
|
// as a bare parameter anyone could write it, and it suppresses the
|
|
// one prompt standing between a link and a token.
|
|
if (window.oidcPopupReturn?.oidc_login) {
|
|
picked_a_user_for_sdk_login = true;
|
|
await window.getUserAppToken(window.openerOrigin);
|
|
} else {
|
|
// Show session list so user can pick which account to use
|
|
picked_a_user_for_sdk_login = await UIWindowSessionList({
|
|
reload_on_success: false,
|
|
draggable_body: false,
|
|
has_head: false,
|
|
cover_page: true,
|
|
});
|
|
|
|
if (picked_a_user_for_sdk_login) {
|
|
await window.getUserAppToken(window.openerOrigin);
|
|
}
|
|
}
|
|
// Picking an account here *is* the consent to sign the opener in.
|
|
// `postAuthActions` runs later and unconditionally, so it needs to
|
|
// know whether that decision was ever made — dismissing the picker
|
|
// has to mean the opener gets nothing, not just that the early
|
|
// token exchange was skipped.
|
|
window.popup_signin_consent = !!picked_a_user_for_sdk_login;
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Display an error if the query parameters have an error
|
|
//--------------------------------------------------------------------------------------
|
|
if (window.url_query_params.has('error')) {
|
|
// TODO: i18n
|
|
await UIAlert({
|
|
message: window.url_query_params.get('message'),
|
|
});
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Inform the user if they chose "signup" but were logged into an existing account
|
|
//--------------------------------------------------------------------------------------
|
|
if (
|
|
window.url_query_params.get('oidc_switched') === 'login' &&
|
|
window.is_auth()
|
|
) {
|
|
await UIAlert({
|
|
message: i18n('oidc_switched_to_login_message'),
|
|
});
|
|
const params = new URLSearchParams(window.location.search);
|
|
params.delete('oidc_switched');
|
|
const cleanSearch = params.toString();
|
|
const cleanUrl = cleanSearch
|
|
? `${window.location.pathname}?${cleanSearch}`
|
|
: window.location.pathname || '/';
|
|
window.history.replaceState(null, document.title, cleanUrl);
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Early check for fullpage mode from app metadata
|
|
// If the user navigated to /app/<app_name> and the app has fullpage_on_landing,
|
|
// set fullpage mode now so we can skip loading the desktop background and items.
|
|
// Dashboard mode never reaches the fetch (it sets is_fullpage_mode itself): app
|
|
// landings open in the dashboard regardless of fullpage_on_landing — the flag only
|
|
// matters for the boots that still go through the desktop flow (embeds, popups).
|
|
//--------------------------------------------------------------------------------------
|
|
if (
|
|
!window.is_fullpage_mode &&
|
|
window.url_paths[0]?.toLocaleLowerCase() === 'app' &&
|
|
window.url_paths[1]
|
|
) {
|
|
try {
|
|
const app_info = await puter.apps.get(window.url_paths[1], {
|
|
icon_size: 64,
|
|
});
|
|
if (app_info?.metadata?.fullpage_on_landing) {
|
|
window.is_fullpage_mode = true;
|
|
window.taskbar_height = 0;
|
|
window.app_launched_from_url = app_info;
|
|
}
|
|
} catch (e) {
|
|
// App metadata fetch failed; will retry later in UIDesktop
|
|
}
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Desktop background (early)
|
|
// Set before action=login/signup so OIDC error redirects show the background behind the form.
|
|
// -------------------------------------------------------------------------------------
|
|
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
|
|
window.refresh_desktop_background();
|
|
}
|
|
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Password recovery
|
|
//--------------------------------------------------------------------------------------
|
|
if (action === 'set-new-password') {
|
|
let user = window.url_query_params.get('user');
|
|
let token = window.url_query_params.get('token');
|
|
|
|
await UIWindowNewPassword({
|
|
user: user,
|
|
token: token,
|
|
});
|
|
}
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Change Username
|
|
//--------------------------------------------------------------------------------------
|
|
else if (action === 'change-username') {
|
|
await UIWindowChangeUsername();
|
|
}
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Login
|
|
//--------------------------------------------------------------------------------------
|
|
else if (action === 'login') {
|
|
const opts = window.url_query_params.has('auth_error')
|
|
? { authError: authErrorDisplayMessage() }
|
|
: {};
|
|
if (!window.is_auth()) {
|
|
opts.window_options = { cover_page: true, has_head: false };
|
|
}
|
|
await UIWindowLogin(Object.keys(opts).length ? opts : undefined);
|
|
}
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Password recovery
|
|
//--------------------------------------------------------------------------------------
|
|
else if (action === 'password-recovery') {
|
|
await UIWindowRecoverPassword({
|
|
window_options: {
|
|
cover_page: true,
|
|
has_head: false,
|
|
},
|
|
});
|
|
}
|
|
//--------------------------------------------------------------------------------------
|
|
// Action: Signup
|
|
//--------------------------------------------------------------------------------------
|
|
else if (action === 'signup') {
|
|
const opts = window.url_query_params.has('auth_error')
|
|
? { authError: authErrorDisplayMessage() }
|
|
: {};
|
|
if (!window.is_auth()) {
|
|
opts.window_options = { cover_page: true, has_head: false };
|
|
}
|
|
opts.send_confirmation_code = true;
|
|
await UIWindowSignup(Object.keys(opts).length ? opts : undefined);
|
|
}
|
|
// Which URL parameter (if any) is carrying a token to sign in with —
|
|
// `auth_token`, or `token` when returning from a remote backend's AuthMe.
|
|
const url_token_param = urlTokenParam(
|
|
window.url_query_params,
|
|
shouldUseRemoteAuthme(window.gui_origin, window.location.origin),
|
|
);
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// If in embedded in a popup, it is important to check whether the opener app has a relationship with the user
|
|
// if yes, we need to get the user app token and send it to the opener
|
|
// if not, we need to ask the user for confirmation before proceeding BUT only if the action is a file-picker action
|
|
// -------------------------------------------------------------------------------------
|
|
if (window.embedded_in_popup && window.openerOrigin) {
|
|
let response = await window.checkUserSiteRelationship(
|
|
window.openerOrigin,
|
|
);
|
|
window.userAppToken = response.token;
|
|
|
|
if (
|
|
!picked_a_user_for_sdk_login &&
|
|
window.logged_in_users.length > 1 &&
|
|
(!window.userAppToken ||
|
|
window.url_query_params.get('request_auth'))
|
|
) {
|
|
picked_a_user_for_sdk_login = await UIWindowSessionList({
|
|
reload_on_success: false,
|
|
draggable_body: false,
|
|
has_head: false,
|
|
cover_page: true,
|
|
});
|
|
if (picked_a_user_for_sdk_login) {
|
|
window.popup_signin_consent = true;
|
|
}
|
|
}
|
|
|
|
// An opener the user has already signed in to before does not need to
|
|
// be re-approved on every visit — that grant is what
|
|
// `checkUserSiteRelationship` reports. This is also what keeps the
|
|
// file-picker and permission popups, which never show an account
|
|
// picker, from being blocked by the gate in `postAuthActions`.
|
|
if (window.userAppToken) {
|
|
window.popup_signin_consent = true;
|
|
}
|
|
}
|
|
// -------------------------------------------------------------------------------------
|
|
// A token provided in the URL, use it to log in. `auth_token` normally;
|
|
// `token` as well when a locally served GUI is returning from the remote
|
|
// backend's AuthMe flow, which is the name AuthMe hands back.
|
|
// -------------------------------------------------------------------------------------
|
|
else if (url_token_param) {
|
|
let query_param_auth_token =
|
|
window.url_query_params.get(url_token_param);
|
|
|
|
const previous_auth_token = window.auth_token;
|
|
|
|
// The token is resolved against our own API origin — deliberately not
|
|
// one named by the same URL that carried the token, which would leave
|
|
// the identity we're about to render up to whoever wrote the link.
|
|
puter.setAuthToken(query_param_auth_token);
|
|
|
|
try {
|
|
whoami = await puter.os.user({ query: 'icon_size=64' });
|
|
} catch (e) {
|
|
if (e.status === 401) {
|
|
window.logout();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Confirm the identity before adopting a token that came from the
|
|
// URL. Every other path into `update_auth_data` is an account the
|
|
// user picked in the UI, so those don't ask.
|
|
if (whoami && (!window.user || window.user.uuid !== whoami.uuid)) {
|
|
const proceed = await UIAlert({
|
|
type: 'confirm',
|
|
// `false` — UIAlert encodes the message itself.
|
|
message: i18n(
|
|
'confirm_continue_as',
|
|
{ username: whoami.username },
|
|
false,
|
|
),
|
|
buttons: [
|
|
{ label: i18n('continue'), value: true, type: 'primary' },
|
|
{ label: i18n('cancel'), value: false, type: 'secondary' },
|
|
],
|
|
});
|
|
if (!proceed) {
|
|
// Back to whatever session was already here; normal boot
|
|
// picks up below.
|
|
puter.setAuthToken(previous_auth_token);
|
|
whoami = null;
|
|
}
|
|
}
|
|
|
|
if (whoami) {
|
|
// Verification gates run in order: email → phone (SMS) → card,
|
|
// matching the server-side order in assertVerifiedAccount.
|
|
if (whoami.requires_email_confirmation) {
|
|
let is_verified;
|
|
do {
|
|
is_verified = await UIWindowEmailConfirmationRequired({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
window_options: {
|
|
is_draggable: false,
|
|
},
|
|
});
|
|
} while (!is_verified);
|
|
}
|
|
// is phone verification required? (hard gate for low-rep signups)
|
|
// A user the SMS path keeps failing may be offered the card
|
|
// fallback instead; that resolves 'card' and clears the card gate
|
|
// too (see UIWindowPhoneVerificationRequired).
|
|
let card_gate_cleared_by_fallback = false;
|
|
if (whoami.requires_phone_verification) {
|
|
let is_verified;
|
|
do {
|
|
is_verified = await UIWindowPhoneVerificationRequired({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
// Already out of SMS attempts (the offer outlives the
|
|
// send window, and no send can report it any more).
|
|
card_fallback_available:
|
|
whoami.card_fallback_available,
|
|
window_options: {
|
|
is_draggable: false,
|
|
},
|
|
});
|
|
} while (!is_verified);
|
|
card_gate_cleared_by_fallback = is_verified === 'card';
|
|
}
|
|
// Card verification is the last gate: only show it once the email and
|
|
// phone (SMS) gates are cleared, since those show up first.
|
|
if (
|
|
whoami.requires_card_verification &&
|
|
!card_gate_cleared_by_fallback
|
|
) {
|
|
let is_verified;
|
|
do {
|
|
is_verified = await UIWindowCardVerificationRequired({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
window_options: {
|
|
is_draggable: false,
|
|
},
|
|
});
|
|
} while (!is_verified);
|
|
}
|
|
// if user is logging in using an auth token that means it's not their first ever visit to Puter.com
|
|
// it might be their first visit to Puter on this specific device but it's not their first time ever visiting Puter.
|
|
window.first_visit_ever = false;
|
|
// show login progress window
|
|
UIWindowLoginInProgress({ user_info: whoami });
|
|
// update auth data
|
|
await window.update_auth_data(query_param_auth_token, whoami);
|
|
// The token landed and `whoami` accepted it — let a later sign-out
|
|
// in this tab hand off to AuthMe again (see the remote-backend
|
|
// branch below).
|
|
try {
|
|
sessionStorage.removeItem('puter.authme_redirect_attempted');
|
|
} catch (e) { /* sessionStorage unavailable */ }
|
|
}
|
|
// remove the token from URL, keeping the current path (e.g. `/` or
|
|
// `/desktop`) and hash (dashboard tab links like /#usage).
|
|
//
|
|
// `replaceState`, not `pushState`: pushing leaves the token-bearing URL
|
|
// as the previous history entry, so Back returns to a URL containing a
|
|
// live credential (and it stays in session restore). Replacing drops it
|
|
// from this tab's history instead of merely navigating away from it.
|
|
window.history.replaceState(
|
|
null,
|
|
document.title,
|
|
window.location.pathname + window.location.hash,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Logout without showing confirmation or "Save Account" action,
|
|
* and without authenticating with the server.
|
|
*/
|
|
const bad_session_logout = async () => {
|
|
try {
|
|
// TODO: i18n
|
|
await UIAlert({
|
|
message: 'Your session is invalid. You will be logged out.',
|
|
});
|
|
// clear local storage
|
|
localStorage.clear();
|
|
// reload the page
|
|
window.location.reload();
|
|
} catch (e) {
|
|
// TODO: i18n
|
|
await UIAlert({
|
|
message:
|
|
'Session is invalid and logout failed; ' +
|
|
'please clear local storage manually.',
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Event handler for a custom 'logout' event attached to the document.
|
|
* This function handles the process of logging out, including user confirmation,
|
|
* communication with the backend, and subsequent UI updates. It takes special
|
|
* precautions if the user is identified as using a temporary account.
|
|
*
|
|
* @listens Document#event:logout
|
|
* @async
|
|
* @param {Event} event - The JQuery event object associated with the logout event.
|
|
* @returns {Promise<void>} - This function does not return anything meaningful, but it performs an asynchronous operation.
|
|
*/
|
|
$(document).on('logout', async function (event) {
|
|
// is temp user?
|
|
if (window.user && window.user.is_temp && !window.user.deleted) {
|
|
const alert_resp = await UIAlert({
|
|
message:
|
|
'<strong>Save account before logging out!</strong><p>You are using a temporary account and logging out will erase all your data.</p>',
|
|
buttons: [
|
|
{
|
|
label: i18n('save_account'),
|
|
value: 'save_account',
|
|
type: 'primary',
|
|
},
|
|
{
|
|
label: i18n('log_out'),
|
|
value: 'log_out',
|
|
type: 'danger',
|
|
},
|
|
{
|
|
label: i18n('cancel'),
|
|
},
|
|
],
|
|
});
|
|
if (alert_resp === 'save_account') {
|
|
let saved = await UIWindowSaveAccount({
|
|
send_confirmation_code: false,
|
|
default_username: window.user.username,
|
|
});
|
|
if (saved) {
|
|
window.logout();
|
|
}
|
|
} else if (alert_resp === 'log_out') {
|
|
window.logout();
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// logout
|
|
try {
|
|
const resp = await fetch(`${window.gui_origin}/get-anticsrf-token`);
|
|
const { token } = await resp.json();
|
|
await $.ajax({
|
|
url: `${window.gui_origin}/logout`,
|
|
type: 'POST',
|
|
async: true,
|
|
contentType: 'application/json',
|
|
headers: {
|
|
Authorization: `Bearer ${window.auth_token}`,
|
|
},
|
|
data: JSON.stringify({ anti_csrf: token }),
|
|
statusCode: {
|
|
401: function () {},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
// Ignored
|
|
}
|
|
|
|
// remove this user from the array of logged_in_users
|
|
for (let i = 0; i < window.logged_in_users.length; i++) {
|
|
if (window.logged_in_users[i].uuid === window.user.uuid) {
|
|
window.logged_in_users.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// update logged_in_users in local storage
|
|
localStorage.setItem(
|
|
'logged_in_users',
|
|
JSON.stringify(window.logged_in_users),
|
|
);
|
|
|
|
// delete this user from local storage
|
|
window.user = null;
|
|
localStorage.removeItem('user');
|
|
window.auth_token = null;
|
|
// `window.logout()` above already cleared the current key; this drops
|
|
// any value left under the retired one.
|
|
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED || 'auth_token');
|
|
|
|
// close all windows
|
|
$('.window').close();
|
|
// close all ctxmenus
|
|
$('.context-menu').remove();
|
|
// remove desktop
|
|
$('.desktop').remove();
|
|
// remove taskbar
|
|
$('.taskbar').remove();
|
|
// disable native browser exit confirmation
|
|
window.onbeforeunload = null;
|
|
// go back to the interface the user was in: dashboard users to the root
|
|
// dashboard, desktop users to /desktop
|
|
window.location.replace(window.is_dashboard_mode ? '/' : '/desktop');
|
|
});
|
|
|
|
const verification_gate_windows = {
|
|
phone_verification_required: UIWindowPhoneVerificationRequired,
|
|
email_confirmation_required: UIWindowEmailConfirmationRequired,
|
|
card_verification_required: UIWindowCardVerificationRequired,
|
|
};
|
|
let verification_gate_open = false;
|
|
$(document).ajaxError(async function (event, jqxhr) {
|
|
if (jqxhr?.status !== 403 || verification_gate_open) {
|
|
return;
|
|
}
|
|
let body = jqxhr.responseJSON;
|
|
if (!body && jqxhr.responseText) {
|
|
try {
|
|
body = JSON.parse(jqxhr.responseText);
|
|
} catch (e) {
|
|
body = null;
|
|
}
|
|
}
|
|
const UIWindowVerificationGate = verification_gate_windows[body?.code];
|
|
if (!UIWindowVerificationGate) {
|
|
return;
|
|
}
|
|
verification_gate_open = true;
|
|
try {
|
|
const is_verified = await UIWindowVerificationGate({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
logout_in_footer: true,
|
|
window_options: {
|
|
is_draggable: false,
|
|
},
|
|
});
|
|
if (is_verified) {
|
|
await window.refresh_user_data(window.auth_token);
|
|
}
|
|
} catch (e) {
|
|
console.error('verification gate dialog failed:', e);
|
|
} finally {
|
|
verification_gate_open = false;
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Authed
|
|
// -------------------------------------------------------------------------------------
|
|
if (window.is_auth()) {
|
|
// try to get user data using /whoami, only if that data is missing
|
|
if (!whoami) {
|
|
try {
|
|
whoami = await puter.os.user({ query: 'icon_size=64' });
|
|
} catch (e) {
|
|
if (e.status === 401) {
|
|
bad_session_logout();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// update local user data
|
|
if (whoami) {
|
|
// The server renders the /app/<name> landing overlay only for
|
|
// requests it saw as anonymous, but its only signal is the session
|
|
// cookie — which can be gone (e.g. browser restart) while the
|
|
// localStorage session is still valid. whoami just proved this is
|
|
// a logged-in user, so drop the overlay. This must happen before
|
|
// the verification gates below: the overlay's max z-index would
|
|
// cover them.
|
|
document.getElementById('appLanding')?.remove();
|
|
// Verification gates run in order: email → phone (SMS) → card,
|
|
// matching the server-side order in assertVerifiedAccount.
|
|
if (whoami.requires_email_confirmation) {
|
|
let is_verified;
|
|
do {
|
|
is_verified = await UIWindowEmailConfirmationRequired({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
logout_in_footer: true,
|
|
window_options: {
|
|
is_draggable: false,
|
|
cover_page: window.is_embedded,
|
|
},
|
|
});
|
|
} while (!is_verified);
|
|
}
|
|
// is phone verification required? (hard gate for low-rep signups)
|
|
// A user the SMS path keeps failing may be offered the card
|
|
// fallback instead; that resolves 'card' and clears the card gate
|
|
// too (see UIWindowPhoneVerificationRequired).
|
|
let card_gate_cleared_by_fallback = false;
|
|
if (whoami.requires_phone_verification) {
|
|
let is_verified;
|
|
do {
|
|
is_verified = await UIWindowPhoneVerificationRequired({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
logout_in_footer: true,
|
|
// Already out of SMS attempts (the offer outlives the
|
|
// send window, and no send can report it any more).
|
|
card_fallback_available:
|
|
whoami.card_fallback_available,
|
|
window_options: {
|
|
is_draggable: false,
|
|
cover_page: window.is_embedded,
|
|
},
|
|
});
|
|
} while (!is_verified);
|
|
card_gate_cleared_by_fallback = is_verified === 'card';
|
|
}
|
|
// Card verification is the last gate: only show it once the email and
|
|
// phone (SMS) gates are cleared, since those show up first.
|
|
if (
|
|
whoami.requires_card_verification &&
|
|
!card_gate_cleared_by_fallback
|
|
) {
|
|
let is_verified;
|
|
do {
|
|
is_verified = await UIWindowCardVerificationRequired({
|
|
show_close_button: false,
|
|
stay_on_top: true,
|
|
has_head: false,
|
|
logout_in_footer: true,
|
|
window_options: {
|
|
is_draggable: false,
|
|
cover_page: window.is_embedded,
|
|
},
|
|
});
|
|
} while (!is_verified);
|
|
}
|
|
await window.update_auth_data(
|
|
whoami.token || window.auth_token,
|
|
whoami,
|
|
);
|
|
|
|
await postAuthActions(action);
|
|
// ----------------------------------------------------------
|
|
// Get user's sites
|
|
// ----------------------------------------------------------
|
|
window.update_sites_cache();
|
|
}
|
|
}
|
|
// -------------------------------------------------------------------------------------
|
|
// Desktop Background
|
|
// If we're in fullpage/emebedded/Auth Popup mode, we don't want to load the custom background
|
|
// because it's not visible anyway and it's a waste of bandwidth
|
|
// -------------------------------------------------------------------------------------
|
|
if (!window.is_fullpage_mode && !window.embedded_in_popup) {
|
|
window.refresh_desktop_background();
|
|
}
|
|
// -------------------------------------------------------------------------------------
|
|
// Un-authed but not first visit -> try to log in/sign up
|
|
// -------------------------------------------------------------------------------------
|
|
// App landing pages (`/app/<name>`, incl. `/desktop/app/<name>`) require a
|
|
// real account even on a first visit — never a temp user. So does a share
|
|
// link: a share only ever reaches a real account, so a temporary one could
|
|
// never see what it points at.
|
|
const is_app_landing_page = window.url_paths[0] === 'app' && !!window.url_paths[1];
|
|
const is_share_link = window.url_query_params.has('shared');
|
|
if (
|
|
!window.is_auth() &&
|
|
(!window.first_visit_ever || window.disable_temp_users || is_app_landing_page || is_share_link)
|
|
) {
|
|
// `npm start --server=<remote>` serves this GUI locally while pointing
|
|
// `gui_origin` at a remote Puter. There is nothing here to log into:
|
|
// `/login` accepts only its own origin, because it answers with a full
|
|
// session token and reflected CORS would otherwise let any page read
|
|
// one. So hand off to the remote's AuthMe flow — the password is typed
|
|
// on the real origin, and we come back with a token in the URL.
|
|
if (shouldUseRemoteAuthme(window.gui_origin, window.location.origin)) {
|
|
// One attempt per tab. A token that comes back but fails `whoami`
|
|
// would otherwise bounce us straight back out again forever.
|
|
const ATTEMPTED_KEY = 'puter.authme_redirect_attempted';
|
|
let attempted = false;
|
|
try {
|
|
attempted = sessionStorage.getItem(ATTEMPTED_KEY) === '1';
|
|
sessionStorage.setItem(ATTEMPTED_KEY, '1');
|
|
} catch (e) {
|
|
// sessionStorage unavailable — fall through and redirect once.
|
|
}
|
|
if (!attempted) {
|
|
window.location.href = authmeRequestUrl(
|
|
window.gui_origin,
|
|
window.location.origin + window.location.pathname,
|
|
{ fullToken: true },
|
|
).href;
|
|
return;
|
|
}
|
|
// Second pass in this tab: something came back but didn't
|
|
// authenticate us. Clear the flag before reporting, so a reload
|
|
// gets one fresh attempt instead of being stuck on this alert
|
|
// forever — the guard exists to stop an *automatic* loop, and an
|
|
// automatic return only happens when a token was handed back.
|
|
try {
|
|
sessionStorage.removeItem(ATTEMPTED_KEY);
|
|
} catch (e) { /* sessionStorage unavailable */ }
|
|
await UIAlert({
|
|
message: i18n('remote_backend_signin_failed', {
|
|
origin: window.gui_origin,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const needs_action = action === 'authme' || action === 'copyauth';
|
|
const reload_on_success = needs_action;
|
|
if (window.logged_in_users.length > 0) {
|
|
// dashboard mode skips the wallpaper (fullpage), but the session list
|
|
// has no cover page — restore the wallpaper so it isn't on a blank page
|
|
if (window.is_dashboard_mode) {
|
|
window.refresh_desktop_background();
|
|
}
|
|
await UIWindowSessionList({
|
|
redirect_url: needs_action ? window.location.href : undefined,
|
|
});
|
|
} else {
|
|
const resp = await fetch(`${window.gui_origin}/whoarewe`);
|
|
const whoarewe = await resp.json();
|
|
await UIWindowLogin({
|
|
reload_on_success: !window.embedded_in_popup,
|
|
send_confirmation_code: true,
|
|
show_signup_button: !whoarewe.disable_user_signup,
|
|
redirect_url: needs_action ? window.location.href : undefined,
|
|
window_options: {
|
|
cover_page: true,
|
|
has_head: false,
|
|
},
|
|
});
|
|
}
|
|
if (!reload_on_success && window.is_auth()) {
|
|
window.__login_completed = true;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Un-authed and first visit ever -> create temp user with Turnstile challenge
|
|
// -------------------------------------------------------------------------------------
|
|
else if (
|
|
!window.is_auth() &&
|
|
window.first_visit_ever &&
|
|
!window.disable_temp_users &&
|
|
action !== 'login' &&
|
|
action !== 'signup'
|
|
) {
|
|
let referrer;
|
|
try {
|
|
referrer = new URL(window.location.href).pathname;
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
|
|
referrer = window.openerOrigin ?? referrer;
|
|
|
|
// a global object that will be used to store the user's referrer
|
|
window.referrerStr = referrer;
|
|
|
|
// in case there is also a referrer query param, add it to the referrer URL
|
|
if (window.url_query_params.has('ref')) {
|
|
if (!referrer) {
|
|
referrer = '/';
|
|
}
|
|
referrer += `?ref=${html_encode(window.url_query_params.get('ref'))}`;
|
|
}
|
|
|
|
let headers = {};
|
|
if (window.custom_headers) {
|
|
headers = window.custom_headers;
|
|
}
|
|
|
|
// Function to create temp user after captcha completion
|
|
const createTempUser = async (turnstileToken) => {
|
|
// if this is a popup, show a spinner
|
|
let spinner_init_ts = Date.now();
|
|
const requestData = {
|
|
referrer: referrer,
|
|
is_temp: true,
|
|
};
|
|
|
|
// Add Turnstile token if available
|
|
if (turnstileToken) {
|
|
requestData['cf-turnstile-response'] = turnstileToken;
|
|
}
|
|
|
|
// Device signal for abuse prevention; omitted when unavailable
|
|
try {
|
|
const fingerprint = await window.getDeviceFingerprint?.();
|
|
if (fingerprint) {
|
|
requestData.fingerprint = fingerprint;
|
|
}
|
|
} catch (e) {
|
|
// signup must never block or fail because of device signals
|
|
}
|
|
|
|
$.ajax({
|
|
url: `${window.gui_origin}/signup`,
|
|
type: 'POST',
|
|
async: true,
|
|
headers: headers,
|
|
contentType: 'application/json',
|
|
data: JSON.stringify(requestData),
|
|
success: async function (data) {
|
|
/*eslint-disable*/
|
|
const turnstile_duration =
|
|
Date.now() - window.turnstile_success_ts;
|
|
if (turnstile_duration < 2000) {
|
|
// Sleep until 2 seconds have passed
|
|
await window.sleep(2000 - turnstile_duration);
|
|
}
|
|
|
|
const $captchaModal = $('.captcha-modal');
|
|
if ($captchaModal.length > 0)
|
|
await new Promise(async (resolve) => {
|
|
// The callback operand for fadeOut could be called
|
|
// more than once if there are multiple `.captcha-modal`
|
|
// elements, but only the first call to `resolve()` will
|
|
// have any effect.
|
|
$captchaModal.fadeOut(200, function () {
|
|
$(this).remove();
|
|
resolve();
|
|
});
|
|
|
|
// Just in case anything fails, also resolve after 500ms
|
|
await window.sleep(500);
|
|
resolve();
|
|
});
|
|
|
|
await window.update_auth_data(data.token, data.user);
|
|
|
|
// if this is a popup, hide the spinner, make sure it was visible for at least 2 seconds
|
|
if (window.embedded_in_popup)
|
|
await new Promise(async (resolve) => {
|
|
let spinner_duration = Date.now() - spinner_init_ts;
|
|
|
|
(async () => {
|
|
let closing = false;
|
|
try {
|
|
if (runsUserAppTokenExchange(action)) {
|
|
let msg_id =
|
|
window.url_query_params.get('msg_id');
|
|
let data = await window.getUserAppToken(
|
|
new URL(window.openerOrigin).origin,
|
|
);
|
|
// A network failure here returns null and
|
|
// an HTTP failure returns the parsed error
|
|
// body, neither of which carries a token;
|
|
// the reads below would fault or hand the
|
|
// opener an `undefined` token.
|
|
if (!data?.token) {
|
|
throw new Error(
|
|
'user-app token exchange returned no token',
|
|
);
|
|
}
|
|
// This is an implicit app and the app_uid is sent back from the server
|
|
// we cache it here so that we can use it later
|
|
window.host_app_uid = data.app_uid;
|
|
// send token to parent
|
|
if (deliversTokenToOpener(action)) {
|
|
window.opener?.postMessage(
|
|
{
|
|
msg: 'puter.token',
|
|
success: true,
|
|
msg_id: msg_id,
|
|
token: data.token,
|
|
username: window.user.username,
|
|
app_uid: data.app_uid,
|
|
},
|
|
window.openerOrigin,
|
|
);
|
|
}
|
|
}
|
|
// close popup
|
|
if (!action || action === 'sign-in') {
|
|
closing = true;
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
'popup token exchange failed',
|
|
err,
|
|
);
|
|
} finally {
|
|
// Actions that keep the popup open still
|
|
// have work to do after this wait, and the
|
|
// sleep below only ends it when the spinner
|
|
// was up for under 2s — so this has to run
|
|
// on the failure paths too, or the popup
|
|
// wedges on the spinner forever.
|
|
if (!closing) {
|
|
resolve();
|
|
}
|
|
}
|
|
})();
|
|
if (spinner_duration < 2000) {
|
|
await window.sleep(2000 - spinner_duration);
|
|
resolve();
|
|
}
|
|
});
|
|
/*eslint-enable*/
|
|
|
|
document.dispatchEvent(
|
|
new Event('login', { bubbles: true }),
|
|
);
|
|
},
|
|
error: async (err) => {
|
|
let err_obj = null;
|
|
try {
|
|
err_obj = JSON.parse(err.responseText);
|
|
} catch (e) {
|
|
err_obj = e;
|
|
}
|
|
if (err_obj.code === 'must_login_or_signup') {
|
|
// hide Turnstile challenge
|
|
$('.captcha-modal').hide();
|
|
|
|
await UIWindowSignup({
|
|
reload_on_success: !window.embedded_in_popup,
|
|
send_confirmation_code: true,
|
|
window_options: {
|
|
has_head: false,
|
|
cover_page: true,
|
|
},
|
|
});
|
|
|
|
// Popup-only: it posts to `window.opener` and closes the
|
|
// window. On a normal page `window.openerOrigin` is
|
|
// undefined and `new URL(undefined)` would throw into
|
|
// nothing.
|
|
if (window.embedded_in_popup)
|
|
(async () => {
|
|
try {
|
|
if (runsUserAppTokenExchange(action)) {
|
|
let msg_id =
|
|
window.url_query_params.get('msg_id');
|
|
let data = await window.getUserAppToken(
|
|
new URL(window.openerOrigin).origin,
|
|
);
|
|
if (!data?.token) {
|
|
throw new Error(
|
|
'user-app token exchange returned no token',
|
|
);
|
|
}
|
|
// This is an implicit app and the app_uid is sent back from the server
|
|
// we cache it here so that we can use it later
|
|
window.host_app_uid = data.app_uid;
|
|
// send token to parent
|
|
if (deliversTokenToOpener(action)) {
|
|
window.opener?.postMessage(
|
|
{
|
|
msg: 'puter.token',
|
|
success: true,
|
|
msg_id: msg_id,
|
|
token: data.token,
|
|
username: window.user.username,
|
|
app_uid: data.app_uid,
|
|
},
|
|
window.openerOrigin,
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(
|
|
'popup token exchange failed after manual signup',
|
|
err,
|
|
);
|
|
}
|
|
// close popup
|
|
if (!action || action === 'sign-in') {
|
|
window.close();
|
|
window.open('', '_self').close();
|
|
return;
|
|
}
|
|
// Every other action is handled by
|
|
// `postAuthActions`, which only runs off the
|
|
// `login` event on this path — without it the
|
|
// popup sits blank and the requester is never
|
|
// answered.
|
|
document.dispatchEvent(
|
|
new Event('login', { bubbles: true }),
|
|
);
|
|
})();
|
|
} else if (err_obj.code === 'signup_blocked') {
|
|
// Hide any captcha modal
|
|
$('.captcha-modal').hide();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.classList.add('signup-blocked-overlay');
|
|
const blockedMsg = err_obj.message || 'Signup blocked';
|
|
overlay.innerHTML = `
|
|
<div class="signup-blocked-content">
|
|
<img src="${window.icons['logo.svg'] || window.icons['logo-white.svg'] || ''}" style="width:64px;margin-bottom:24px;" />
|
|
<p>${html_encode(blockedMsg)}</p>
|
|
<p>If you already have an account, try <a href="/action/login">logging in</a>. Otherwise, contact <a href="mailto:support@puter.com">support@puter.com</a> for assistance.</p>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(overlay);
|
|
} else {
|
|
UIAlert({
|
|
message:
|
|
err_obj.message ??
|
|
'There was an error creating your account. Please try again.',
|
|
});
|
|
}
|
|
},
|
|
complete: function () {},
|
|
});
|
|
};
|
|
|
|
// Check if Turnstile is enabled and show challenge
|
|
if (window.gui_params?.turnstileSiteKey) {
|
|
window.showTurnstileChallenge({
|
|
onSuccess: createTempUser,
|
|
onError: (error) => {
|
|
console.error('Turnstile verification failed:', error);
|
|
UIAlert({
|
|
message:
|
|
'Security verification failed. Please refresh the page and try again.',
|
|
});
|
|
},
|
|
});
|
|
} else {
|
|
// No Turnstile configured, proceed without challenge
|
|
createTempUser();
|
|
}
|
|
}
|
|
|
|
// if there is at least one window open (only non-Explorer windows), ask user for confirmation when navigating away from puter
|
|
if (window.feature_flags.prompt_user_when_navigation_away_from_puter) {
|
|
window.onbeforeunload = function () {
|
|
if ($('.window:not(.window[data-app="explorer"])').length > 0) {
|
|
return true;
|
|
}
|
|
};
|
|
}
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// `login` event handler
|
|
// --------------------------------------------------------------------------------------
|
|
$(document).on('login', async (e) => {
|
|
// Reaching this in a popup means the user just entered credentials in
|
|
// a window the opener asked for — that is the consent `postAuthActions`
|
|
// looks for. The account-picker gate upstream never runs on this path:
|
|
// it only applies to a popup that was already signed in at boot.
|
|
if (window.embedded_in_popup) {
|
|
window.popup_signin_consent = true;
|
|
}
|
|
// close all windows
|
|
$('.window').close();
|
|
|
|
// -------------------------------------------------------------------------------------
|
|
// Early check for fullpage mode from app metadata (after login)
|
|
// -------------------------------------------------------------------------------------
|
|
if (
|
|
!window.is_fullpage_mode &&
|
|
window.url_paths[0]?.toLocaleLowerCase() === 'app' &&
|
|
window.url_paths[1]
|
|
) {
|
|
try {
|
|
const app_info = await puter.apps.get(window.url_paths[1], {
|
|
icon_size: 64,
|
|
});
|
|
if (app_info?.metadata?.fullpage_on_landing) {
|
|
window.is_fullpage_mode = true;
|
|
window.taskbar_height = 0;
|
|
window.app_launched_from_url = app_info;
|
|
}
|
|
} catch (e) {
|
|
// App metadata fetch failed; will retry later in UIDesktop
|
|
}
|
|
}
|
|
|
|
await postAuthActions(action);
|
|
});
|
|
|
|
if (window.__login_completed) {
|
|
document.dispatchEvent(new Event('login', { bubbles: true }));
|
|
window.__login_completed = false;
|
|
}
|
|
|
|
$('.popover, .context-menu').on('remove', function () {
|
|
$('.window-active .window-app-iframe').css('pointer-events', 'all');
|
|
});
|
|
|
|
// If the document is clicked/tapped somewhere
|
|
$(document).bind('mousedown touchstart', function (e) {
|
|
// update last touch coordinates
|
|
update_last_touch_coordinates(e);
|
|
|
|
// dismiss touchstart on regular devices
|
|
if (e.type === 'touchstart' && !isMobile.phone && !isMobile.tablet) {
|
|
return;
|
|
}
|
|
|
|
// If .item-container clicked, unselect all its item children
|
|
if (
|
|
$(e.target).hasClass('item-container') &&
|
|
!e.ctrlKey &&
|
|
!e.metaKey
|
|
) {
|
|
$(e.target).children('.item-selected').removeClass('item-selected');
|
|
window.update_explorer_footer_selected_items_count(e.target);
|
|
}
|
|
|
|
// If the clicked element is not a context menu, remove all context menus
|
|
if ($(e.target).parents('.context-menu').length === 0) {
|
|
$('.context-menu').fadeOut(200, function () {
|
|
$(this).remove();
|
|
});
|
|
}
|
|
|
|
// click on anything will close all popovers, but there are some exceptions
|
|
if (
|
|
!$(e.target).hasClass('start-app') &&
|
|
!$(e.target).hasClass('launch-search') &&
|
|
!$(e.target).hasClass('launch-search-clear') &&
|
|
$(e.target).closest('.start-app').length === 0 &&
|
|
!isMobile.phone &&
|
|
!isMobile.tablet &&
|
|
!$(e.target).hasClass('popover') &&
|
|
$(e.target).parents('.popover').length === 0
|
|
) {
|
|
$('.popover').fadeOut(200, function () {
|
|
$('.popover').remove();
|
|
});
|
|
}
|
|
|
|
// Close all tooltips
|
|
$('.ui-tooltip').remove();
|
|
|
|
// rename items whose names were being edited
|
|
if (!$(e.target).hasClass('item-name-editor')) {
|
|
// blurring an Item Name Editor will automatically trigger renaming the item
|
|
$('.item-name-editor-active').blur();
|
|
}
|
|
|
|
// update active_item_container
|
|
if ($(e.target).hasClass('item-container')) {
|
|
window.active_item_container = e.target;
|
|
} else {
|
|
let ic = $(e.target).closest('.item-container');
|
|
if (ic.length > 0) {
|
|
window.active_item_container = ic.get(0);
|
|
} else {
|
|
let pp = $(e.target).find('.item-container');
|
|
if (pp.length > 0) {
|
|
window.active_item_container = pp.get(0);
|
|
}
|
|
}
|
|
}
|
|
|
|
//active element
|
|
window.active_element = e.target;
|
|
});
|
|
|
|
// update mouse position coordinates
|
|
$(document).mousemove(function (event) {
|
|
update_mouse_position(event.clientX, event.clientY);
|
|
});
|
|
|
|
//--------------------------------------------------------
|
|
// Window Activation
|
|
//--------------------------------------------------------
|
|
$(document).on('mousedown', function (e) {
|
|
// if taskbar or any parts of it is clicked, drop the event
|
|
if (
|
|
$(e.target).hasClass('taskbar') ||
|
|
$(e.target).closest('.taskbar').length > 0
|
|
) {
|
|
return;
|
|
}
|
|
// if toolbar or any parts of it is clicked, drop the event
|
|
if (
|
|
$(e.target).hasClass('toolbar') ||
|
|
$(e.target).closest('.toolbar').length > 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// if close or minimize button clicked, drop the event
|
|
if (
|
|
document
|
|
.elementFromPoint(e.clientX, e.clientY)
|
|
.closest('.window-close-btn, .window-minimize-btn')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// if mouse is clicked on a window, activate it
|
|
if (window.mouseover_window !== undefined) {
|
|
// if popover clicked on, don't activate window. This is because if an app
|
|
// is using the popover API to show a popover, the popover will be closed if the window is activated
|
|
if (
|
|
$(e.target).hasClass('popover') ||
|
|
$(e.target).parents('.popover').length > 0
|
|
) {
|
|
return;
|
|
}
|
|
$(window.mouseover_window).focusWindow(e);
|
|
}
|
|
});
|
|
|
|
// if an element has the .long-hover class, fire a long-hover event after 600ms
|
|
$(document).on('mouseenter', '.long-hover', function () {
|
|
let el = this;
|
|
el.long_hover_timeout = setTimeout(() => {
|
|
$(el).trigger('long-hover');
|
|
}, 600);
|
|
});
|
|
|
|
// if an element has the .long-hover class, cancel the long-hover event if the mouse leaves
|
|
$(document).on('mouseleave', '.long-hover', function () {
|
|
clearTimeout(this.long_hover_timeout);
|
|
});
|
|
|
|
document.addEventListener('visibilitychange', (event) => {
|
|
if (document.visibilityState !== 'visible') {
|
|
window.doc_title_before_blur = document.title;
|
|
if (Object.keys(window.active_uploads).length > 0) {
|
|
update_title_based_on_uploads();
|
|
}
|
|
} else if (window.active_uploads) {
|
|
document.title = window.doc_title_before_blur ?? 'Puter';
|
|
}
|
|
});
|
|
};
|
|
|
|
function requestOpenerOrigin() {
|
|
return new Promise((resolve, reject) => {
|
|
if (!window.opener) {
|
|
reject(new Error('No window.opener available'));
|
|
return;
|
|
}
|
|
|
|
// Function to handle the message event
|
|
const handleMessage = (event) => {
|
|
// Check if the message is the expected response
|
|
if (event.data.msg === 'originResponse') {
|
|
// Clean up by removing the event listener
|
|
window.removeEventListener('message', handleMessage);
|
|
resolve(event.origin);
|
|
}
|
|
};
|
|
|
|
// Set up the listener for the response
|
|
window.addEventListener('message', handleMessage, false);
|
|
|
|
// Send the request to the opener
|
|
window.opener.postMessage({ msg: 'requestOrigin' }, '*');
|
|
|
|
// Optional: Reject the promise if no response is received within a timeout
|
|
setTimeout(() => {
|
|
window.removeEventListener('message', handleMessage);
|
|
reject(new Error('Response timed out'));
|
|
}, 5000); // Timeout after 5 seconds
|
|
});
|
|
}
|
|
|
|
$(document).on('click', '.generic-close-window-button', function (e) {
|
|
$(this).closest('.window').close();
|
|
});
|
|
|
|
$(document).on('click', function (e) {
|
|
if (
|
|
!$(e.target).hasClass('window-search') &&
|
|
$(e.target).closest('.window-search').length === 0 &&
|
|
!$(e.target).is('.toolbar-btn.search-btn')
|
|
) {
|
|
$('.window-search').close();
|
|
}
|
|
});
|
|
|
|
// Re-calculate desktop height and width on window resize and re-position the login and signup windows
|
|
$(window).on('resize', function () {
|
|
// If host env is popup, don't continue because the popup window has its own resize requirements.
|
|
if (window.embedded_in_popup) {
|
|
return;
|
|
}
|
|
|
|
const ratio = window.desktop_width / window.innerWidth;
|
|
|
|
window.desktop_height =
|
|
window.innerHeight - window.toolbar_height - window.taskbar_height;
|
|
window.desktop_width = window.innerWidth;
|
|
|
|
// Re-center the login window
|
|
const top = $('.window-login').position()?.top;
|
|
const width = $('.window-login').width();
|
|
$('.window-login').css({
|
|
left: (window.desktop_width - width) / 2,
|
|
top: top / ratio,
|
|
});
|
|
|
|
// Re-center the create account window
|
|
const top2 = $('.window-signup').position()?.top;
|
|
const width2 = $('.window-signup').width();
|
|
$('.window-signup').css({
|
|
left: (window.desktop_width - width2) / 2,
|
|
top: top2 / ratio,
|
|
});
|
|
});
|
|
|
|
$(document).on('contextmenu', '.disable-context-menu', function (e) {
|
|
if ($(e.target).hasClass('disable-context-menu')) {
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// util/desktop.js
|
|
window.privacy_aware_path = privacy_aware_path_factory({ window });
|
|
|
|
$(window).on('system-logout-event', function () {
|
|
// Clear cookie
|
|
document.cookie = 'puter=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
|
// Redirect to clean URL without any query parameters
|
|
const cleanUrl = window.location.origin + window.location.pathname;
|
|
window.location.replace(cleanUrl);
|
|
});
|