Skip to content
Open
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
17 changes: 17 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ cd "$root" || exit 0
list="$(mktemp)" || exit 0
trap 'rm -f "$list"' EXIT

main_index=
case "${GIT_INDEX_FILE:-}" in
*next-index-*.lock)
main_index="$(
unset GIT_INDEX_FILE
git rev-parse --path-format=absolute --git-path index
).lock" || exit 0
if [ ! -f "$main_index" ]; then
echo "pre-commit: not formatting because the index for this partial commit was not found. Run pnpm format:check before you push." >&2
exit 0
fi
;;
esac

unstaged="$(git -c core.quotePath=false diff --name-only)"

git -c core.quotePath=false diff --cached --name-only --diff-filter=ACMR |
Expand All @@ -23,4 +37,7 @@ git -c core.quotePath=false diff --cached --name-only --diff-filter=ACMR |
tr '\n' '\0' <"$list" | xargs -0 pnpm --silent exec prettier --write --ignore-unknown >/dev/null 2>&1 ||
echo "WARNING: failed to format staged files." >&2
tr '\n' '\0' <"$list" | xargs -0 git --literal-pathspecs add --
if [ -n "$main_index" ]; then
tr '\n' '\0' <"$list" | GIT_INDEX_FILE="$main_index" xargs -0 git --literal-pathspecs add --
fi
exit 0
78 changes: 59 additions & 19 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { TabIcon } from './pages/tab-icon';
import { styleHubRail } from './hub-rail-style';
import { followHubDocks, selectHubDock } from './hub-dock-sync';
import { panelConfig, tabEnabled } from './devtools-config';
import { connectToken, scopeTrustUpdates, serverOrigin, watchTrust, type TrustState } from './auth';
import { CodeEntry } from './ui/code-entry';

const HUB_VIEWS = ['angular', 'ngrx', 'analog', 'nativescript', 'capacitor'] as const;

