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
98 changes: 12 additions & 86 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,100 +365,26 @@ pnpm devtools:publish

## Chrome DevTools Extension

To distribute this as a Chrome DevTools extension, you need a thin Chrome extension shell that opens the devtools UI in a DevTools panel. The built SPA already works standalone — the extension just embeds it.
The `extension/` directory is a Manifest V3 extension that adds an **Angular DevTools** panel to Chrome DevTools. The panel embeds the same SPA and connects it live to the devtools server of the page you inspect.

### 1. Create the extension scaffold

Create an `extension/` directory:

```
extension/
manifest.json
devtools.html
devtools.js
panel.html
```

### 2. `extension/manifest.json`

```json
{
"manifest_version": 3,
"name": "Angular DevTools",
"version": "0.0.1",
"description": "Inspect Angular components, signals, DI, and routes.",
"devtools_page": "devtools.html",
"permissions": ["scripting"],
"host_permissions": [
"http://localhost/*",
"https://localhost/*",
"http://127.0.0.1/*",
"https://127.0.0.1/*"
],
"icons": {
"128": "icon-128.png"
}
}
```

### 3. `extension/devtools.html` and `extension/devtools.js`

```html
<!-- devtools.html -->
<!doctype html>
<script src="devtools.js"></script>
```

```js
// devtools.js — creates the panel in Chrome DevTools
chrome.devtools.panels.create('Angular', 'icon-128.png', 'panel.html');
```

### 4. `extension/panel.html`

This is where the built SPA loads. Copy the built assets (`dist/devtools-ui/`) into the extension and point `panel.html` at the SPA's `index.html`:

```html
<!-- panel.html — the devtools SPA loads here -->
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<iframe src="ui/index.html" style="width:100%;height:100vh;border:none;"></iframe>
</body>
</html>
```

### 5. Build the extension
### Load it unpacked

```sh
# Build the devtools SPA
pnpm devtools:build

# Copy into the extension
mkdir -p extension/ui
cp -r dist/devtools-ui/* extension/ui/
pnpm extension:build
```

### 6. Load in Chrome

1. Go to `chrome://extensions`
2. Enable **Developer mode**
3. Click **Load unpacked** → select the `extension/` directory
4. Open DevTools on any Angular app → the **Angular** panel appears

