Skip to content

Commit c93a48f

Browse files
committed
Makes long-running Apps tool APIs run properly in background (BL-16350)
Several Apps tool APIs, especially Build, were made to kick off a background task and then return; task completion signaled by websocket. Enhanced so if you return to the tool and build is still running, it gets back into a state indicating the progress of the build. Also, Build is now more appropriately enabled; you don't have to do Customize first if all the required settings are already known.
1 parent 8e709b1 commit c93a48f

8 files changed

Lines changed: 270 additions & 68 deletions

File tree

src/BloomBrowserUI/publish/Apps/AppPublisherScreen.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -460,11 +460,7 @@ const AppPublisherScreenContents: React.FunctionComponent<{
460460
<Step expanded={true} completed={false}>
461461
<StepLabel>
462462
<AppActionButton
463-
enabled={
464-
prepareIsReady &&
465-
buildIsNeeded &&
466-
canRunBuild
467-
}
463+
enabled={prepareIsReady && canRunBuild}
468464
l10nKey="PublishTab.Apps.Build"
469465
onClick={() =>
470466
screenState.runAction("build")

src/BloomBrowserUI/publish/Apps/appBuilderShared.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export interface IAppBuilderStatus {
3434
rabRoot?: string;
3535
trackedBookTitles?: string[];
3636
trackedBooks: IAppBuilderTrackedBook[];
37+
/** The action currently running on the server, or undefined if idle. */
38+
activeAction?: AppBuilderAction;
39+
/** Last progress stage reported by the server (e.g. "building-android-app"). */
40+
activeActionProgressStage?: string;
41+
/** Last progress percent (0–100) reported by the server. */
42+
activeActionProgressPercent?: number;
3743
}
3844

3945
export type AppBuilderPrepareStepId =
@@ -74,6 +80,12 @@ export interface IAppBuilderStatusApi {
7480
apkSizeBytes?: number;
7581
rabRoot?: string;
7682
trackedBookTitles?: string[];
83+
activeAction?: string;
84+
activeActionProgressStage?: string;
85+
activeActionProgressPercent?: number;
86+
ActiveAction?: string;
87+
ActiveActionProgressStage?: string;
88+
ActiveActionProgressPercent?: number;
7789
RabInstalled?: boolean;
7890
ProjectExists?: boolean;
7991
ApkExists?: boolean;
@@ -129,6 +141,10 @@ export interface IProgressStageLabels {
129141

130142
export const kAppBuilderWebSocketContext = "publish-rab";
131143

144+
/// Websocket event id sent by the C# side when a prepare/build/install completes.
145+
/// The message payload is "{action}:success" or "{action}:failure".
146+
export const kAppBuilderActionCompleteEventId = "actionComplete";
147+
132148
export type AppBuilderAction = "prepare" | "build" | "install";
133149

134150
export const defaultStatus: IAppBuilderStatus = {
@@ -209,6 +225,16 @@ export function normalizeStatus(
209225
getDefaultPrepareSteps()
210226
).map(normalizePrepareStepStatus);
211227

228+
const rawActiveAction =
229+
status?.activeAction ?? status?.ActiveAction ?? undefined;
230+
const rawProgressStage =
231+
status?.activeActionProgressStage ??
232+
status?.ActiveActionProgressStage ??
233+
undefined;
234+
const rawProgressPercent =
235+
status?.activeActionProgressPercent ??
236+
status?.ActiveActionProgressPercent ??
237+
undefined;
212238
return {
213239
rabInstalled: status?.rabInstalled ?? status?.RabInstalled ?? false,
214240
projectExists: status?.projectExists ?? status?.ProjectExists ?? false,
@@ -230,6 +256,9 @@ export function normalizeStatus(
230256
trackedBooks: (status?.trackedBooks ?? status?.TrackedBooks ?? []).map(
231257
normalizeTrackedBook,
232258
),
259+
activeAction: rawActiveAction as AppBuilderAction | undefined,
260+
activeActionProgressStage: rawProgressStage,
261+
activeActionProgressPercent: rawProgressPercent,
233262
};
234263
}
235264

src/BloomBrowserUI/publish/Apps/useAppBuilderPublisherScreen.ts

Lines changed: 116 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import * as React from "react";
2-
import { get, getWithPromise, postData, postJson } from "../../utils/bloomApi";
2+
import {
3+
get,
4+
getWithPromise,
5+
post,
6+
postData,
7+
postJson,
8+
} from "../../utils/bloomApi";
39
import {
410
IBloomWebSocketProgressEvent,
511
useSubscribeToWebSocketForEvent,
@@ -16,6 +22,7 @@ import {
1622
IAppBuilderStatusApi,
1723
IAppBuilderSizeEstimatesApi,
1824
kAppBuilderWebSocketContext,
25+
kAppBuilderActionCompleteEventId,
1926
normalizeSizeEstimates,
2027
normalizeStatus,
2128
AppBuilderAction,
@@ -158,6 +165,27 @@ export function useAppBuilderPublisherScreen(
158165
statusRetryTimeoutRef.current = undefined;
159166
}
160167
setStatus(nextStatus);
168+
// If the backend reports an action is running but we have no local busyAction
169+
// (e.g. after a remount mid-build), restore the action and its last-known progress
170+
// so the indicator appears immediately without waiting for a websocket event.
171+
if (nextStatus.activeAction) {
172+
const isRestoringFromBlank = !busyActionRef.current;
173+
setBusyAction(
174+
(current) =>
175+
current ??
176+
(nextStatus.activeAction as AppBuilderAction),
177+
);
178+
if (isRestoringFromBlank) {
179+
if (nextStatus.activeActionProgressStage) {
180+
setProgressStageCode(
181+
nextStatus.activeActionProgressStage,
182+
);
183+
}
184+
setProgressPercent(
185+
nextStatus.activeActionProgressPercent ?? 0,
186+
);
187+
}
188+
}
161189

162190
if (initializeSettingsAfterPrepare && nextStatus.appDefPath) {
163191
const initializedSettings = await initializeAppBuilderSettings(
@@ -198,18 +226,73 @@ export function useAppBuilderPublisherScreen(
198226
}
199227
}
200228

201-
// This effect is warranted because tab activation is an external UI lifecycle boundary,
202-
// and we need to reset the ephemeral BloomPUB cache plus re-read the RAB project whenever the Apps screen becomes active.
229+
// Track busyAction in a ref so async callbacks can read the latest value
230+
// without closing over a stale render's state.
231+
const busyActionRef = React.useRef(busyAction);
232+
busyActionRef.current = busyAction;
233+
234+
// This effect is warranted because tab activation is an external UI lifecycle boundary.
235+
// We fetch status first so any active build, its current stage, and its progress are
236+
// shown immediately — without waiting for the next websocket event — and so we know
237+
// whether it's safe to reset the ephemeral BloomPUB cache.
203238
React.useEffect(() => {
204239
if (!isActive) {
205240
return;
206241
}
207242

208-
void postData("publish/rab/reset-bloompub-cache", {}).then(() => {
243+
void (async () => {
244+
// Snapshot the current server state right away.
245+
let activeActionFromServer: AppBuilderAction | undefined;
246+
try {
247+
const nextStatus = await fetchStatusAsync();
248+
if (!isMountedRef.current) {
249+
return;
250+
}
251+
setStatus(nextStatus);
252+
activeActionFromServer = nextStatus.activeAction;
253+
if (activeActionFromServer) {
254+
setBusyAction(
255+
(current) => current ?? activeActionFromServer!,
256+
);
257+
// Restore the last-known stage and progress so the indicator
258+
// appears immediately rather than waiting for the next websocket event.
259+
if (nextStatus.activeActionProgressStage) {
260+
setProgressStageCode(
261+
nextStatus.activeActionProgressStage,
262+
);
263+
}
264+
setProgressPercent(
265+
nextStatus.activeActionProgressPercent ?? 0,
266+
);
267+
}
268+
void refreshSettings(nextStatus.appDefPath);
269+
} catch {
270+
// Status fetch failed; fall through to the cache-reset path which
271+
// will call refreshStatus() for its own retry handling.
272+
}
273+
274+
if (!isMountedRef.current) {
275+
return;
276+
}
277+
refreshSizeEstimates();
278+
279+
if (activeActionFromServer) {
280+
// A background action is running — skip the cache reset to avoid
281+
// deleting BloomPUBs the build is actively using.
282+
return;
283+
}
284+
285+
// No active action: clear the stale per-session BloomPUB cache, then
286+
// do a full status refresh to pick up any changes since the last visit.
287+
await postData("publish/rab/reset-bloompub-cache", {});
288+
if (!isMountedRef.current) {
289+
return;
290+
}
209291
void refreshStatus();
210292
refreshSizeEstimates();
211-
});
212-
// refreshStatus and refreshSizeEstimates are intentionally omitted so this only reruns when the tab activation boundary changes.
293+
})();
294+
// fetchStatusAsync, refreshSettings, refreshSizeEstimates, and refreshStatus
295+
// are intentionally omitted — this only reruns at the tab-activation boundary.
213296
// eslint-disable-next-line react-hooks/exhaustive-deps
214297
}, [isActive]);
215298

@@ -298,18 +381,35 @@ export function useAppBuilderPublisherScreen(
298381
: undefined,
299382
);
300383
setBusyAction(action);
301-
postData(
302-
`publish/rab/${action}`,
303-
{},
304-
() => {
305-
void handleActionCompleted(action, action === "prepare");
306-
},
307-
() => {
308-
void handleActionCompleted(action, false);
309-
},
310-
);
384+
// The endpoint returns immediately; actual completion arrives via the
385+
// "actionComplete" websocket event handled by the subscription below.
386+
post(`publish/rab/${action}`);
311387
}
312388

389+
// Listen for background-task completion from the server.
390+
// useWebSocketListener keeps the callback reference current on every render
391+
// so handleActionCompleted always has a fresh closure.
392+
useSubscribeToWebSocketForStringMessage(
393+
kAppBuilderWebSocketContext,
394+
kAppBuilderActionCompleteEventId,
395+
(result) => {
396+
const separatorIndex = result.indexOf(":");
397+
if (separatorIndex < 0) {
398+
return;
399+
}
400+
const completedAction = result.substring(
401+
0,
402+
separatorIndex,
403+
) as AppBuilderAction;
404+
const succeeded =
405+
result.substring(separatorIndex + 1) === "success";
406+
void handleActionCompleted(
407+
completedAction,
408+
completedAction === "prepare" && succeeded,
409+
);
410+
},
411+
);
412+
313413
function showApkInExplorerInShell(): void {
314414
if (!status.apkPath) {
315415
return;

src/BloomBrowserUI/react_components/requiresSubscription.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export const RequiresSubscriptionOverlayWrapper: React.FunctionComponent<{
165165
>
166166
{props.children}
167167
</div>
168-
{featureStatus?.enabled || (
168+
{featureStatus === undefined || featureStatus.enabled || (
169169
<div
170170
css={css`
171171
position: absolute;

src/BloomExe/Publish/Rab/RabProjectModels.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ public class RabProjectStatus
2828
public RabTrackedBookInfo[] TrackedBooks { get; set; } = Array.Empty<RabTrackedBookInfo>();
2929
public RabPrepareStepStatus[] PrepareSteps { get; set; } =
3030
Array.Empty<RabPrepareStepStatus>();
31+
32+
/// <summary>
33+
/// The action currently running ("prepare", "build", or "install"), or null if idle.
34+
/// Lets the UI restore its busy indicator when switching back to the Apps tab mid-action.
35+
/// </summary>
36+
public string ActiveAction { get; set; }
37+
38+
/// <summary>
39+
/// The most-recently reported progress stage code for the active action (e.g.
40+
/// "building-android-app"), or null when idle. Sent with the status so the UI can
41+
/// show the correct stage label immediately on tab re-activation, without waiting for
42+
/// the next websocket progress event.
43+
/// </summary>
44+
public string ActiveActionProgressStage { get; set; }
45+
46+
/// <summary>
47+
/// The most-recently reported progress percentage (0–100) for the active action, or
48+
/// 0 when idle. Paired with <see cref="ActiveActionProgressStage"/> for the same reason.
49+
/// </summary>
50+
public int ActiveActionProgressPercent { get; set; }
3151
}
3252

3353
/// <summary>

src/BloomExe/Publish/Rab/RabProjectService.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ string packageSegment
8080
private readonly CollectionSettings _collectionSettings;
8181
private readonly BloomWebSocketServer _webSocketServer;
8282
private readonly IWebSocketProgress _progress;
83-
private string _activeProgressAction;
83+
private volatile string _activeProgressAction;
8484
private int _lastBuildProgressPercent;
8585
private string _lastLoggedProgressStage;
8686
private int? _lastLoggedProgressPercent;
@@ -379,6 +379,11 @@ public RabProjectStatus GetStatus()
379379
TrackedBooks = trackedBooks,
380380
TrackedBookTitles = trackedBooks.Select(book => book.Title).ToArray(),
381381
PrepareSteps = prepareSteps,
382+
ActiveAction = _activeProgressAction,
383+
ActiveActionProgressStage =
384+
_activeProgressAction != null ? _lastLoggedProgressStage : null,
385+
ActiveActionProgressPercent =
386+
_activeProgressAction != null ? (_lastLoggedProgressPercent ?? 0) : 0,
382387
};
383388

384389
if (status.ProjectExists)
@@ -432,6 +437,19 @@ public RabAppSizeEstimates GetSizeEstimates()
432437
};
433438
}
434439

440+
/// <summary>
441+
/// Sends an "actionComplete" websocket event so the Apps screen knows when a background
442+
/// prepare/build/install has finished. Called from the finally block of each action.
443+
/// </summary>
444+
private void SendActionCompleteEvent(string action, bool succeeded)
445+
{
446+
_webSocketServer.SendString(
447+
kWebSocketContext,
448+
RabPublishApi.kWebSocketEventId_ActionComplete,
449+
$"{action}:{(succeeded ? "success" : "failure")}"
450+
);
451+
}
452+
435453
/// <summary>
436454
/// Reports an App Builder failure to Bloom's log and the progress channel used by the Apps screen.
437455
/// </summary>
@@ -450,6 +468,7 @@ private void Prepare()
450468
{
451469
var paths = GetPaths();
452470
_activeProgressAction = "prepare";
471+
var succeeded = false;
453472
try
454473
{
455474
ReportProgressStage("checking-installer", 0);
@@ -544,10 +563,12 @@ private void Prepare()
544563
ReportProgressStage("complete", 100);
545564
_progress.MessageWithoutLocalizing("Prepare complete.", ProgressKind.Heading);
546565
_progress.MessageWithoutLocalizing($"Project file: {existingProjectPath}");
566+
succeeded = true;
547567
}
548568
finally
549569
{
550570
_activeProgressAction = null;
571+
SendActionCompleteEvent("prepare", succeeded);
551572
}
552573
}
553574

@@ -655,6 +676,7 @@ private void Build()
655676
EnsureWorkspaceFolders(paths);
656677
_activeProgressAction = "build";
657678
_lastBuildProgressPercent = 0;
679+
var succeeded = false;
658680
try
659681
{
660682
ReportProgressStage("preparing-build", 0);
@@ -735,10 +757,12 @@ private void Build()
735757
);
736758
state.LastBuiltApkPath = apkPath;
737759
SaveState(paths, state);
760+
succeeded = true;
738761
}
739762
finally
740763
{
741764
_activeProgressAction = null;
765+
SendActionCompleteEvent("build", succeeded);
742766
}
743767
}
744768

@@ -747,6 +771,7 @@ private void Install()
747771
// Install re-reads package/app metadata from the project so launching uses the same identity that was built.
748772
var paths = GetPaths();
749773
_activeProgressAction = "install";
774+
var succeeded = false;
750775
try
751776
{
752777
var apkPath = FindLatestApkPath(paths);
@@ -825,10 +850,12 @@ private void Install()
825850
$"Install complete. Opened {appName} on {device.DisplayName}.",
826851
ProgressKind.Heading
827852
);
853+
succeeded = true;
828854
}
829855
finally
830856
{
831857
_activeProgressAction = null;
858+
SendActionCompleteEvent("install", succeeded);
832859
}
833860
}
834861

0 commit comments

Comments
 (0)