├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── demo.css
├── demo.js
├── index.d.ts
├── index.html
├── index.js
├── jsconfig.json
└── package.json
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.associations": {
3 | "**/dist/{client,server}/**/*.js": "text", // Loads bundled js much faster.
4 | ".eslintignore": "ignore",
5 | },
6 | "json.schemas": [
7 | {
8 | "fileMatch": [ "*package*.json" ],
9 | "url": "https://json.schemastore.org/package",
10 | }
11 | ],
12 |
13 | "files.trimTrailingWhitespace": true,
14 |
15 | "javascript.suggest.autoImports": false,
16 | "[javascript]": {
17 | "editor.insertSpaces": false,
18 | },
19 | "[typescript]": {
20 | "editor.insertSpaces": false,
21 | },
22 | "[css]": {
23 | "editor.insertSpaces": false,
24 | },
25 | "[html]": {
26 | "editor.insertSpaces": false,
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 David Fong
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 💞🐛 Detect Devtools Via Debugger Heartstop
2 |
3 | [![npm version][npm-version-label]][npm-url]
4 |
5 | Detects whether the browser's devtools are open. ([demo](https://david-fong.github.io/detect-devtools-via-debugger-heartstop/))
6 |
7 | ## How It Works
8 |
9 | 1. Main thread sends a message to a webworker thread.
10 | 1. Worker thread replies with an opening heartbeat.
11 | 1. Main thread reacts by starting a timer to expect the closing heartbeat.
12 | 1. Worker thread's message handler encounters a debugger statement.
13 | 1. If devtools are closed, the worker will immediately send an acknowledgement to the main thread, and the main thread will conclude that devtools are closed.
14 | 1. If devtools are opened, the _worker_ will enter a debugging session, and the main thread will notice that the Worker has not responded sufficiently quickly, concluding that the debugger must be open. The main thread will _not_ be blocked by the worker's debugging session, but it's timeout _response_ will be blocked by any heavy processing in the main thread ahead of it in the event queue.
15 |
16 | It assumes that the browser always enters debugging when devtools are open for any thread that encounters a debugging statement. Please read on to find out about browser support.
17 |
18 | This was a fun challenge to tackle. If this solution sounds overly complex, take a look through [the listed alternatives](#Alternatives). It's a pretty fascinating rabbit hole.
19 |
20 | ## Pros and Cons
21 |
22 | ### Pros
23 |
24 | This is well suited for devs who want to do silly/weird things to users such as rickrolling people who open devtools in a browser game, and don't mind absolutely destroying the usability/ergonomics of the devtools. In fact, this was the very kind of spirit for which I created this.
25 |
26 | It doesn't depend on whether the devtools pane is attached to the browser window, or some other browser-internal behaviours such as lazy console logging of complex objects, which are not part of any web spec.
27 |
28 | Though the design involves timing program execution, it is written such that the detection should never trigger false positives due to busy threads, given a reasonable main thread timeout value.
29 |
30 | ### Cons
31 |
32 | Like all solutions involving the `debugger` statement, the devtools user can bypass it simply by disabling breakpoints.
33 |
34 | On FireFox, this only works when the debugger is the active tab. Chrome (tested for v92) always enters debugging no matter what the active devtools tab is.
35 |
36 | 🚨 To devs who want some custom browser hooks for their own purposes, _this is not for you_. You will hate it. It will enter debugging for the worker thread whenever devtools are opened, which (in most browsers) also causes the console context to change to the worker's context. Simply continuing the debugger will result in the debugger activating again, and the only way around this is to use the browser's inbuilt mechanism to disable all breakpoints (which may need to be done _each_ time opening the devtools depending on whether your browser remembers the setting). Scroll down for [links to alternatives](#Alternatives).
37 |
38 | It can get messed up when certain debugger statements are placed in the main thread. I have not yet tested out what the rules for this are, nor am I really interested in doing so 😅.
39 |
40 | ## Usage
41 |
42 | See [the typings file](https://github.com/david-fong/detect-devtools-via-debugger-heartstop/blob/main/index.d.ts), or visit [the demo page](https://david-fong.github.io/detect-devtools-via-debugger-heartstop/).
43 |
44 | ## Alternatives
45 |
46 | - https://github.com/dsa28s/detect-browser-devtools - uses somewhat specific browser behaviours
47 | - https://github.com/sindresorhus/devtools-detect - compares the page dimensions to the window dimensions
48 |
49 | You may also have luck sifting through the below StackOverflow thread. For example, one simple but non-robust way to do it is to hook into keyboard shortcuts.
50 |
51 | ## Some History Readings
52 |
53 | - https://stackoverflow.com/questions/7798748/find-out-whether-chrome-console-is-open
54 | - https://bugs.chromium.org/p/chromium/issues/detail?id=672625
55 |
56 | [npm-version-label]: https://img.shields.io/npm/v/detect-devtools-via-debugger-heartstop.svg?style=flat-square
57 | [npm-url]: https://www.npmjs.com/package/detect-devtools-via-debugger-heartstop
58 |
--------------------------------------------------------------------------------
/demo.css:
--------------------------------------------------------------------------------
1 |
2 | *, *::before, *::after {
3 | box-sizing: border-box;
4 | }
5 | body {
6 | margin: 0;
7 | font-size: 1.2em;
8 | height: 100%;
9 | max-width: 60ch;
10 | background-color: rgb(24, 3, 83); color: white;
11 | font-family: monospace;
12 | user-select: none;
13 | }
14 | :link { color: rgb(138, 166, 255); }
15 | :visited { color: rgb(206, 147, 255); }
16 | ::placeholder { color:rgb(132, 118, 175); }
17 | header {
18 | contain: content;
19 | padding-top: 2ch;
20 | display: grid;
21 | place-items: center;
22 | gap: 0.75ch
23 | }
24 | main {
25 | contain: content;
26 | padding: 3.5ch;
27 | display: grid;
28 | place-items: center;
29 | gap: 2ch;
30 | }
31 | legend {
32 | padding: 0.5ch 1ch;
33 | font-size: 1.1em;
34 | text-align: center;
35 | }
36 | label {
37 | padding-block: 0.5ch;
38 | }
39 | input, textarea, select, fieldset {
40 | border: dashed 0.3ch rgb(125, 88, 185);
41 | border-radius: 0.5ch;
42 | box-sizing: content-box;
43 | font: inherit;
44 | background: inherit;
45 | color: inherit;
46 | }
47 | input:focus, textarea:focus, select:focus {
48 | border: solid 0.3ch rgb(125, 88, 185);
49 | outline: none;
50 | }
51 | fieldset {
52 | border-style: dotted;
53 | }
54 | textarea {
55 | margin-top: 0.5ch;
56 | width: 100%;
57 | resize: vertical;
58 | box-sizing: border-box;
59 | }
60 | option {
61 | background: initial;
62 | color: initial;
63 | }
64 | input[type="number"] {
65 | width: 8ch;
66 | text-align: end;
67 | }
68 | @keyframes dancing-emoji {
69 | from { transform: translateY(0); }
70 | 25% { transform: translateY(0.25ch); }
71 | 50% { transform: translateY(0); }
72 | 75% { transform: translateY(-0.25ch); }
73 | to { transform: translateY(0);}
74 | }
75 | .dancing-emoji {
76 | display: inline-block;
77 | /* position: relative; */
78 | /* animation: 1s linear 0s infinite forward dancing_emoji; */
79 | animation-name: dancing-emoji;
80 | animation-iteration-count: infinite;
81 | animation-duration: 1s;
82 | animation-timing-function: step-end;
83 | }
84 | .dancing-emoji:nth-child(2n) {
85 | animation-delay: 0.25s;
86 | }
--------------------------------------------------------------------------------
/demo.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | ///
3 | (() => {
4 | const main = document.getElementsByTagName("main").item(0);
5 | /**@type {(isOpen:boolean) => void}*/let renderIsOpen = (isOpen) => {};
6 | {
7 | const status = document.createElement("div");
8 | status.style.display = "grid";
9 | {
10 | const wrap = document.createElement("div");
11 | {
12 | const label = document.createElement("label");
13 | label.textContent = "devtoolsDetector.isOpen: ";
14 | wrap.appendChild(label);
15 | }{
16 | const isOpen = document.createElement("output");
17 | const f = document.createTextNode("false");
18 | const t = document.createElement("span");
19 | {
20 | const emoji = (/**@type {string}*/text) => {
21 | const el = document.createElement("div");
22 | el.classList.add("dancing-emoji");
23 | el.textContent = text;
24 | return el;
25 | };
26 | t.appendChild(emoji("🕺"));
27 | t.appendChild(document.createTextNode("true"));
28 | t.appendChild(emoji("💃"));
29 | }
30 | renderIsOpen = (val) => {
31 | isOpen.replaceChild((val ? t : f), isOpen.firstChild);
32 | };
33 | isOpen.appendChild(f);
34 | wrap.appendChild(isOpen);
35 | }
36 | status.appendChild(wrap);
37 | }{
38 | const label = document.createElement("label");
39 | label.textContent = "devtoolsDetector.paused";
40 | const pause = document.createElement("input");
41 | pause.type = "checkbox";
42 | pause.onchange = (ev) => { devtoolsDetector.paused = pause.checked; };
43 | label.appendChild(pause);
44 | status.appendChild(label);
45 | }
46 | main?.append(status);
47 | }
48 |
49 | const form = document.createElement("fieldset");
50 | Object.assign(form.style, {
51 | contain: "content", position: "relative",
52 | display: "flex", flexFlow: "column",
53 | });
54 | {
55 | const legend = document.createElement("legend");
56 | legend.textContent = "devtoolsDetector.config";
57 | form.appendChild(legend);
58 | }
59 | const fields = {
60 | pollingIntervalSeconds: { type: "number", default: 1.0, min: 0 },
61 | maxMillisBeforeAckWhenClosed: { type: "number", default: 100, min: 0 },
62 | moreAnnoyingDebuggerStatements: { type: "number", default: 0, min: 0 },
63 |
64 | onDetectOpen: { type: "eval-js" },
65 | onDetectClose: { type: "eval-js" },
66 |
67 | startup: {
68 | type: "select", options: ["manual", "asap", "domContentLoaded"], default: "asap",
69 | },
70 | onCheckOpennessWhilePaused: {
71 | type: "select", options: ["returnStaleValue", "throw"], default: "returnStaleValue",
72 | },
73 | };
74 | Object.keys(fields).forEach((field) => {
75 | // @ts-expect-error
76 | const desc = fields[field];
77 | const label = document.createElement("label");
78 | label.textContent = field + ": ";
79 |
80 | if (desc.type === "select") {
81 | const sel = document.createElement("select");
82 | for (const optName of desc.options) {
83 | const opt = document.createElement("option");
84 | opt.value = optName;
85 | opt.text = optName;
86 | sel.appendChild(opt);
87 | }
88 | sel.value = desc.default;
89 | sel.onchange = (ev) => {
90 | devtoolsDetector.config[field] = sel.value || desc.default;
91 | };
92 | label.appendChild(sel);
93 |
94 | } else if (desc.type === "eval-js") {
95 | const area = document.createElement("textarea");
96 | area.style.display = "grid";
97 | area.placeholder = "< something to eval() >";
98 | area.onchange = (ev) => {
99 | devtoolsDetector.config[field] = () => {
100 | renderIsOpen(devtoolsDetector.isOpen);
101 | try {
102 | eval(area.value);
103 | } catch (err) {
104 | alert(err.message + "\n\n " + err.stack);
105 | }
106 | };
107 | };
108 | area.dispatchEvent(new Event("change"));
109 | label.appendChild(area);
110 |
111 | } else {
112 | const input = document.createElement("input");
113 | input.type = desc.type;
114 | input.min = desc.min;
115 | input.placeholder = desc.default;
116 | input.value = desc.default;
117 | input.onchange = (ev) => {
118 | devtoolsDetector.config[field] = input.value ?? desc.default;
119 | };
120 | label.appendChild(input);
121 | }
122 | form.appendChild(label);
123 | });
124 | main?.appendChild(form);
125 |
126 | /* const rr = document.createElement("iframe");
127 | Object.assign(rr, {
128 | width: "300", height: "150",
129 | src: "https://www.youtube.com/embed/iik25wqIuFo?disablekb=1&fs=0&modestbranding=1&controls=0",
130 | title: "🕺💃",
131 | allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
132 | allowFullScreen: false,
133 | });
134 | main?.appendChild(rr); */
135 | })();
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 |
2 | type DevtoolsDetectorConfig = {
3 |
4 | /** @default 1.0 */
5 | pollingIntervalSeconds: number;
6 |
7 | /** @default 100 */
8 | maxMillisBeforeAckWhenClosed: number;
9 |
10 | /** @default 0 */
11 | moreAnnoyingDebuggerStatements: number;
12 |
13 | onDetectOpen?(): void;
14 | onDetectClose?(): void;
15 |
16 | /** @default "asap" */
17 | startup: "manual" | "asap" | "domContentLoaded";
18 |
19 | /** @default "returnStaleValue" */
20 | onCheckOpennessWhilePaused: "returnStaleValue" | "throw";
21 | }
22 |
23 | type DevtoolsDetector = {
24 | readonly config: DevtoolsDetectorConfig;
25 |
26 | /** Retains last read value while paused. */
27 | get isOpen(): boolean;
28 |
29 | get paused(): boolean;
30 | set paused(_: boolean);
31 | }
32 |
33 | declare const devtoolsDetector: DevtoolsDetector;
34 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Detect Devtools Demo
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | ///
3 | /** @typedef {{ moreDebugs: number }} PulseCall */
4 | /** @typedef {{ isOpenBeat: boolean }} PulseAck */
5 |
6 | (() => {
7 | /** @type {DevtoolsDetectorConfig} */
8 | const config = {
9 | pollingIntervalSeconds: 0.25,
10 | maxMillisBeforeAckWhenClosed: 100,
11 | moreAnnoyingDebuggerStatements: 1,
12 |
13 | onDetectOpen: undefined,
14 | onDetectClose: undefined,
15 |
16 | startup: "asap",
17 | onCheckOpennessWhilePaused: "returnStaleValue",
18 | };
19 | Object.seal(config);
20 |
21 | const heart = new Worker(URL.createObjectURL(new Blob([
22 | // Note: putting everything before the first debugger on the same line as the
23 | // opening callback brace prevents a user from placing their own debugger on
24 | // a line before the first debugger and taking control in that way.
25 | `"use strict";
26 | onmessage = (ev) => { postMessage({isOpenBeat:true});
27 | debugger; for (let i = 0; i < ev.data.moreDebugs; i++) { debugger; }
28 | postMessage({isOpenBeat:false});
29 | };`
30 | ], { type: "text/javascript" })));
31 |
32 | let _isDevtoolsOpen = false;
33 | let _isDetectorPaused = true;
34 |
35 | // @ts-expect-error
36 | // note: leverages that promises can only resolve once.
37 | /**@type {function (boolean | null): void}*/ let resolveVerdict = undefined;
38 | /**@type {number}*/ let nextPulse$ = NaN;
39 |
40 | const onHeartMsg = (/** @type {MessageEvent}*/ msg) => {
41 | if (msg.data.isOpenBeat) {
42 | /** @type {Promise} */
43 | let p = new Promise((_resolveVerdict) => {
44 | resolveVerdict = _resolveVerdict;
45 | let wait$ = setTimeout(
46 | () => { wait$ = NaN; resolveVerdict(true); },
47 | config.maxMillisBeforeAckWhenClosed + 1,
48 | );
49 | });
50 | p.then((verdict) => {
51 | if (verdict === null) return;
52 | if (verdict !== _isDevtoolsOpen) {
53 | _isDevtoolsOpen = verdict;
54 | const cb = { true: config.onDetectOpen, false: config.onDetectClose }[verdict+""];
55 | if (cb) cb();
56 | }
57 | nextPulse$ = setTimeout(
58 | () => { nextPulse$ = NaN; doOnePulse(); },
59 | config.pollingIntervalSeconds * 1000,
60 | );
61 | });
62 | } else {
63 | resolveVerdict(false);
64 | }
65 | };
66 |
67 | const doOnePulse = () => {
68 | heart.postMessage({ moreDebugs: config.moreAnnoyingDebuggerStatements });
69 | }
70 |
71 | /** @type {DevtoolsDetector} */
72 | const detector = {
73 | config,
74 | get isOpen() {
75 | if (_isDetectorPaused && config.onCheckOpennessWhilePaused === "throw") {
76 | throw new Error("`onCheckOpennessWhilePaused` is set to `\"throw\"`.")
77 | }
78 | return _isDevtoolsOpen;
79 | },
80 | get paused() { return _isDetectorPaused; },
81 | set paused(pause) {
82 | // Note: a simpler implementation is to skip updating results in the
83 | // ack callback. The current implementation conserves resources when
84 | // paused.
85 | if (_isDetectorPaused === pause) { return; }
86 | _isDetectorPaused = pause;
87 | if (pause) {
88 | heart.removeEventListener("message", onHeartMsg);
89 | clearTimeout(nextPulse$); nextPulse$ = NaN;
90 | resolveVerdict(null);
91 | } else {
92 | heart.addEventListener("message", onHeartMsg);
93 | doOnePulse();
94 | }
95 | }
96 | };
97 | Object.freeze(detector);
98 | // @ts-expect-error
99 | globalThis.devtoolsDetector = detector;
100 |
101 | switch (config.startup) {
102 | case "manual": break;
103 | case "asap": detector.paused = false; break;
104 | case "domContentLoaded": {
105 | if (document.readyState !== "loading") {
106 | detector.paused = false;
107 | } else {
108 | document.addEventListener("DOMContentLoaded", (ev) => {
109 | detector.paused = false;
110 | }, { once: true });
111 | }
112 | break;
113 | }
114 | }
115 | })();
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "lib": ["es2015", "dom"],
5 | "checkJs": true,
6 | "strict": true,
7 | "noFallthroughCasesInSwitch": true
8 | }
9 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "detect-devtools-via-debugger-heartstop",
3 | "version": "2.0.1",
4 | "license": "MIT",
5 | "description": "Detect whether browser devtools are open",
6 | "keywords": [
7 | "devtools"
8 | ],
9 | "author": "David Fong (https://github.com/david-fong)",
10 | "repository": "github:david-fong/detect-devtools-via-debugger-heartstop",
11 | "homepage": "https://david-fong.github.io/detect-devtools-via-debugger-heartstop/",
12 | "main": "index.js",
13 | "types": "index.d.ts",
14 | "files": ["index.d.ts"]
15 | }
16 |
--------------------------------------------------------------------------------