### 7. Publish to Chrome Web Store
1. Go to `chrome://extensions` and enable **Developer mode**
2. Click **Load unpacked** and select the `extension/` directory
3. Run your app with the devtools server (see [How to Use](#how-to-use)), open it in Chrome, then open Chrome DevTools: the **Angular DevTools** panel appears once the page renders Angular

1. Zip the `extension/` directory
2. Go to the [Chrome Developer Dashboard](https://chrome.google.com/webstore/devconsole)
3. Click **New item** → upload the zip
4. Fill in the listing details and submit for review
`pnpm extension:zip` writes `dist/ng-devtools-extension.zip`, ready to upload to the [Chrome Web Store](https://chrome.google.com/webstore/devconsole).

### Connecting the extension to the running app
### How it works

The extension panel loads the SPA in static mode by default. To connect it to a live dev server for real-time RPC, the extension's content script or background service worker needs to detect the devframe's `__connection.json` on the inspected page and pass the connection to the panel. This is the same pattern the official Angular DevTools Chrome extension uses — a content script bridges the inspected page and the DevTools panel via `chrome.runtime.connect`.
- **Connection.** The panel looks for the devframe connection on the inspected page's origin, under `/__ng-devtools/`, `/__devframes/ng-devtools/`, `/__devframe/` and `/`, and connects to the first one that answers. When none answers, it lists the URLs it tried.
- **Inspected tab.** With several tabs open on the same app, the panel shows the page it inspects, not the one that reported last. It reconnects after each navigation.
- **Elements panel.** While the Components tab is open, selecting an element in Chrome's Elements panel selects its component there. Other tabs stay where they are.
- **Hosts.** `localhost`, `*.localhost`, `127.0.0.1` and `[::1]` work out of the box. For any other host (a LAN IP, a tunnel), the panel shows an **Allow access** button that grants the extension that host only. The devtools server still only answers requests from your machine.

## Community

Expand Down
41 changes: 30 additions & 11 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ function readView(): View | null {

@Component({
selector: 'app-root',
host: { '[style.--accent]': 'viewAccent()' },
host: {
'[style.--accent]': 'viewAccent()',
'(window:message)': 'inspectFromPanel($event)',
},
imports: [
Dashboard,
ComponentTree,
Expand Down Expand Up @@ -253,7 +256,12 @@ function readView(): View | null {
<app-dashboard [rpc]="rpc()" (navigate)="switchTab($event)" />
}
@case ('components') {
<app-component-tree [rpc]="rpc()" (showForm)="showForm($event)" />
<app-component-tree
[rpc]="rpc()"
[focus]="componentFocus()"
(focusHandled)="componentFocus.set(null)"
(showForm)="showForm($event)"
/>
}
@case ('routes') {
<app-route-inspector [rpc]="rpc()" />
Expand Down Expand Up @@ -741,12 +749,27 @@ export class App implements OnInit, OnDestroy {
}

formFocus = signal<{ id: string } | null>(null);
// Belongs to the Components tab that received it, so leaving the tab drops it.
readonly componentFocus = linkedSignal<Tab, { id: string } | null>({
source: this.tab,
computation: () => null,
});

showForm(formId: string) {
this.formFocus.set({ id: formId });
this.switchTab('forms');
}

inspectFromPanel({ source, origin, data }: MessageEvent<unknown>) {
if (source !== window.parent || origin !== location.origin) return;
const message = data as { type?: unknown; id?: unknown } | null;
if (message?.type !== 'ng-devtools:inspect-component' || typeof message.id !== 'string') return;
// Any element inside the app resolves to a component, so following every
// Elements selection would pull the user off whichever tab they are on.
if (this.tab() !== 'components') return;
this.componentFocus.set({ id: message.id });
}

switchTab(id: Tab) {
const target = TAB_VIEW[id];
if (this.view() === 'angular' && target) {
Expand Down Expand Up @@ -812,16 +835,12 @@ function sameOrigin(value: string): boolean {
}
}

const LOOPBACK_HOSTS = ['localhost', '127.0.0.1'];

function loopbackFromExtension(value: string): boolean {
// The extension panel is not web accessible, and it only passes hosts the user granted.
function fromExtension(value: string): boolean {
if (location.protocol !== 'chrome-extension:') return false;
try {
const url = new URL(value);
return (
(url.protocol === 'http:' || url.protocol === 'https:') &&
LOOPBACK_HOSTS.includes(url.hostname)
);
const { protocol } = new URL(value);
return protocol === 'http:' || protocol === 'https:';
} catch {
return false;
}
Expand All @@ -834,7 +853,7 @@ function detectBaseURL(): string | undefined {
// the panel opens its RPC channel.
// `new URL` throws on a malformed value, and this runs before the connection
// is made, so an unhandled throw would leave the panel blank.
if (fromQuery && (sameOrigin(fromQuery) || loopbackFromExtension(fromQuery))) {
if (fromQuery && (sameOrigin(fromQuery) || fromExtension(fromQuery))) {
return fromQuery;
}

Expand Down
32 changes: 32 additions & 0 deletions app/src/pages/component-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { JsonPipe } from '@angular/common';
import {
Component,
DestroyRef,
Injector,
afterNextRender,
computed,
effect,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import type { DevframeRpcClient } from 'devframe/client';
import { hostPageId } from '../page-id';
Expand Down Expand Up @@ -1089,7 +1092,9 @@ function bare(name: string): string {
})
export class ComponentTree {
readonly rpc = input<DevframeRpcClient | null>(null);
readonly focus = input<{ id: string } | null>(null);
readonly showForm = output<string>();
readonly focusHandled = output<void>();

readonly filter = signal('');
readonly loading = signal(false);
Expand All @@ -1104,6 +1109,7 @@ export class ComponentTree {
private readonly outlets = signal<OutletInfo[]>([]);
private readonly pageId = hostPageId();
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private readonly cleanups: (() => void)[] = [];

readonly page = computed<Page | null>(() => {
Expand Down Expand Up @@ -1234,6 +1240,14 @@ export class ComponentTree {
void this.refresh();
void this.watch(client);
});
effect(() => {
const focus = this.focus();
if (!focus || !this.live()) return;
untracked(() => {
if (this.index().map.has(focus.id)) this.reveal(focus.id);
this.focusHandled.emit();
});
});
this.destroyRef.onDestroy(() => {
this.highlight(null);
for (const cleanup of this.cleanups.splice(0)) cleanup();
Expand Down Expand Up @@ -1333,6 +1347,24 @@ export class ComponentTree {
.catch(() => {});
}

private reveal(id: string) {
const { parents } = this.index();
this.collapsed.update((set) => {
const next = new Set(set);
for (let parent = parents.get(id); parent; parent = parents.get(parent)) next.delete(parent);
return next;
});
if (!this.rows().some((row) => row.node.id === id)) this.filter.set('');
if (this.selectedId() !== id) this.select(id);
afterNextRender(
() =>
document
.querySelector(`.row[data-id="${CSS.escape(id)}"]`)
?.scrollIntoView({ block: 'nearest' }),
{ injector: this.injector },
);
}

toggle(id: string, event?: Event) {
event?.stopPropagation();
if (this.query()) return;
Expand Down
14 changes: 7 additions & 7 deletions docs/privacy-policy.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,31 @@
</head>
<body>
<h1>Privacy Policy</h1>
<p class="updated">Last updated: September 19, 2026</p>
<p class="updated">Last updated: September 30, 2026</p>

<p>Angular DevTools ("the Extension") is a browser extension that helps developers inspect Angular applications. This privacy policy explains what data the Extension accesses and how it is used.</p>

<h2>Data Collection</h2>
<p>The Extension does <strong>not</strong> collect, store, transmit, or share any personal data or browsing data. Specifically:</p>
<p>The Extension does <strong>not</strong> collect, store, or share any personal data or browsing data. Specifically:</p>
<ul>
<li>No personal information is collected</li>
<li>No browsing history is recorded</li>
<li>No cookies or tracking mechanisms are used</li>
<li>No analytics or telemetry data is sent</li>
<li>No data is transmitted to any external server</li>
<li>No analytics or telemetry data is sent, to the Extension's authors or to anyone else</li>
<li>No data is sent anywhere except the devtools server of the app you are inspecting, described below</li>
</ul>

<h2>What the Extension Accesses</h2>
<p>The Extension accesses the following information from pages where its content scripts run and from the page you are actively inspecting via Chrome DevTools:</p>
<ul>
<li><strong>Angular framework detection</strong> — on every page, checks for the <code>ng-version</code> HTML attribute and the <code>window.ng</code> global to determine if the page uses Angular</li>
<li><strong>Angular debug APIs</strong> — reads component trees, signal graphs, dependency injection hierarchies, and route configurations using Angular's built-in debug utilities (available only in development builds)</li>
<li><strong>Localhost communication</strong> — communicates with a devframe server running on <code>localhost</code> on the developer's own machine for live inspection data</li>
<li><strong>Devtools server communication</strong> — communicates with the devframe server of the app you are inspecting, for live inspection data. By default this server runs on the developer's own machine (<code>localhost</code>, <code>*.localhost</code>, <code>127.0.0.1</code> or <code>[::1]</code>)</li>
</ul>
<p>All data processing happens entirely within your browser and your local machine. No information ever leaves your device.</p>
<p>By default, all data stays within your browser and your own machine. If you grant access to another host from the DevTools panel, such as a server on your local network or a tunnel, the Extension exchanges inspection data with the devtools server on that host, so that data leaves your device. It goes only to the host you granted (and, for a tunnel, through the tunnel service you chose). It is never sent to the Extension's authors or to any service the Extension picks on its own.</p>

<h2>Host Permissions</h2>
<p>The Extension requests host permissions only for <code>localhost</code> and <code>127.0.0.1</code>, to reach the devframe server on the developer's own machine. The <code>detect-angular.js</code> content script runs on every page, including when DevTools is closed. It reads only the <code>ng-version</code> attribute and whether <code>window.ng</code> exists, to detect Angular. It does not modify page content.</p>
<p>The Extension requests host permissions for loopback hosts only (<code>localhost</code>, <code>*.localhost</code>, <code>127.0.0.1</code> and <code>[::1]</code>), to reach the devframe server on the developer's own machine. It can reach any other host only after the developer grants that host from the DevTools panel. Chrome keeps a granted host until the developer removes it from the Extension's site access settings. The <code>detect-angular.js</code> content script runs on every page, including when DevTools is closed. It reads only the <code>ng-version</code> attribute and whether <code>window.ng</code> exists, to detect Angular. It does not modify page content.</p>

<h2>Data Storage</h2>
<p>The Extension does not persist any data between sessions. All inspection data exists only in memory while the DevTools panel is open and is discarded when the panel is closed.</p>
Expand Down
11 changes: 7 additions & 4 deletions extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
{
"manifest_version": 3,
"name": "Angular DevTools",
"version": "0.0.4",
"version": "0.0.5",
"description": "Inspect Angular components, signals, dependency injection, and routes.",
"minimum_chrome_version": "111",
"devtools_page": "devtools.html",
"permissions": [],
"host_permissions": [
"http://localhost/*",
"https://localhost/*",
"http://*.localhost/*",
"https://*.localhost/*",
"http://127.0.0.1/*",
"https://127.0.0.1/*"
"https://127.0.0.1/*",
"http://[::1]/*",
"https://[::1]/*"
],
"optional_host_permissions": ["http://*/*", "https://*/*"],
"content_scripts": [
{
"matches": ["<all_urls>"],
Expand Down
Loading
Loading