@franzzemen/brokenstock-shell 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -233,8 +233,17 @@ path here is bounded regardless.
|
|
|
233
233
|
fall back to running uncoordinated — today's behaviour — never throw. A missing
|
|
234
234
|
API must not be the reason nobody can sign in.
|
|
235
235
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
/**
|
|
237
|
+
* Checked at CALL time, not once at module load.
|
|
238
|
+
*
|
|
239
|
+
* Module-level would be evaluated during import — before `navigator` exists at
|
|
240
|
+
* all under prerendering, and before a test can arrange anything — permanently
|
|
241
|
+
* fixing the answer to "no" for the life of the process. Found by writing the
|
|
242
|
+
* test for the fallback path, which could not be reached any other way.
|
|
243
|
+
*/
|
|
244
|
+
function locksAvailable() {
|
|
245
|
+
return typeof navigator !== 'undefined' && typeof navigator.locks?.request === 'function';
|
|
246
|
+
}
|
|
238
247
|
/**
|
|
239
248
|
* Runs `work` if this window can take the lock right now.
|
|
240
249
|
*
|
|
@@ -246,7 +255,7 @@ const LOCKS_AVAILABLE = typeof navigator !== 'undefined'
|
|
|
246
255
|
* deliberate degradation to today's behaviour rather than a refusal.
|
|
247
256
|
*/
|
|
248
257
|
async function runExclusive(name, work) {
|
|
249
|
-
if (!
|
|
258
|
+
if (!locksAvailable()) {
|
|
250
259
|
console.warn(`[shell] navigator.locks unavailable; "${name}" runs without cross-window coordination`);
|
|
251
260
|
return { ran: true, value: await work() };
|
|
252
261
|
}
|
|
@@ -281,6 +290,40 @@ async function awaitChange(read, unchanged, timeoutMs, pollMs = 100) {
|
|
|
281
290
|
}
|
|
282
291
|
}
|
|
283
292
|
|
|
293
|
+
/*
|
|
294
|
+
Created by Franz Zemen 2026-08-29
|
|
295
|
+
License Type: UNLICENSED
|
|
296
|
+
|
|
297
|
+
What a window should do once it can act on a session renewal
|
|
298
|
+
(multiple-windows PRD D5).
|
|
299
|
+
|
|
300
|
+
WHY THIS IS A NAMED FUNCTION RATHER THAN THREE `if`s INSIDE THE SERVICE. The
|
|
301
|
+
rule was originally written as "retry with a token different from the one you
|
|
302
|
+
failed with", which is not a well-defined instruction: the in-window
|
|
303
|
+
single-flight already collapses requests that were sent with DIFFERENT tokens,
|
|
304
|
+
so "the one you failed with" is not a single value. Pulling the decision out
|
|
305
|
+
forces the three cases to be named, and lets them be tested without mocking a
|
|
306
|
+
network call — which is what they deserve, because getting this wrong shows up
|
|
307
|
+
hours later as "a window signed me out" with nothing linking cause to effect.
|
|
308
|
+
*/
|
|
309
|
+
/**
|
|
310
|
+
* @param before the token this window held immediately BEFORE asking for the lock
|
|
311
|
+
* @param current the token in shared storage now that this window can act
|
|
312
|
+
*/
|
|
313
|
+
function decideRenewal(before, current) {
|
|
314
|
+
// Gone. Retrying would present nothing and 401 again; worse, it would look
|
|
315
|
+
// like a transient failure when it is a definite answer.
|
|
316
|
+
if (before && !current)
|
|
317
|
+
return 'sign-out';
|
|
318
|
+
// Someone else's fresh token. Rotating again would invalidate the token every
|
|
319
|
+
// OTHER window just adopted — turning one window's recovery into everyone
|
|
320
|
+
// else's sign-out.
|
|
321
|
+
if (current && current !== before)
|
|
322
|
+
return 'adopt';
|
|
323
|
+
// Unchanged, or nothing stored to begin with.
|
|
324
|
+
return 'renew';
|
|
325
|
+
}
|
|
326
|
+
|
|
284
327
|
/** Name of the cross-window renewal lock (PRD D5). */
|
|
285
328
|
const RENEWAL_LOCK = 'brokenstock.session.renew';
|
|
286
329
|
/** How long a window waits for the renewing window to publish a token (PRD D5). */
|
|
@@ -547,17 +590,15 @@ class AuthenticationService {
|
|
|
547
590
|
*/
|
|
548
591
|
async #renewOrAdopt(before) {
|
|
549
592
|
const current = this.#store.read();
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return true;
|
|
593
|
+
switch (decideRenewal(before, current)) {
|
|
594
|
+
case 'sign-out':
|
|
595
|
+
return false;
|
|
596
|
+
case 'adopt':
|
|
597
|
+
this.#auth = current;
|
|
598
|
+
return true;
|
|
599
|
+
case 'renew':
|
|
600
|
+
return this.#renew();
|
|
559
601
|
}
|
|
560
|
-
return this.#renew();
|
|
561
602
|
}
|
|
562
603
|
/**
|
|
563
604
|
* The renewal request itself.
|
|
@@ -2736,12 +2777,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
2736
2777
|
args: [{ providedIn: 'root' }]
|
|
2737
2778
|
}], ctorParameters: () => [] });
|
|
2738
2779
|
|
|
2780
|
+
/** Roomy enough for the scanner beside a chart, small enough for a laptop. */
|
|
2781
|
+
const POP_OUT_WIDTH = 1280;
|
|
2782
|
+
const POP_OUT_HEIGHT = 900;
|
|
2739
2783
|
class StatusBar {
|
|
2740
2784
|
constructor() {
|
|
2741
2785
|
this.#authService = inject(AuthenticationService);
|
|
2742
2786
|
this.#focusService = inject(TerminalFocusService);
|
|
2743
2787
|
this.#commandService = inject(CommandService);
|
|
2788
|
+
this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
|
|
2744
2789
|
this.isAuthenticated = this.#authService.authenticated;
|
|
2790
|
+
/**
|
|
2791
|
+
* Whether a second window is offered at all (multiple-windows PRD D14).
|
|
2792
|
+
*
|
|
2793
|
+
* ⚠ GATED ON SHARED SESSIONS, and this is a correctness gate rather than a
|
|
2794
|
+
* tidiness one. Without a session shared across windows, the new window starts
|
|
2795
|
+
* with no token, goes to the login screen, and signing in there ends the
|
|
2796
|
+
* session THIS window is using. A pop-out button in an app without sharing is
|
|
2797
|
+
* a button that signs you out — the exact failure the multiple-windows work
|
|
2798
|
+
* exists to remove.
|
|
2799
|
+
*/
|
|
2800
|
+
this.canPopOut = computed(() => this.#sharedSession && this.isAuthenticated(), /* @ts-ignore */
|
|
2801
|
+
...(ngDevMode ? [{ debugName: "canPopOut" }] : /* istanbul ignore next */ []));
|
|
2745
2802
|
this.loginStatus = computed(() => this.#authService.authenticating()
|
|
2746
2803
|
? 'Authenticating...'
|
|
2747
2804
|
: this.#authService.authenticated()
|
|
@@ -2752,21 +2809,41 @@ class StatusBar {
|
|
|
2752
2809
|
#authService;
|
|
2753
2810
|
#focusService;
|
|
2754
2811
|
#commandService;
|
|
2812
|
+
#sharedSession;
|
|
2755
2813
|
logout() {
|
|
2756
2814
|
this.#authService.logout();
|
|
2757
2815
|
this.#commandService.echoCommand('logout');
|
|
2758
2816
|
this.#focusService.requestFocus();
|
|
2759
2817
|
}
|
|
2818
|
+
/**
|
|
2819
|
+
* Open the screen you are on in a second, sized window.
|
|
2820
|
+
*
|
|
2821
|
+
* There is no mechanism by which this differs from the browser's own
|
|
2822
|
+
* "Duplicate tab": every window is a separate copy of the app started at some
|
|
2823
|
+
* address. It exists for the sizing and because it is discoverable — Ctrl+T,
|
|
2824
|
+
* a bookmark and dragging a tab out all work equally well once the session is
|
|
2825
|
+
* shared.
|
|
2826
|
+
*
|
|
2827
|
+
* `noopener` is deliberate. The new window needs nothing from this one — it
|
|
2828
|
+
* reads the session from storage — and leaving the opener reachable would give
|
|
2829
|
+
* two windows script access to each other for no purpose.
|
|
2830
|
+
*/
|
|
2831
|
+
popOut() {
|
|
2832
|
+
if (typeof window === 'undefined')
|
|
2833
|
+
return;
|
|
2834
|
+
window.open(window.location.href, '_blank', `noopener,noreferrer,width=${POP_OUT_WIDTH},height=${POP_OUT_HEIGHT}`);
|
|
2835
|
+
this.#focusService.requestFocus();
|
|
2836
|
+
}
|
|
2760
2837
|
openFaq() {
|
|
2761
2838
|
this.#commandService.execute('faq');
|
|
2762
2839
|
this.#focusService.requestFocus();
|
|
2763
2840
|
}
|
|
2764
2841
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: StatusBar, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
2765
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: StatusBar, isStandalone: true, selector: "shell-status-bar", ngImport: i0, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
2842
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.2", type: StatusBar, isStandalone: true, selector: "shell-status-bar", ngImport: i0, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"], changeDetection: i0.ChangeDetectionStrategy.Eager }); }
|
|
2766
2843
|
}
|
|
2767
2844
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: StatusBar, decorators: [{
|
|
2768
2845
|
type: Component,
|
|
2769
|
-
args: [{ selector: 'shell-status-bar', changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"] }]
|
|
2846
|
+
args: [{ selector: 'shell-status-bar', changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"status-bar\">\n <div class=\"status-item\">\n <span class=\"status-label text-secondary-info\">Status:</span>\n @if (isAuthenticated()) {\n <a class=\"status-value text-interactive logout-link\" (click)=\"logout()\">Log Out</a>\n } @else {\n <span class=\"status-value text-loss\">{{ loginStatus() }}</span>\n }\n </div>\n @if (isAuthenticated()) {\n <ng-content />\n }\n @if (canPopOut()) {\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"popOut()\"\n title=\"Open this screen in a second window\"\n aria-label=\"Open this screen in a second window\"\n >\u29C9</button>\n }\n <button\n type=\"button\"\n class=\"faq-button text-interactive\"\n (click)=\"openFaq()\"\n title=\"Open FAQ (or type `faq`)\"\n aria-label=\"Open FAQ\"\n >?</button>\n</div>\n", styles: [".status-bar{display:flex;align-items:center;gap:24px;padding:0 16px;height:100%;background-color:var(--color-surface);border-bottom:1px solid var(--color-surface-border);font-size:.8rem}.status-item{display:flex;align-items:baseline;gap:6px}.status-label{font-weight:500;text-transform:uppercase;letter-spacing:.05em;font-size:.7rem}.logout-link{cursor:pointer;text-decoration:underline}.logout-link:hover{opacity:.8}.faq-button{margin-left:auto;background:none;border:1px solid var(--color-surface-border);border-radius:50%;width:22px;height:22px;font-size:.85rem;font-weight:600;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}.faq-button:hover{opacity:.8}\n"] }]
|
|
2770
2847
|
}] });
|
|
2771
2848
|
|
|
2772
2849
|
class SideMenu {
|
|
@@ -3165,5 +3242,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
3165
3242
|
* Generated bundle index. Do not edit.
|
|
3166
3243
|
*/
|
|
3167
3244
|
|
|
3168
|
-
export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, guestGuard, provideShellConfig, tokenize };
|
|
3245
|
+
export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, decideRenewal, guestGuard, provideShellConfig, tokenize };
|
|
3169
3246
|
//# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map
|