@hienlh/ppm 0.17.37 → 0.17.39
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.
- package/CHANGELOG.md +21 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +5 -1
- package/dist/web/assets/{ai-resource-editor-DAIaB4mX.js → ai-resource-editor-CAQ_0AXP.js} +1 -1
- package/dist/web/assets/{audio-preview-Df6_blMs.js → audio-preview-BUBpG5oQ.js} +1 -1
- package/dist/web/assets/chat-tab-BhjsCgzO.js +13 -0
- package/dist/web/assets/code-editor-CO_084SG.js +10 -0
- package/dist/web/assets/{conflict-editor-BqMdj782.js → conflict-editor-BsWmVy7f.js} +3 -3
- package/dist/web/assets/context-menu-DIiZdfbE.js +1 -0
- package/dist/web/assets/{csv-preview-BC_BuOzK.js → csv-preview-CfwxBxoL.js} +1 -1
- package/dist/web/assets/{database-viewer-DZQDQOye.js → database-viewer-iR997ZF8.js} +1 -1
- package/dist/web/assets/{diff-viewer-CaKD5pR5.js → diff-viewer-DyOLq66H.js} +1 -1
- package/dist/web/assets/{docx-preview-MBY4op8y.js → docx-preview-CwjJkYcL.js} +1 -1
- package/dist/web/assets/{extension-webview-CMtucoeg.js → extension-webview-MI4F6BtU.js} +1 -1
- package/dist/web/assets/{git-log-panel-DWZUIupS.js → git-log-panel-CYwATO2N.js} +1 -1
- package/dist/web/assets/{glide-data-grid-SLs8Xp6G.js → glide-data-grid-B3DyD4fp.js} +4 -4
- package/dist/web/assets/group-chat-tab-CgWgatpe.js +1 -0
- package/dist/web/assets/{image-preview-lRcpEoEk.js → image-preview-CcPAUgwV.js} +1 -1
- package/dist/web/assets/index-BEB87kj8.css +2 -0
- package/dist/web/assets/index-CSpawh6a.js +50 -0
- package/dist/web/assets/keybindings-store-CT2XJy_J.js +1 -0
- package/dist/web/assets/{markdown-renderer-D9r_4-qe.js → markdown-renderer-B54mHbyz.js} +2 -2
- package/dist/web/assets/{markdown-renderer-DkYo6o9f.js → markdown-renderer-UboKVdKw.js} +1 -1
- package/dist/web/assets/notification-store-Cw-106GW.js +1 -0
- package/dist/web/assets/{pdf-preview-D17y9l8U.js → pdf-preview-B9munmj0.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DgpX_Kyl.js → postgres-viewer-DMBDGrnZ.js} +2 -2
- package/dist/web/assets/settings-tab-CXqpAnZU.js +1 -0
- package/dist/web/assets/{sql-query-editor-BIeAQego.js → sql-query-editor-BT11M9Xs.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-SipxlqWM.js → sqlite-viewer-BMrS0LPb.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-Cp-rP5HZ.js → system-monitor-tab-CznKv8Bb.js} +1 -1
- package/dist/web/assets/terminal-tab-DLvj76hN.js +3 -0
- package/dist/web/assets/tool-cards-BgUD_NIq.js +7 -0
- package/dist/web/assets/{use-monaco-theme-Bd1GBgB3.js → use-monaco-theme-BMgGJW1N.js} +1 -1
- package/dist/web/assets/{video-preview-DT8B2-re.js → video-preview-Bkre8g0Y.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +4 -0
- package/src/server/routes/oauth-loopback.ts +75 -0
- package/src/web/app.tsx +6 -2
- package/src/web/components/layout/dock-panel.tsx +8 -1
- package/src/web/components/layout/mobile-nav.tsx +21 -4
- package/src/web/components/terminal/terminal-finish-login.tsx +75 -0
- package/src/web/components/terminal/terminal-links-sheet.tsx +89 -0
- package/src/web/components/terminal/terminal-mobile-toolbar.tsx +107 -0
- package/src/web/components/terminal/terminal-tab.tsx +57 -86
- package/src/web/components/ui/mobile-bottom-sheet.tsx +49 -29
- package/src/web/hooks/use-terminal-touch-selection.ts +87 -0
- package/src/web/hooks/use-terminal.ts +71 -8
- package/src/web/hooks/use-visual-viewport.ts +83 -0
- package/src/web/lib/oauth-loopback-url.ts +55 -0
- package/dist/web/assets/chat-tab-yPXWmX01.js +0 -13
- package/dist/web/assets/code-editor-CSDB2W8N.js +0 -10
- package/dist/web/assets/group-chat-tab-Celp6ihW.js +0 -1
- package/dist/web/assets/index-BkH2b_6W.js +0 -50
- package/dist/web/assets/index-eW0VEaRF.css +0 -2
- package/dist/web/assets/keybindings-store-CtfEQL3U.js +0 -1
- package/dist/web/assets/mobile-bottom-sheet-BwPBKZON.js +0 -1
- package/dist/web/assets/notification-store-OF6L8lWu.js +0 -1
- package/dist/web/assets/settings-tab-Cou9P1gO.js +0 -1
- package/dist/web/assets/terminal-tab-C9T_X4oo.js +0 -3
- package/dist/web/assets/tool-cards-BFVmoF_j.js +0 -7
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-to-select for xterm on touch devices.
|
|
3
|
+
*
|
|
4
|
+
* xterm has no touch selection at all: `SelectionService` is driven by a
|
|
5
|
+
* `mousedown` on the `.xterm` element followed by `mousemove`/`mouseup` on the
|
|
6
|
+
* owning document, and nothing translates touch into those. This hook feeds it
|
|
7
|
+
* synthetic mouse events from a one-finger drag, so selection, its highlight and
|
|
8
|
+
* `term.getSelection()` all behave exactly as they do with a mouse.
|
|
9
|
+
*
|
|
10
|
+
* Only active while the caller has select mode switched on — the same gesture
|
|
11
|
+
* otherwise scrolls the viewport, and silently stealing it would strand the user
|
|
12
|
+
* with no way to scroll back.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, type RefObject } from "react";
|
|
15
|
+
|
|
16
|
+
function mouseEventFrom(type: string, touch: Touch, buttons: number): MouseEvent {
|
|
17
|
+
return new MouseEvent(type, {
|
|
18
|
+
bubbles: true,
|
|
19
|
+
cancelable: true,
|
|
20
|
+
view: window,
|
|
21
|
+
clientX: touch.clientX,
|
|
22
|
+
clientY: touch.clientY,
|
|
23
|
+
screenX: touch.screenX,
|
|
24
|
+
screenY: touch.screenY,
|
|
25
|
+
button: 0,
|
|
26
|
+
buttons,
|
|
27
|
+
// Click count. xterm branches on it to pick single/word/line selection and
|
|
28
|
+
// ignores the event entirely at the default of 0, so a drag would start no
|
|
29
|
+
// selection at all without this.
|
|
30
|
+
detail: 1,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useTerminalTouchSelection(
|
|
35
|
+
containerRef: RefObject<HTMLDivElement | null>,
|
|
36
|
+
enabled: boolean,
|
|
37
|
+
) {
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const container = containerRef.current;
|
|
40
|
+
if (!enabled || !container) return;
|
|
41
|
+
|
|
42
|
+
// `.xterm` is where SelectionService listens for mousedown; dispatching on
|
|
43
|
+
// the screen element would work too but this keeps the target explicit.
|
|
44
|
+
const xterm = container.querySelector(".xterm");
|
|
45
|
+
if (!xterm) return;
|
|
46
|
+
|
|
47
|
+
// Capture phase so these run before xterm's own document-level touch
|
|
48
|
+
// gesture handler, and stopPropagation keeps that handler from scrolling
|
|
49
|
+
// the viewport out from under the drag.
|
|
50
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
51
|
+
const touch = e.touches[0];
|
|
52
|
+
if (!touch || e.touches.length > 1) return;
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
e.stopPropagation();
|
|
55
|
+
xterm.dispatchEvent(mouseEventFrom("mousedown", touch, 1));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
59
|
+
const touch = e.touches[0];
|
|
60
|
+
if (!touch) return;
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
document.dispatchEvent(mouseEventFrom("mousemove", touch, 1));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onTouchEnd = (e: TouchEvent) => {
|
|
67
|
+
const touch = e.changedTouches[0];
|
|
68
|
+
if (!touch) return;
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
document.dispatchEvent(mouseEventFrom("mouseup", touch, 0));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const opts = { capture: true, passive: false } as const;
|
|
75
|
+
container.addEventListener("touchstart", onTouchStart, opts);
|
|
76
|
+
container.addEventListener("touchmove", onTouchMove, opts);
|
|
77
|
+
container.addEventListener("touchend", onTouchEnd, opts);
|
|
78
|
+
container.addEventListener("touchcancel", onTouchEnd, opts);
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
container.removeEventListener("touchstart", onTouchStart, opts);
|
|
82
|
+
container.removeEventListener("touchmove", onTouchMove, opts);
|
|
83
|
+
container.removeEventListener("touchend", onTouchEnd, opts);
|
|
84
|
+
container.removeEventListener("touchcancel", onTouchEnd, opts);
|
|
85
|
+
};
|
|
86
|
+
}, [containerRef, enabled]);
|
|
87
|
+
}
|
|
@@ -34,9 +34,19 @@ interface UseTerminalReturn {
|
|
|
34
34
|
getSelection: () => string;
|
|
35
35
|
/** Read buffer from last command start to current cursor (for "Send to Chat"). */
|
|
36
36
|
getLastCommandOutput: () => string;
|
|
37
|
+
/** URLs present in the scrollback, most recent first. */
|
|
38
|
+
getBufferUrls: () => string[];
|
|
37
39
|
restart: () => void;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** Trailing punctuation that ends a sentence rather than the URL itself. */
|
|
43
|
+
const URL_TRAILING_PUNCT = /[).,;:'"\]}>]+$/;
|
|
44
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/g;
|
|
45
|
+
/** Cap the scan so a 50k-line scrollback never blocks the tap that triggered it. */
|
|
46
|
+
const URL_SCAN_MAX_ROWS = 5000;
|
|
47
|
+
/** Quiet period before refitting to a new container size, in ms. */
|
|
48
|
+
const RESIZE_SETTLE_MS = 100;
|
|
49
|
+
|
|
40
50
|
const RESIZE_PREFIX = "\x01RESIZE:";
|
|
41
51
|
const PING_MSG = "\x01PING";
|
|
42
52
|
const PONG_MSG = "\x01PONG";
|
|
@@ -101,6 +111,50 @@ export function useTerminal(
|
|
|
101
111
|
return lines.join("\n");
|
|
102
112
|
}, []);
|
|
103
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Collect URLs from the scrollback.
|
|
116
|
+
*
|
|
117
|
+
* xterm's link addon activates a link from a mouse hover followed by mouseup,
|
|
118
|
+
* neither of which a touch device produces, so on mobile this list is the only
|
|
119
|
+
* way to reach a URL the shell printed (an `aws sso login` code, for example).
|
|
120
|
+
* Wrapped lines are joined first — a URL long enough to matter is usually the
|
|
121
|
+
* one that got split across rows.
|
|
122
|
+
*/
|
|
123
|
+
const getBufferUrls = useCallback(() => {
|
|
124
|
+
const term = termRef.current;
|
|
125
|
+
if (!term) return [];
|
|
126
|
+
const buf = term.buffer.active;
|
|
127
|
+
const endRow = buf.baseY + buf.cursorY;
|
|
128
|
+
const startRow = Math.max(0, endRow - URL_SCAN_MAX_ROWS);
|
|
129
|
+
|
|
130
|
+
const logical: string[] = [];
|
|
131
|
+
let current = "";
|
|
132
|
+
for (let i = startRow; i <= endRow; i++) {
|
|
133
|
+
const line = buf.getLine(i);
|
|
134
|
+
if (!line) continue;
|
|
135
|
+
current += line.translateToString(true);
|
|
136
|
+
// isWrapped marks a row as the continuation of the previous one, so the
|
|
137
|
+
// break is a display artefact and must not split the URL.
|
|
138
|
+
const next = buf.getLine(i + 1);
|
|
139
|
+
if (next?.isWrapped) continue;
|
|
140
|
+
logical.push(current);
|
|
141
|
+
current = "";
|
|
142
|
+
}
|
|
143
|
+
if (current) logical.push(current);
|
|
144
|
+
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
const urls: string[] = [];
|
|
147
|
+
for (const text of logical) {
|
|
148
|
+
for (const match of text.match(URL_PATTERN) ?? []) {
|
|
149
|
+
const url = match.replace(URL_TRAILING_PUNCT, "");
|
|
150
|
+
if (!url || seen.has(url)) continue;
|
|
151
|
+
seen.add(url);
|
|
152
|
+
urls.push(url);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return urls.reverse();
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
104
158
|
const sendResize = useCallback(() => {
|
|
105
159
|
const term = termRef.current;
|
|
106
160
|
const ws = wsRef.current;
|
|
@@ -311,16 +365,24 @@ export function useTerminal(
|
|
|
311
365
|
};
|
|
312
366
|
document.addEventListener("visibilitychange", onVisibility);
|
|
313
367
|
|
|
314
|
-
// ResizeObserver for auto-fit — skip when tab is hidden (0 dimensions)
|
|
368
|
+
// ResizeObserver for auto-fit — skip when tab is hidden (0 dimensions).
|
|
369
|
+
// Debounced: an on-screen keyboard resizes the container many times as it
|
|
370
|
+
// slides, and refitting on each one sends the shell a burst of size changes
|
|
371
|
+
// that it answers by redrawing, so the output visibly churns. One fit once
|
|
372
|
+
// the size settles gives the same result without the churn.
|
|
373
|
+
let fitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
315
374
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
316
375
|
const entry = entries[0];
|
|
317
376
|
if (!entry || entry.contentRect.width === 0 || entry.contentRect.height === 0) return;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
377
|
+
if (fitTimer) clearTimeout(fitTimer);
|
|
378
|
+
fitTimer = setTimeout(() => {
|
|
379
|
+
try {
|
|
380
|
+
fitAddon.fit();
|
|
381
|
+
sendResize();
|
|
382
|
+
} catch {
|
|
383
|
+
// Ignore fit errors during teardown
|
|
384
|
+
}
|
|
385
|
+
}, RESIZE_SETTLE_MS);
|
|
324
386
|
});
|
|
325
387
|
resizeObserver.observe(container);
|
|
326
388
|
|
|
@@ -332,6 +394,7 @@ export function useTerminal(
|
|
|
332
394
|
|
|
333
395
|
return () => {
|
|
334
396
|
unsubTheme();
|
|
397
|
+
if (fitTimer) clearTimeout(fitTimer);
|
|
335
398
|
resizeObserver.disconnect();
|
|
336
399
|
clearInterval(heartbeatInterval);
|
|
337
400
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
@@ -344,5 +407,5 @@ export function useTerminal(
|
|
|
344
407
|
};
|
|
345
408
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
346
409
|
|
|
347
|
-
return { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, restart };
|
|
410
|
+
return { connected, reconnecting, exited, sendData, getSelection, getLastCommandOutput, getBufferUrls, restart };
|
|
348
411
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measures the part of the screen the user can actually see, and how much of it
|
|
3
|
+
* the on-screen keyboard is occupying.
|
|
4
|
+
*
|
|
5
|
+
* `vh`/`dvh` units and a `fixed` element both resolve against the layout
|
|
6
|
+
* viewport, which does not shrink when a keyboard opens — so anything anchored
|
|
7
|
+
* to the bottom ends up behind it. Only `visualViewport` reports the truth.
|
|
8
|
+
*
|
|
9
|
+
* Updates are applied on the frame they arrive rather than after a settle
|
|
10
|
+
* delay: iOS emits intermediate sizes throughout the keyboard's slide and its
|
|
11
|
+
* final event can land before the layout has finished moving, so deferring is
|
|
12
|
+
* what leaves geometry stale — visibly, as a strip of untouched page below a
|
|
13
|
+
* panel that should be flush with the keyboard.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
|
|
17
|
+
export interface ViewportInsets {
|
|
18
|
+
/** Height of the visible area, in px. */
|
|
19
|
+
height: number;
|
|
20
|
+
/**
|
|
21
|
+
* Roughly how much the keyboard covers, in px.
|
|
22
|
+
*
|
|
23
|
+
* Approximate on purpose: `innerHeight` on iOS counts space behind browser
|
|
24
|
+
* chrome, so this can be off by a bar's height. Use it to inset content, never
|
|
25
|
+
* to place a background — an inset that is slightly wrong shifts content a
|
|
26
|
+
* little, whereas a background placed the same way leaves a visible hole.
|
|
27
|
+
*/
|
|
28
|
+
keyboardInset: number;
|
|
29
|
+
/** Whether the missing height is big enough to be a keyboard, not browser chrome. */
|
|
30
|
+
keyboardOpen: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Below this, the missing height is a collapsed address bar, not a keyboard. */
|
|
34
|
+
const KEYBOARD_MIN_INSET = 120;
|
|
35
|
+
|
|
36
|
+
function measure(viewport: VisualViewport): ViewportInsets {
|
|
37
|
+
const inset = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop);
|
|
38
|
+
return {
|
|
39
|
+
height: viewport.height,
|
|
40
|
+
keyboardInset: inset,
|
|
41
|
+
keyboardOpen: inset >= KEYBOARD_MIN_INSET,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Live viewport insets, or null when unsupported or while `active` is false. */
|
|
46
|
+
export function useVisualViewport(active: boolean): ViewportInsets | null {
|
|
47
|
+
const [insets, setInsets] = useState<ViewportInsets | null>(null);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const viewport = window.visualViewport;
|
|
51
|
+
if (!active || !viewport) {
|
|
52
|
+
setInsets(null);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Several resize/scroll events can fire per frame; one read per frame is
|
|
57
|
+
// enough and keeps React from rendering the same geometry repeatedly.
|
|
58
|
+
let frame: number | null = null;
|
|
59
|
+
const update = () => {
|
|
60
|
+
frame = null;
|
|
61
|
+
setInsets((prev) => {
|
|
62
|
+
const next = measure(viewport);
|
|
63
|
+
return prev && prev.height === next.height && prev.keyboardInset === next.keyboardInset
|
|
64
|
+
? prev
|
|
65
|
+
: next;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const schedule = () => {
|
|
69
|
+
if (frame === null) frame = requestAnimationFrame(update);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
update();
|
|
73
|
+
viewport.addEventListener("resize", schedule);
|
|
74
|
+
viewport.addEventListener("scroll", schedule);
|
|
75
|
+
return () => {
|
|
76
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
77
|
+
viewport.removeEventListener("resize", schedule);
|
|
78
|
+
viewport.removeEventListener("scroll", schedule);
|
|
79
|
+
};
|
|
80
|
+
}, [active]);
|
|
81
|
+
|
|
82
|
+
return insets;
|
|
83
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognises OAuth authorization URLs that hand their result back to a port on
|
|
3
|
+
* the machine running the CLI, rather than to whatever device opened them.
|
|
4
|
+
*
|
|
5
|
+
* This is what strands a login started from a phone: the sign-in succeeds, the
|
|
6
|
+
* browser is redirected to 127.0.0.1:<port>, and on the phone that address is
|
|
7
|
+
* the phone itself — so the CLI waiting on the real machine never hears back.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "::1"]);
|
|
11
|
+
|
|
12
|
+
/** Whether a hostname addresses the machine the browser is running on. */
|
|
13
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
14
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
15
|
+
if (LOOPBACK_HOSTNAMES.has(host)) return true;
|
|
16
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The loopback port an authorization URL will redirect back to, or null when it
|
|
21
|
+
* redirects somewhere reachable from any device.
|
|
22
|
+
*/
|
|
23
|
+
export function loopbackRedirectPort(url: string): string | null {
|
|
24
|
+
let parsed: URL;
|
|
25
|
+
try {
|
|
26
|
+
parsed = new URL(url);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
// `redirect_uri` is the OAuth 2.0 name; some tools still send `redirectUri`.
|
|
31
|
+
const redirect =
|
|
32
|
+
parsed.searchParams.get("redirect_uri") ?? parsed.searchParams.get("redirectUri");
|
|
33
|
+
if (!redirect) return null;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const target = new URL(redirect);
|
|
37
|
+
if (!isLoopbackHostname(target.hostname)) return null;
|
|
38
|
+
return target.port || (target.protocol === "https:" ? "443" : "80");
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether a pasted string is a callback this machine could deliver. */
|
|
45
|
+
export function isDeliverableCallback(url: string): boolean {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(url.trim());
|
|
48
|
+
return (
|
|
49
|
+
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
|
|
50
|
+
isLoopbackHostname(parsed.hostname)
|
|
51
|
+
);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|