Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/BloomBrowserUI/publish/Apps/AppPublisherScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,11 +460,7 @@ const AppPublisherScreenContents: React.FunctionComponent<{
<Step expanded={true} completed={false}>
<StepLabel>
<AppActionButton
enabled={
prepareIsReady &&
buildIsNeeded &&
canRunBuild
}
enabled={prepareIsReady && canRunBuild}
l10nKey="PublishTab.Apps.Build"
onClick={() =>
screenState.runAction("build")
Expand Down
29 changes: 29 additions & 0 deletions src/BloomBrowserUI/publish/Apps/appBuilderShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export interface IAppBuilderStatus {
rabRoot?: string;
trackedBookTitles?: string[];
trackedBooks: IAppBuilderTrackedBook[];
/** The action currently running on the server, or undefined if idle. */
activeAction?: AppBuilderAction;
/** Last progress stage reported by the server (e.g. "building-android-app"). */
activeActionProgressStage?: string;
/** Last progress percent (0–100) reported by the server. */
activeActionProgressPercent?: number;
}

export type AppBuilderPrepareStepId =
Expand Down Expand Up @@ -74,6 +80,12 @@ export interface IAppBuilderStatusApi {
apkSizeBytes?: number;
rabRoot?: string;
trackedBookTitles?: string[];
activeAction?: string;
activeActionProgressStage?: string;
activeActionProgressPercent?: number;
ActiveAction?: string;
ActiveActionProgressStage?: string;
ActiveActionProgressPercent?: number;
RabInstalled?: boolean;
ProjectExists?: boolean;
ApkExists?: boolean;
Expand Down Expand Up @@ -129,6 +141,10 @@ export interface IProgressStageLabels {

export const kAppBuilderWebSocketContext = "publish-rab";

/// Websocket event id sent by the C# side when a prepare/build/install completes.
/// The message payload is "{action}:success" or "{action}:failure".
export const kAppBuilderActionCompleteEventId = "actionComplete";

export type AppBuilderAction = "prepare" | "build" | "install";

export const defaultStatus: IAppBuilderStatus = {
Expand Down Expand Up @@ -209,6 +225,16 @@ export function normalizeStatus(
getDefaultPrepareSteps()
).map(normalizePrepareStepStatus);

const rawActiveAction =
status?.activeAction ?? status?.ActiveAction ?? undefined;
const rawProgressStage =
status?.activeActionProgressStage ??
status?.ActiveActionProgressStage ??
undefined;
const rawProgressPercent =
status?.activeActionProgressPercent ??
status?.ActiveActionProgressPercent ??
undefined;
return {
rabInstalled: status?.rabInstalled ?? status?.RabInstalled ?? false,
projectExists: status?.projectExists ?? status?.ProjectExists ?? false,
Expand All @@ -230,6 +256,9 @@ export function normalizeStatus(
trackedBooks: (status?.trackedBooks ?? status?.TrackedBooks ?? []).map(
normalizeTrackedBook,
),
activeAction: rawActiveAction as AppBuilderAction | undefined,
activeActionProgressStage: rawProgressStage,
activeActionProgressPercent: rawProgressPercent,
};
}

Expand Down
172 changes: 151 additions & 21 deletions src/BloomBrowserUI/publish/Apps/useAppBuilderPublisherScreen.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as React from "react";
import { get, getWithPromise, postData, postJson } from "../../utils/bloomApi";
import {
get,
getWithPromise,
post,
postData,
postJson,
} from "../../utils/bloomApi";
import {
IBloomWebSocketProgressEvent,
useSubscribeToWebSocketForEvent,
Expand All @@ -16,6 +22,7 @@ import {
IAppBuilderStatusApi,
IAppBuilderSizeEstimatesApi,
kAppBuilderWebSocketContext,
kAppBuilderActionCompleteEventId,
normalizeSizeEstimates,
normalizeStatus,
AppBuilderAction,
Expand Down Expand Up @@ -158,6 +165,34 @@ export function useAppBuilderPublisherScreen(
statusRetryTimeoutRef.current = undefined;
}
setStatus(nextStatus);
// If the backend reports an action is running but we have no local busyAction
// (e.g. after a remount mid-build), restore the action and its last-known progress
// so the indicator appears immediately without waiting for a websocket event.
if (nextStatus.activeAction) {
const isRestoringFromBlank = !busyActionRef.current;
setBusyAction(
(current) =>
current ??
(nextStatus.activeAction as AppBuilderAction),
);
if (isRestoringFromBlank) {
if (nextStatus.activeActionProgressStage) {
setProgressStageCode(
nextStatus.activeActionProgressStage,
);
}
setProgressPercent(
nextStatus.activeActionProgressPercent ?? 0,
);
}
} else if (busyActionRef.current) {
// Server is idle but client still thinks an action is running —
// recover by clearing the stale busy state (e.g. after a remount
// that missed the actionComplete websocket event).
setBusyAction(undefined);
setProgressPercent(0);
setProgressStageCode(undefined);
}

if (initializeSettingsAfterPrepare && nextStatus.appDefPath) {
const initializedSettings = await initializeAppBuilderSettings(
Expand Down Expand Up @@ -198,18 +233,77 @@ export function useAppBuilderPublisherScreen(
}
}

// This effect is warranted because tab activation is an external UI lifecycle boundary,
// and we need to reset the ephemeral BloomPUB cache plus re-read the RAB project whenever the Apps screen becomes active.
// Track busyAction in a ref so async callbacks can read the latest value
// without closing over a stale render's state.
const busyActionRef = React.useRef(busyAction);
busyActionRef.current = busyAction;

// This effect is warranted because tab activation is an external UI lifecycle boundary.
// We fetch status first so any active build, its current stage, and its progress are
// shown immediately — without waiting for the next websocket event — and so we know
// whether it's safe to reset the ephemeral BloomPUB cache.
React.useEffect(() => {
if (!isActive) {
return;
}

void postData("publish/rab/reset-bloompub-cache", {}).then(() => {
void (async () => {
// Snapshot the current server state right away.
let activeActionFromServer: AppBuilderAction | undefined;
try {
const nextStatus = await fetchStatusAsync();
if (!isMountedRef.current) {
return;
}
setStatus(nextStatus);
activeActionFromServer = nextStatus.activeAction;
if (activeActionFromServer) {
// Only restore server-side progress when we weren't already tracking
// the action locally — if busyAction is already set, live websocket
// events have fresher values than the status snapshot might.
const wasAlreadyTracking = !!busyActionRef.current;
setBusyAction(
(current) => current ?? activeActionFromServer!,
);
if (!wasAlreadyTracking) {
if (nextStatus.activeActionProgressStage) {
setProgressStageCode(
nextStatus.activeActionProgressStage,
);
}
setProgressPercent(
nextStatus.activeActionProgressPercent ?? 0,
);
}
}
void refreshSettings(nextStatus.appDefPath);
} catch {
// Status fetch failed; fall through to the cache-reset path which
// will call refreshStatus() for its own retry handling.
}

if (!isMountedRef.current) {
return;
}
refreshSizeEstimates();

if (activeActionFromServer) {
// A background action is running — skip the cache reset to avoid
// deleting BloomPUBs the build is actively using.
return;
}

// No active action: clear the stale per-session BloomPUB cache, then
// do a full status refresh to pick up any changes since the last visit.
await postData("publish/rab/reset-bloompub-cache", {});
if (!isMountedRef.current) {
return;
}
void refreshStatus();
refreshSizeEstimates();
});
// refreshStatus and refreshSizeEstimates are intentionally omitted so this only reruns when the tab activation boundary changes.
})();
// fetchStatusAsync, refreshSettings, refreshSizeEstimates, and refreshStatus
// are intentionally omitted — this only reruns at the tab-activation boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive]);

Expand Down Expand Up @@ -265,24 +359,30 @@ export function useAppBuilderPublisherScreen(
return;
}

// We know the action is done — clear busy state immediately rather than
// delegating to refreshStatus's recovery else-branch (which is for remounts
// that missed the completion event).
setBusyAction(undefined);
setProgressPercent(0);
setProgressStageCode(undefined);
if (action === "prepare" || action === "build") {
setPendingBuildNeeded(false);
refreshSizeEstimates();
}
// Fetch updated server state: APK path, project existence, build signature,
// and (for prepare) initialize settings from the new appDef.
await refreshStatus(initializeSettingsAfterPrepare);
if (!isMountedRef.current) {
return;
}

setBusyAction(undefined);
}

function runAction(action: AppBuilderAction): void {
if (action === "build" && !hasRequiredBuildSettings(settings)) {
return;
}
// UI guards (canRunBuild, canRunPrepare …) should prevent this, but bail out
// here before touching any shared state as a safety net.
if (busyAction) {
return;
}

getActionLogController(action).clear();

Expand All @@ -298,18 +398,48 @@ export function useAppBuilderPublisherScreen(
: undefined,
);
setBusyAction(action);
postData(
`publish/rab/${action}`,
{},
() => {
void handleActionCompleted(action, action === "prepare");
},
() => {
void handleActionCompleted(action, false);
},
);
// The endpoint returns immediately; actual completion arrives via the
// "actionComplete" websocket event handled by the subscription below.
// Pass a failure callback so that a server-side rejection (e.g. another action
// already running) doesn't leave busyAction permanently set.
post(`publish/rab/${action}`, undefined, () => {
// Don't think this can ever happen, since the only immediate failure mode is
// if we try to start an action while another is running, and the UI should
// prevent that. If it does, clear everything so at least the user can try again.
setBusyAction(undefined);
setProgressPercent(0);
setProgressStageCode(undefined);
});
}

// Listen for background-task completion from the server.
// useWebSocketListener keeps the callback reference current on every render
// so handleActionCompleted always has a fresh closure.
useSubscribeToWebSocketForStringMessage(
kAppBuilderWebSocketContext,
kAppBuilderActionCompleteEventId,
(result) => {
const separatorIndex = result.indexOf(":");
if (separatorIndex < 0) {
return;
}
const completedAction = result.substring(
0,
separatorIndex,
) as AppBuilderAction;
// guard against possibility of receiving a completion event that is stale somehow.
if (completedAction !== busyActionRef.current) {
return;
}
const succeeded =
result.substring(separatorIndex + 1) === "success";
void handleActionCompleted(
completedAction,
completedAction === "prepare" && succeeded,
);
},
);

function showApkInExplorerInShell(): void {
if (!status.apkPath) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export const RequiresSubscriptionOverlayWrapper: React.FunctionComponent<{
>
{props.children}
</div>
{featureStatus?.enabled || (
{featureStatus === undefined || featureStatus.enabled || (
<div
css={css`
position: absolute;
Expand Down
20 changes: 20 additions & 0 deletions src/BloomExe/Publish/Rab/RabProjectModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ public class RabProjectStatus
public RabTrackedBookInfo[] TrackedBooks { get; set; } = Array.Empty<RabTrackedBookInfo>();
public RabPrepareStepStatus[] PrepareSteps { get; set; } =
Array.Empty<RabPrepareStepStatus>();

/// <summary>
/// The action currently running ("prepare", "build", or "install"), or null if idle.
/// Lets the UI restore its busy indicator when switching back to the Apps tab mid-action.
/// </summary>
public string ActiveAction { get; set; }

/// <summary>
/// The most-recently reported progress stage code for the active action (e.g.
/// "building-android-app"), or null when idle. Sent with the status so the UI can
/// show the correct stage label immediately on tab re-activation, without waiting for
/// the next websocket progress event.
/// </summary>
public string ActiveActionProgressStage { get; set; }

/// <summary>
/// The most-recently reported progress percentage (0–100) for the active action, or
/// 0 when idle. Paired with <see cref="ActiveActionProgressStage"/> for the same reason.
/// </summary>
public int ActiveActionProgressPercent { get; set; }
}

/// <summary>
Expand Down
Loading