Skip to content

Commit 2dfcbd3

Browse files
committed
feat(boot): coordinate boot-time greeting dialogs via BootGreetings
Introduce a small coordinator (utils/BootGreetings) that named boot dialogs register against. Consumers like the onboarding tour await `allDismissed()` so they no longer race the auto-update "What's New" dialog or the pro trial-start dialog. - New utils/BootGreetings: registerBlocker / unblockBlocker / allDismissed. Misuse (missing name, duplicate name, unknown unblock) is reported via logger.reportError instead of throwing — boot stability matters more. - appUpdater (Tauri + Electron): register an `updater-*` gate and unblock it after the existing "What's New" dialog dismisses, or immediately on every early-return path. - phoenix-tour: wait on BootGreetings.allDismissed() before starting. Drop the pre-wait button-presence and editor-collapsed gates so the tour fires on every first run; report missing step anchors via logger.reportError rather than bailing silently. - strings: add PROMO_PRO_WHATS_NEW_TITLE / PROMO_PRO_WHATS_NEW_MESSAGE used by the pro paid-user "what's new" dialog (lives in phoenix-pro).
1 parent 13d3234 commit 2dfcbd3

5 files changed

Lines changed: 161 additions & 60 deletions

File tree

src/extensionsIntegrated/Phoenix/phoenix-tour.js

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
*
1919
*/
2020

21-
/*global PhStore */
21+
/*global PhStore, logger */
2222

