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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ bun).
without updating README's privacy section.
- Follow the Pickforge design system: ember `#FF7A1A` accent, Geist/Geist Mono,
tokens over raw values.
- Async waits built on event listeners must re-check the awaited condition
right after the listener registers (the state can change in the gap) and
re-verify every condition on each event instead of trusting the event
payload alone — pickgauge#70's eligibility gate shipped both mistakes.

## Releasing

Expand Down
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 21 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ reset this file.

- Added blocking CI gates for Rust and frontend complexity, coverage, secrets,
and high/critical dependency advisories, with one required aggregate status.
- Added the shared `@pickforge/tauri-updater` dialog behind the `studioUpdateDialog`
flag (default off; `@pickforge/flags`), part of
pickforge/pickforge-platform#36. While off, the legacy `window.confirm`
updater in `src/lib/updater.ts` is untouched. When on, `src/lib/updateDialog.ts`
mounts the shared controller only on the visible main window: a Tauri
window-label check excludes the floating capsule outright, and a hidden
main window (tray/login-start) defers the check until its first focus
event, mirroring the pre-existing `checkForUpdatesWhenVisible` deferral.
One controller per process enforces a single check. A dev-only fixture
(`?updateDialogFixture=available|downloading`, tree-shaken from production
builds) stands in for a visual baseline since PickGauge has no VRT harness.
- Headless `usage --json` refreshes independent providers concurrently
(join-all, emit in fixed service order) so an offline credentialed install
pays ~max per-provider timeout instead of the sequential sum.
Expand Down Expand Up @@ -154,8 +165,18 @@ reset this file.
candidate by several days. The validated candidate remains installed with a
checksum-verified rollback backup.