Expand Down Expand Up @@ -127,6 +129,7 @@ function readView(): View | null {
NetworkInspector,
ComingSoon,
TabIcon,
CodeEntry,
],
template: `
<header>
Expand Down Expand Up @@ -238,17 +241,20 @@ function readView(): View | null {
class="status"
[class.connected]="connected()"
[class.failed]="connectionFailed()"
[class.locked]="needsCode()"
role="status"
>
<span class="dot" aria-hidden="true"><span></span></span>
{{ connected() ? 'Live' : connectionFailed() ? 'Disconnected' : 'Connecting…' }}
{{ statusLabel() }}
</span>
</header>
<main #main tabindex="-1">
@if (connectionFailed()) {
<p class="connection-error" role="alert">
Can't reach the devtools server. Check that the dev server is running, then reload.
</p>
} @else if (needsCode() && client(); as client) {
<app-code-entry [client]="client" [server]="server()" [pageOrigin]="pageOrigin" />
} @else if (comingSoon(); as info) {
<app-coming-soon [info]="info" />
} @else if (!tabEnabled(tab(), config())) {
Expand Down Expand Up @@ -530,6 +536,13 @@ function readView(): View | null {
background: var(--danger);
animation: none;
}
.status.locked {
border-color: color-mix(in srgb, var(--warn) 36%, transparent);
color: var(--text);
}
.status.locked .dot span {
animation: none;
}
.status.connected .dot::before {
background: var(--ok);
animation: ping 2s var(--ease) infinite;
Expand Down Expand Up @@ -658,6 +671,17 @@ export class App implements OnInit, OnDestroy {
rpc = signal<DevframeRpcClient | null>(null);
connected = signal(false);
readonly connectionFailed = signal(false);
readonly client = signal<DevframeRpcClient | null>(null);
readonly trust = signal<TrustState>('pending');
readonly needsCode = computed(() => this.trust() === 'needs-code');
readonly server = signal('');
protected readonly pageOrigin = location.origin;
readonly statusLabel = computed(() => {
if (this.connected()) return 'Live';
if (this.connectionFailed()) return 'Disconnected';
return this.needsCode() ? 'Code needed' : 'Connecting…';
});
private stopTrust = () => {};

private stopFollowing = () => {};
private readonly nav = viewChild<ElementRef<HTMLElement>>('nav');
Expand Down Expand Up @@ -721,28 +745,24 @@ export class App implements OnInit, OnDestroy {
}

const baseURL = detectBaseURL();
connectDevframe(baseURL ? { baseURL } : {}).then(
const server = serverOrigin(baseURL, location.href);
this.server.set(server);
connectDevframe({
...(baseURL ? { baseURL } : {}),
authToken: connectToken(server, this.pageOrigin),
simpleAuth: false,
}).then(
(client) => {
this.rpc.set(client);
this.connected.set(true);
const scoped = client.scope('ng-devtools').rpc as unknown as {
call: (name: string) => Promise<unknown>;
};
scoped.call('analog-project').then(
(project) => {
const isAnalog = !!(project as { analog?: boolean } | null)?.analog;
this.analog.set(isAnalog);
this.analogKnown.set(true);
if (!isAnalog && this.tab() === 'analog' && !this.view()) this.tab.set('dashboard');
},
() => {
this.analogKnown.set(true);
if (this.tab() === 'analog' && !this.view()) this.tab.set('dashboard');
},
);
scopeTrustUpdates(client, server, this.pageOrigin);
this.client.set(client);
this.connected.set(client.status === 'connected');
client.events.on('connection:status', (status) => {
this.connected.set(status === 'connected');
});
this.stopTrust = watchTrust(client, (state) => {
this.trust.set(state);
if (state === 'trusted' && !this.rpc()) this.start(client);
});
},
() => {
this.connectionFailed.set(true);
Expand All @@ -751,8 +771,28 @@ export class App implements OnInit, OnDestroy {
);
}

private start(client: DevframeRpcClient) {
this.rpc.set(client);
const scoped = client.scope('ng-devtools').rpc as unknown as {
call: (name: string) => Promise<unknown>;
};
scoped.call('analog-project').then(
(project) => {
const isAnalog = !!(project as { analog?: boolean } | null)?.analog;
this.analog.set(isAnalog);
this.analogKnown.set(true);
if (!isAnalog && this.tab() === 'analog' && !this.view()) this.tab.set('dashboard');
},
() => {
this.analogKnown.set(true);
if (this.tab() === 'analog' && !this.view()) this.tab.set('dashboard');
},
);
}

ngOnDestroy() {
this.stopFollowing();
this.stopTrust();
this.navObserver?.disconnect();
}

Expand Down
140 changes: 140 additions & 0 deletions app/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { DevframeRpcClient } from 'devframe/client';

/** What the panel needs from the devframe client to run the one-time code flow. */
export type TrustClient = Pick<
DevframeRpcClient,
'isTrusted' | 'status' | 'events' | 'connection' | 'requestAuthCode' | 'requestTrustWithCode'
>;

export type TrustState = 'pending' | 'trusted' | 'needs-code';

export type CodeResult = 'trusted' | 'empty' | 'wrong' | 'unreachable';

type TokenStorage = Pick<Storage, 'getItem' | 'setItem'>;

const TOKENS_KEY = 'ng-devtools:auth-tokens';
export const NO_TOKEN = 'ng-devtools:no-token';

export function trustState(client: Pick<TrustClient, 'isTrusted' | 'status'>): TrustState {
if (client.isTrusted) return 'trusted';
return client.status === 'unauthorized' ? 'needs-code' : 'pending';
}

/** Reports the trust state now and on every change; returns the unsubscribe. */
export function watchTrust(client: TrustClient, onChange: (state: TrustState) => void): () => void {
const emit = () => onChange(trustState(client));
const offStatus = client.events.on('connection:status', emit);
const offTrust = client.events.on('rpc:is-trusted:updated', emit);
emit();
return () => {
offStatus();
offTrust();
};
}

/** The origin of the devtools server the panel connects to. */
export function serverOrigin(baseURL: string | undefined, page: string): string {
try {
return new URL(baseURL ?? './', page).origin;
} catch {
return new URL(page).origin;
}
}

function defaultStorage(): TokenStorage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}

function readTokens(storage: TokenStorage | null): Record<string, string> {
try {
const parsed: unknown = JSON.parse(storage?.getItem(TOKENS_KEY) ?? '{}');
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {};
} catch {
return {};
}
}

/**
* The token saved for a server on another origin, such as the dev server seen
* from the extension panel. A page on the server's own origin already shares
* devframe's stored token, so it gets none here.
*/
export function savedToken(
server: string,
pageOrigin: string,
storage = defaultStorage(),
): string | undefined {
if (server === pageOrigin) return undefined;
const token = readTokens(storage)[server];
return typeof token === 'string' && token ? token : undefined;
}

export function connectToken(
server: string,
pageOrigin: string,
storage = defaultStorage(),
): string | undefined {
if (server === pageOrigin) return undefined;
return savedToken(server, pageOrigin, storage) ?? NO_TOKEN;
}

export function scopeTrustUpdates(
client: Pick<DevframeRpcClient, 'requestTrustWithToken'>,
server: string,
pageOrigin: string,
storage = defaultStorage(),
): void {
if (server === pageOrigin) return;
const requestTrustWithToken = client.requestTrustWithToken;
client.requestTrustWithToken = async (token) =>
token === savedToken(server, pageOrigin, storage) ? requestTrustWithToken(token) : false;
}

export function saveToken(
server: string,
pageOrigin: string,
token: string | undefined,
storage = defaultStorage(),
): void {
if (server === pageOrigin || !token || !storage) return;
try {
storage.setItem(TOKENS_KEY, JSON.stringify({ ...readTokens(storage), [server]: token }));
} catch {
// Storage is full or blocked; the panel asks for a code again next time.
}
}

/** Exchanges a one-time code for a token and saves it for the server. */
export async function submitCode(
client: TrustClient,
code: string,
server: string,
pageOrigin: string,
storage = defaultStorage(),
): Promise<CodeResult> {
const trimmed = code.replace(/\s+/g, '');
if (!trimmed) return 'empty';
let trusted: boolean;
try {
trusted = await client.requestTrustWithCode(trimmed);
} catch {
return 'unreachable';
}
if (!trusted) return 'wrong';
saveToken(server, pageOrigin, client.connection.authToken, storage);
return 'trusted';
}

/** Asks the server to print its code in the terminal; `reissue` rotates it first. */
export async function requestCode(client: TrustClient, reissue = false): Promise<boolean> {
try {
await client.requestAuthCode(reissue ? { reissue: true } : undefined);
return true;
} catch {
return false;
}
}
Loading
Loading