2323
/**
2424
* One-shot, app-lifetime onboarding tour that introduces the design-mode
@@ -32,6 +32,7 @@ define(function (require, exports, module) {
3232
const Strings = require("strings"),
3333
StringUtils = require("utils/StringUtils"),
3434
Metrics = require("utils/Metrics"),
35+
BootGreetings = require("utils/BootGreetings"),
3536
SidebarView = require("project/SidebarView"),
3637
SidebarTabs = require("view/SidebarTabs"),
3738
ProjectManager = require("project/ProjectManager"),
@@ -41,12 +42,6 @@ define(function (require, exports, module) {
4142
WorkspaceManager = require("view/WorkspaceManager"),
4243
CentralControlBar = require("view/CentralControlBar");
4344

44-
// Capture the kernel trust ring at module-load time — it's deleted from
45-
// `window` shortly after boot. Treated as optional: community-edition
46-
// builds without the pro trial flow won't expose `loginService` and the
47-
// tour will simply proceed without waiting.
48-
const _LoginService = (window.KernalModeTrust && window.KernalModeTrust.loginService) || null;
49-
5045
const TOUR_STORAGE_KEY = "phoenixOnboardingTourState";
5146
const CURRENT_TOUR_VERSION = 1;
5247

@@ -287,6 +282,7 @@ define(function (require, exports, module) {
287282
_ensureSidebarVisible();
288283
const $btn = $("#ccbCollapseEditorBtn");
289284
if (!$btn.length) {
285+
logger.reportError(new Error("phoenix-tour: #ccbCollapseEditorBtn missing at step 1"));
290286
_markComplete();
291287
_teardown();
292288
return;
@@ -337,7 +333,7 @@ define(function (require, exports, module) {
337333
_ensureSidebarVisible();
338334
const $tab = $('.sidebar-tab[data-tab-id="ai"]');
339335
if (!$tab.length) {
340-
// No AI tab in this build — skip ahead to the next step.
336+
logger.reportError(new Error("phoenix-tour: AI sidebar tab missing at step 2"));
341337
_runStep3();
342338
return;
343339
}
@@ -369,8 +365,7 @@ define(function (require, exports, module) {
369365
_ensureSidebarVisible();
370366
const $newBtn = $("#newProject");
371367
if (!$newBtn.length) {
372-
// No new-project button — skip to the live-preview step instead
373-
// of giving up on the tour entirely.
368+
logger.reportError(new Error("phoenix-tour: #newProject missing at step 3"));
374369
_runStep4();
375370
return;
376371
}
@@ -457,8 +452,7 @@ define(function (require, exports, module) {
457452

458453
const $btn = $("#previewModeLivePreviewButton");
459454
if (!$btn.length) {
460-
// LP panel never came up (custom server, unsupported file, etc.)
461-
// — finalize the tour rather than stalling on a missing target.
455+
logger.reportError(new Error("phoenix-tour: #previewModeLivePreviewButton missing at step 4"));
462456
_markComplete();
463457
_teardown();
464458
return;
@@ -495,61 +489,21 @@ define(function (require, exports, module) {
495489
if (Phoenix.isTestWindow || Phoenix.isSpecRunnerWindow) {
496490
return false;
497491
}
498-
if (CentralControlBar.isEditorCollapsed && CentralControlBar.isEditorCollapsed()) {
499-
// User has already discovered design mode in some other way.
500-
return false;
501-
}
502-
if (!$("#ccbCollapseEditorBtn").length) {
503-
return false;
504-
}
505492
return true;
506493
}
507494

508-
/**
509-
* Resolves once the pro trial start dialog has been dismissed. The
510-
* dialog is guaranteed to fire `proTrialStartDialogDismissed` on every
511-
* boot path (including builds where the dialog isn't shown), so we
512-
* just await it without a timeout fallback.
513-
*/
514-
function _waitForTrialStartDialogDismissed() {
515-
const dismissed = _LoginService && _LoginService.proTrialStartDialogDismissed;
516-
// Community-edition builds expose no login service at all — skip
517-
// the wait so the tour still works there.
518-
if (!dismissed) {
519-
return Promise.resolve();
520-
}
521-
return Promise.resolve(dismissed);
522-
}
523-
524495
function startTour() {
525496
if (!_shouldRun()) {
526497
return;
527498
}
528499
_ranThisSession = true;
529500
Metrics.countEvent(Metrics.EVENT_TYPE.GUIDE, "tour", "start");
530501

531-
_waitForTrialStartDialogDismissed().then(function () {
532-
// Re-check primary preconditions after the wait — the user may
533-
// have already discovered design mode while a trial dialog was
534-
// up, or the button may have been torn down.
535-
if (!$("#ccbCollapseEditorBtn").length) {
536-
_markComplete();
537-
_teardown();
538-
return;
539-
}
540-
if (CentralControlBar.isEditorCollapsed && CentralControlBar.isEditorCollapsed()) {
541-
_markComplete();
542-
_teardown();
543-
return;
544-
}
545-
_timers.push(setTimeout(function () {
546-
if (!$("#ccbCollapseEditorBtn").length) {
547-
_markComplete();
548-
_teardown();
549-
return;
550-
}
551-
_runStep1();
552-
}, STEP_START_DELAY_MS));
502+
// Wait until every boot-time greeting dialog (auto/manual update
503+
// "What's New", pro trial start, paid-Pro "What's New") has been
504+
// dismissed.
505+
BootGreetings.allDismissed().then(function () {
506+
_timers.push(setTimeout(_runStep1, STEP_START_DELAY_MS));
553507
});
554508
}
555509

src/extensionsIntegrated/appUpdater/main.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,17 @@ define(function (require, exports, module) {
4242
TaskManager = require("features/TaskManager"),
4343
StringUtils = require("utils/StringUtils"),
4444
NativeApp = require("utils/NativeApp"),
45+
BootGreetings = require("utils/BootGreetings"),
4546
PreferencesManager = require("preferences/PreferencesManager");
47+
48+
// Reserve a slot in the boot-greeting coordinator so the tour can wait
49+
// until the updater has either shown its "What's New" dialog (auto or
50+
// manual update) or decided not to. Unblocked once per boot.
51+
const UPDATER_GATE = "updater-tauri";
52+
BootGreetings.registerBlocker(UPDATER_GATE);
53+
function _unblockUpdaterGate() {
54+
BootGreetings.unblockBlocker(UPDATER_GATE);
55+
}
4656
let updaterWindow, updateTask, updatePendingRestart, updateFailed;
4757

4858
const TAURI_UPDATER_WINDOW_LABEL = "updater",
@@ -519,14 +529,18 @@ define(function (require, exports, module) {
519529
let updateInstalledDialogShown = false, updateFailedDialogShown = false;
520530
AppInit.appReady(function () {
521531
if(Phoenix.isTestWindow) {
532+
_unblockUpdaterGate();
522533
return;
523534
}
524535
if(window.__ELECTRON__) {
525-
// Electron updates handled by update-electron.js
536+
// Electron updates handled by update-electron.js — that
537+
// module owns its own blocker, so this one is a no-op.
538+
_unblockUpdaterGate();
526539
return;
527540
}
528541
if(!window.__TAURI__) {
529542
// app updates are only for desktop builds
543+
_unblockUpdaterGate();
530544
return;
531545
}
532546
if (brackets.platform === "mac") {
@@ -616,13 +630,16 @@ define(function (require, exports, module) {
616630
const lastUpdateDetails = PreferencesManager.getViewState(KEY_LAST_UPDATE_DESCRIPTION);
617631
if(lastUpdateDetails && (lastUpdateDetails.updateVersion === Phoenix.metadata.apiVersion)) {
618632
let markdownHtml = marked.parse(lastUpdateDetails.releaseNotesMarkdown || "");
619-
Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml);
633+
Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml)
634+
.done(_unblockUpdaterGate);
620635
PreferencesManager.setViewState(KEY_LAST_UPDATE_DESCRIPTION, null);
621636
PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, false);
622637
// hide the update available icon as we are showing what's new dialog. In edge cases, there can be an update
623638
// at this time if the user opened phcode after an update, but a new update was just published or the user
624639
// didn't open phcode after last update, which a new update was published.
625640
$("#update-notification").addClass("forced-hidden");
641+
} else {
642+
_unblockUpdaterGate();
626643
}
627644
// check for updates at boot
628645
let lastUpdateCheckTime = PreferencesManager.getViewState(KEY_LAST_UPDATE_CHECK_TIME);

src/extensionsIntegrated/appUpdater/update-electron.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,18 @@ define(function (require, exports, module) {
3737
NotificationUI = require("widgets/NotificationUI"),
3838
TaskManager = require("features/TaskManager"),
3939
NativeApp = require("utils/NativeApp"),
40+
BootGreetings = require("utils/BootGreetings"),
4041
PreferencesManager = require("preferences/PreferencesManager");
4142

43+
// Reserve a slot in the boot-greeting coordinator so the tour can wait
44+
// until the updater has either shown its "What's New" dialog (auto or
45+
// manual update) or decided not to. Unblocked once per boot.
46+
const UPDATER_GATE = "updater-electron";
47+
BootGreetings.registerBlocker(UPDATER_GATE);
48+
function _unblockUpdaterGate() {
49+
BootGreetings.unblockBlocker(UPDATER_GATE);
50+
}
51+
4252
let updateTask, updatePendingRestart, updateFailed;
4353

4454
const KEY_LAST_UPDATE_CHECK_TIME = "PH_LAST_UPDATE_CHECK_TIME",
@@ -448,11 +458,13 @@ define(function (require, exports, module) {
448458

449459
AppInit.appReady(async function () {
450460
if(!window.__ELECTRON__ || Phoenix.isTestWindow) {
461+
_unblockUpdaterGate();
451462
return;
452463
}
453464
// Electron updates only supported on Linux currently
454465
if (brackets.platform !== "linux") {
455466
console.error("App updates not yet implemented on this platform in Electron builds!");
467+
_unblockUpdaterGate();
456468
return;
457469
}
458470
// Check if another window already scheduled an update (multi-window state persistence)
@@ -496,10 +508,13 @@ define(function (require, exports, module) {
496508
const lastUpdateDetails = PreferencesManager.getViewState(KEY_LAST_UPDATE_DESCRIPTION);
497509
if(lastUpdateDetails && (lastUpdateDetails.updateVersion === Phoenix.metadata.apiVersion)) {
498510
let markdownHtml = marked.parse(lastUpdateDetails.releaseNotesMarkdown || "");
499-
Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml);
511+
Dialogs.showInfoDialog(Strings.UPDATE_WHATS_NEW, markdownHtml)
512+
.done(_unblockUpdaterGate);
500513
PreferencesManager.setViewState(KEY_LAST_UPDATE_DESCRIPTION, null);
501514
PreferencesManager.setViewState(KEY_UPDATE_AVAILABLE, false);
502515
$("#update-notification").addClass("forced-hidden");
516+
} else {
517+
_unblockUpdaterGate();
503518
}
504519
// check for updates at boot
505520
let lastUpdateCheckTime = PreferencesManager.getViewState(KEY_LAST_UPDATE_CHECK_TIME);

src/nls/root/strings.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2084,6 +2084,8 @@ define({
20842084
// promos
20852085
"PROMO_UPGRADE_TITLE": "You’ve been upgraded to {0}",
20862086
"PROMO_UPGRADE_MESSAGE": "Enjoy free access to these premium features for the next {0} days:",
2087+
"PROMO_PRO_WHATS_NEW_TITLE": "New in {0}",
2088+
"PROMO_PRO_WHATS_NEW_MESSAGE": "Thanks for being a {0} member. Here’s what’s new in this update:",
20872089
"PROMO_CARD_1": "Edit In Live Preview",
20882090
"PROMO_CARD_1_MESSAGE": "Edit text, update images, change links, drag elements, and more. Your code updates as you go.",
20892091
"PROMO_CARD_2": "Try ideas, build pages, and fix issues with AI",

src/utils/BootGreetings.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* GNU AGPL-3.0 License
3+
*
4+
* Copyright (c) 2021 - present core.ai . All rights reserved.
5+
*
6+
* This program is free software: you can redistribute it and/or modify it
7+
* under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful, but WITHOUT
12+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
14+
* for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see https://opensource.org/licenses/AGPL-3.0.
18+
*
19+
*/
20+
21+
/*global logger*/
22+
23+
/**
24+
* Coordinates the boot-time greeting dialogs (auto-update "What's New",
25+
* pro trial start, paid-Pro "What's New") so that downstream UI like the
26+
* onboarding tour can wait until none of them are on screen.
27+
*
28+
* Usage:
29+
* // At module load (synchronously, before AppInit.appReady):
30+
* BootGreetings.registerBlocker("pro-greeting");
31+
*
32+
* // Once that module has decided/finished — every code path, including
33+
* // error paths:
34+
* BootGreetings.unblockBlocker("pro-greeting");
35+
*
36+
* // From the consumer (e.g. onboarding tour):
37+
* await BootGreetings.allDismissed();
38+
*
39+
* Contract: every registered blocker MUST be unblocked on every code
40+
* path. If a module forgets, `allDismissed` stays pending forever and the
41+
* downstream UI never fires. The names make a forgotten gate easy to
42+
* spot in a debugger by inspecting which entries are still pending.
43+
*/
44+
define(function (require, exports, module) {
45+
46+
// Name -> entry. Each entry holds the promise and resolver and a
47+
// `resolved` flag so a duplicate `unblockBlocker` is a no-op.
48+
const _blockers = new Map();
49+
50+
/**
51+
* Reserve a named blocker. Names must be unique and non-empty —
52+
* misuse is reported via `logger.reportError` (which surfaces to the
53+
* error logger without crashing the app) and the call is otherwise a
54+
* no-op. Boot stability matters more than a strict contract here.
55+
*
56+
* @param {string} name Short identifier (e.g. "pro-greeting").
57+
*/
58+
function registerBlocker(name) {
59+
if (!name) {
60+
logger.reportError(new Error("BootGreetings.registerBlocker called without a name"));
61+
return;
62+
}
63+
if (_blockers.has(name)) {
64+
logger.reportError(new Error(
65+
"BootGreetings: blocker '" + name + "' is already registered"));
66+
return;
67+
}
68+
let resolveFn;
69+
const promise = new Promise(function (resolve) {
70+
resolveFn = resolve;
71+
});
72+
_blockers.set(name, { promise: promise, resolve: resolveFn, resolved: false });
73+
}
74+
75+
/**
76+
* Mark a registered blocker as done. Safe to call more than once.
77+
* An unknown name is reported via `logger.reportError` but does not
78+
* disrupt the boot flow.
79+
*
80+
* @param {string} name The name passed to `registerBlocker`.
81+
*/
82+
function unblockBlocker(name) {
83+
const entry = _blockers.get(name);
84+
if (!entry) {
85+
logger.reportError(new Error(
86+
"BootGreetings.unblockBlocker: unknown blocker '" + name + "'"));
87+
return;
88+
}
89+
if (entry.resolved) {
90+
return;
91+
}
92+
entry.resolved = true;
93+
entry.resolve();
94+
}
95+
96+
/**
97+
* Resolves once every registered blocker has been unblocked.
98+
*
99+
* @return {Promise<void>}
100+
*/
101+
function allDismissed() {
102+
if (!_blockers.size) {
103+
return Promise.resolve();
104+
}
105+
const promises = [];
106+
_blockers.forEach(function (entry) { promises.push(entry.promise); });
107+
return Promise.all(promises);
108+
}
109+
110+
exports.registerBlocker = registerBlocker;
111+
exports.unblockBlocker = unblockBlocker;
112+
exports.allDismissed = allDismissed;
113+
});

0 commit comments

Comments
 (0)