- pickforge/pickforge-platform#36 (PR 5, PickGauge integration): `bun run
lint`, `bun run check`, `bun run test` (85 vitest tests including
`flags.test.ts` and `updateDialog.test.ts`, covering flag-off default,
eligibility for an already-visible main window, capsule exclusion by
window label, and hidden-main-window deferral until focus), and `bun run
test:coverage` (ratchet holds). No Rust touched.

### Not yet tested

- pickforge/pickforge-platform#36 (PR 5): owner-gated packaged-update smoke
(old build to staged newer build) and design-lead visual acceptance from
the dev-only fixture; both deferred to a later PR per the issue's PR plan.
- Issue #49: real KDE Wayland/XWayland Alt+Tab, taskbar, and pager behavior
for the float capsule — deferred to Elberte-PC, a live KDE session with
`qdbus`/`kwriteconfig6` available.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
},
"dependencies": {
"@pickforge/brand": "0.2.1",
"@pickforge/flags": "^0.4.0",
"@pickforge/tauri-updater": "^0.12.0",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
Expand Down
13 changes: 13 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
EVENT_SNAPSHOTS,
} from "./lib/api";
import { serviceLabels, settingsSaveDisplayState } from "./lib/display";
import { flagEnabled } from "./lib/flags";
import { mountUpdateDialog, type UpdateDialogHost } from "./lib/updateDialog";
import { shouldMountSharedUpdateDialog } from "./lib/updateRouting";
import {
browserPreviewSnapshots,
browserPreviewStateFromSearch,
Expand Down Expand Up @@ -47,6 +50,8 @@
let statusMessage = $state<string | null>(null);
let statusIsError = $state(false);
let statusTimer: ReturnType<typeof setTimeout> | null = null;
let updateDialogEl: HTMLElement | undefined = $state();
const showUpdateDialog = shouldMountSharedUpdateDialog(flagEnabled("studioUpdateDialog"));

const navItems: { id: View; label: string; icon: typeof Gauge }[] = [
{ id: "dashboard", label: "Dashboard", icon: Gauge },
Expand Down Expand Up @@ -102,6 +107,10 @@
let cancelled = false;
const cleanups: (() => void)[] = [];

if (showUpdateDialog && updateDialogEl) {
mountUpdateDialog(updateDialogEl as unknown as UpdateDialogHost);
}

if (!desktopApiAvailable()) {
snapshots = browserPreviewSnapshots(browserPreviewStateFromSearch(window.location.search));
loading = false;
Expand Down Expand Up @@ -288,6 +297,10 @@
</div>
{/if}

{#if showUpdateDialog}
<pickforge-update-dialog bind:this={updateDialogEl}></pickforge-update-dialog>
{/if}

<style>
.app {
display: flex;
Expand Down
16 changes: 16 additions & 0 deletions src/lib/flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { flagEnabled, setFlagOverride } from "./flags";

describe("studioUpdateDialog flag", () => {
it("defaults to off", () => {
expect(flagEnabled("studioUpdateDialog")).toBe(false);
});

it("can be flipped on for the current process via an override", () => {
setFlagOverride("studioUpdateDialog", true);
expect(flagEnabled("studioUpdateDialog")).toBe(true);

setFlagOverride("studioUpdateDialog", undefined);
expect(flagEnabled("studioUpdateDialog")).toBe(false);
});
});
25 changes: 25 additions & 0 deletions src/lib/flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Feature-flag registry (@pickforge/flags). Mirrors the shape PickForge's
// src/stores/flags.ts uses for the same package: definitions default off
// unless `default: true` is set, and a launch flip means changing the
// definition and releasing. PickGauge has no flag-override UI yet, so this
// uses the package's default in-memory store — an override only lives for
// the current process, which is enough for manual dev testing.
import { createFlags } from "@pickforge/flags";

const definitions = {
studioUpdateDialog: {
description: "Shared Pickforge update dialog (pickforge/pickforge-platform#36)",
},
} as const;

export type FlagKey = keyof typeof definitions;

const flags = createFlags(definitions);

export function flagEnabled(key: FlagKey): boolean {
return flags.isEnabled(key);
}

export function setFlagOverride(key: FlagKey, value: boolean | undefined): void {
flags.setOverride(key, value);
}
205 changes: 205 additions & 0 deletions src/lib/updateDialog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { createUpdateController, type UpdateAdapter } from "@pickforge/tauri-updater";
import { describe, expect, it, vi } from "vitest";
import {
createMainWindowEligibility,
MAIN_WINDOW_LABEL,
type EligibilityWindow,
} from "./updateDialog";

/** Stateful fake so tests can flip visibility/focus and emit focus-changed
* events the way a real Tauri window would, instead of a fresh mock per
* assertion. */
class FakeEligibilityWindow implements EligibilityWindow {
label: string;
visible: boolean;
focused: boolean;
listeners: ((event: { payload: boolean }) => void)[] = [];
/** Fires synchronously inside `onFocusChanged`, before it resolves — lets
* tests simulate the window's state changing during listener registration. */
onRegister?: () => void;

constructor(label: string, opts: { visible?: boolean; focused?: boolean } = {}) {
this.label = label;
this.visible = opts.visible ?? false;
this.focused = opts.focused ?? false;
}

isVisible = vi.fn(async () => this.visible);
isFocused = vi.fn(async () => this.focused);
onFocusChanged = vi.fn(async (listener: (event: { payload: boolean }) => void) => {
this.listeners.push(listener);
this.onRegister?.();
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
});

emitFocus(focused: boolean) {
this.focused = focused;
for (const listener of this.listeners) {
listener({ payload: focused });
}
}
}

/** Flushes pending microtasks (the `currentlyEligible()` `Promise.all`
* round-trip inside `createMainWindowEligibility` takes a couple of ticks),
* more reliably than a fixed number of `await Promise.resolve()` calls. */
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
}

function fakeAdapter(): UpdateAdapter {
return {
check: vi.fn(async () => ({ version: "1.2.3" })),
downloadAndInstall: vi.fn(async () => {}),
relaunch: vi.fn(async () => {}),
};
}

describe("createMainWindowEligibility", () => {
it("never becomes eligible for the floating capsule window", async () => {
const win = new FakeEligibilityWindow("float", { visible: true, focused: true });
const eligibility = createMainWindowEligibility(win, true);

await expect(eligibility.whenEligible()).resolves.toBe(false);
expect(win.isVisible).not.toHaveBeenCalled();
expect(win.onFocusChanged).not.toHaveBeenCalled();
});

it("never becomes eligible outside a packaged build", async () => {
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: true, focused: true });
const eligibility = createMainWindowEligibility(win, false);

await expect(eligibility.whenEligible()).resolves.toBe(false);
expect(win.isVisible).not.toHaveBeenCalled();
});

it("resolves immediately for an already-visible and focused main window", async () => {
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: true, focused: true });
const eligibility = createMainWindowEligibility(win, true);

await expect(eligibility.whenEligible()).resolves.toBe(true);
expect(win.onFocusChanged).not.toHaveBeenCalled();
});

it("defers a visible-but-unfocused main window until it gains focus", async () => {
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: true, focused: false });
const eligibility = createMainWindowEligibility(win, true);

const pending = eligibility.whenEligible();
await flush();
expect(win.onFocusChanged).toHaveBeenCalledOnce();

win.emitFocus(true);
await expect(pending).resolves.toBe(true);
});

it("stays ineligible when a focus event fires without the window becoming visible", async () => {
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: false, focused: false });
const eligibility = createMainWindowEligibility(win, true);

const pending = eligibility.whenEligible();
await flush();

// Focused, but still hidden (e.g. a tray/login-start window regaining
// OS focus while its main window stays hidden) must not resolve.
win.emitFocus(true);
await flush();
let resolved = false;
void pending.then(() => {
resolved = true;
});
await flush();
expect(resolved).toBe(false);

// Once it is actually shown and re-focused, it resolves.
win.visible = true;
win.emitFocus(true);
await expect(pending).resolves.toBe(true);
});

it("defers a hidden main window until it is visible and focused, then unlistens", async () => {
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: false, focused: false });
const eligibility = createMainWindowEligibility(win, true);
const pending = eligibility.whenEligible();
await flush();

// A blur (or other unfocus) event before the window is shown must not
// resolve the prompt.
win.emitFocus(false);
await flush();

win.visible = true;
win.emitFocus(true);
await expect(pending).resolves.toBe(true);
expect(win.listeners).toHaveLength(0);

// A later, redundant focus event is ignored (single-flight).
const callsBefore = win.onFocusChanged.mock.calls.length;
win.emitFocus(true);
expect(win.onFocusChanged.mock.calls.length).toBe(callsBefore);
});

it("never resolves for a hidden main window that stays unfocused", async () => {
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: false, focused: false });
const eligibility = createMainWindowEligibility(win, true);
let resolved = false;
void eligibility.whenEligible().then(() => {
resolved = true;
});

await flush();
expect(resolved).toBe(false);
});

it("does not miss eligibility reached while the focus listener registers", async () => {
// The window becomes visible and focused synchronously as a side effect
// of registering the listener (simulating a real focus/show event
// landing in the gap between the initial check and the listener being
// wired up) — whenEligible() must still resolve without waiting for a
// subsequent focus event that will never come.
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: false, focused: false });
win.onRegister = () => {
win.visible = true;
win.focused = true;
};
const eligibility = createMainWindowEligibility(win, true);

await expect(eligibility.whenEligible()).resolves.toBe(true);
});
});

describe("capsule and hidden-window exclusion through the shared controller", () => {
it("never checks for updates when mounted for the floating capsule window", async () => {
const adapter = fakeAdapter();
const win = new FakeEligibilityWindow("float", { visible: true, focused: true });
const controller = createUpdateController({
adapter,
eligibility: createMainWindowEligibility(win, true),
});

await controller.start();

expect(adapter.check).not.toHaveBeenCalled();
expect(controller.getState()).toEqual({ status: "idle" });
});

it("defers the update check for a hidden main window until it is shown and focused", async () => {
const adapter = fakeAdapter();
const win = new FakeEligibilityWindow(MAIN_WINDOW_LABEL, { visible: false, focused: false });
const controller = createUpdateController({
adapter,
eligibility: createMainWindowEligibility(win, true),
});

const started = controller.start();
await flush();
expect(adapter.check).not.toHaveBeenCalled();

win.visible = true;
win.emitFocus(true);
await started;
expect(adapter.check).toHaveBeenCalledOnce();
});
});
Loading