Locate the player's window instead of assuming fullscreen
The corners and trail animations had stopped showing stills. Neither was at fault: capture.js gated every screenshot on window.innerWidth === screen.width && window.innerHeight === screen.height and that compares two different units. innerWidth is device pixels divided by devicePixelRatio; screen.width is not divided. At any zoom but 100%, or any fractional display scale, the two can never be equal — measured here at dpr 1.25, fullscreen gives 1536x864 against a reported 1920x1080. The gate stayed shut, grab() returned null, and shoot()/drop() bailed out before making a request. The only symptom was an F11 notice that stayed wrong after F11. Forcing the gate open would not have helped: the rect went out in CSS pixels and was read as logical screen pixels, so every crop would have been 1.25x too small and offset. Stop guessing from browser metrics and ask the compositor, which knows exactly. hyprctl gives the window's position and size in logical pixels; against the viewport the page reports, their ratio is the CSS-pixel scale outright, and since CSS pixels are square that one number does for both axes. The vertical room it leaves over is the tab and address bars, measured rather than assumed — which is what lets the player run in an ordinary window. Fullscreen becomes just the case where the leftover is nothing, so Start still asks for it, for the projector's sake, but says nothing when refused. The window is found by matching document.title as a prefix. The queue page was titled "Party Player — Queue", which would have matched the player's prefix and aimed the crop at the wrong window, so it is now "Queue — Party Player". Candidates are ranked on screen first, then most recently focused, then largest: hyprctl clients has no `focused` field, it has focusHistoryID. The old F11 notice is replaced by one that fires only when the server genuinely cannot find the window, debounced on resize so dragging does not spawn a burst of grim processes. Verified against the live player window: capture() returns bytes identical to a grim -g of the independently predicted region, the same physical region described at 100% and 125% zoom resolves to identical output, and the route answers 422 for an unknown window and 400 for a missing title or viewport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zyhxe2w7eEMXvz8wekCyc
This commit is contained in:
@@ -106,9 +106,21 @@ The server takes it. It runs on the same machine as the screen, so it can grab
|
||||
the region itself with [`grim`](https://sr.ht/~emersion/grim) and hand back the
|
||||
finished crop, which the player drops straight into an `<img>`. No picker, no
|
||||
permission prompt, and no "Sharing this tab" bar parked over the party. This
|
||||
needs a wlroots compositor — Hyprland, Sway, river — and `grim` on `PATH`;
|
||||
`server/screen.js` reads the monitor layout from `hyprctl` so the right screen
|
||||
is measured when a projector is plugged in.
|
||||
needs a wlroots compositor — Hyprland, Sway, river — and `grim` on `PATH`.
|
||||
|
||||
`server/screen.js` also asks `hyprctl` where the player's window is and how big.
|
||||
That is what makes the crop reliable: the page measures in CSS pixels, which are
|
||||
device pixels divided by `devicePixelRatio`, and that ratio moves with browser
|
||||
zoom and fractional display scaling. Comparing the window's real size against the
|
||||
viewport the page reports gives the scale outright, and the vertical room left
|
||||
over is the tab and address bars. **So the server path needs no fullscreen and no
|
||||
particular zoom** — it works in an ordinary window. **Start** still asks for
|
||||
fullscreen because that is what you want on a projector, but nothing breaks if
|
||||
you refuse.
|
||||
|
||||
The window is found by matching the page's `document.title` as a prefix, so the
|
||||
player must be the **active tab** in its window — which it is when it is the
|
||||
thing on screen. If the server cannot find it, the player says so in the corner.
|
||||
|
||||
Without `grim`, the player falls back to `getDisplayMedia` and asks to share
|
||||
your screen on the first click of **Start**. That path costs a picker and a
|
||||
@@ -120,10 +132,12 @@ sharing indicator, and what you choose in the picker matters:
|
||||
- **A single window** cannot be used: it includes the tab and URL bars, and
|
||||
Wayland gives the page no window coordinates to subtract.
|
||||
|
||||
**Either way the page has to be fullscreen**, which is why **Start** requests it:
|
||||
only then does a CSS pixel line up with a screen pixel. Leave fullscreen and the
|
||||
screenshots pause until you press <kbd>F11</kbd>. The exception is Chrome's tab
|
||||
capture, which is the viewport by definition and lines up in a window too.
|
||||
**The fallback needs the page fullscreen**, which is the one case it can map: a
|
||||
CSS pixel lines up with a screen pixel only there, and only at 100% zoom, since
|
||||
`innerWidth` scales with zoom and `screen.width` does not. Leave fullscreen and
|
||||
the screenshots pause until you press <kbd>F11</kbd>. The exception is Chrome's
|
||||
tab capture, which is the viewport by definition and lines up in a window too.
|
||||
The server path has none of these limits.
|
||||
|
||||
Going without is fine: the frame still parks in its corner and still glides,
|
||||
just unphotographed. That is what happens when you open the player from another
|
||||
|
||||
+89
-27
@@ -15,10 +15,13 @@
|
||||
// path removes — a picker, a sharing indicator, and on Firefox no tab capture
|
||||
// at all, only whole windows and screens.
|
||||
//
|
||||
// Both need the page fullscreen, for the same reason: only then does a CSS
|
||||
// pixel equal a screen pixel, and a shared window can never be mapped at all —
|
||||
// it carries the tab bar and URL bar, and Wayland reports window.screenX as 0,
|
||||
// so there is no offset to subtract.
|
||||
// The two paths differ in what they need of the window. The fallback crops a
|
||||
// stream of the whole screen, so it needs the page fullscreen: only then does a
|
||||
// CSS pixel equal a screen pixel, and only then is the viewport's origin the
|
||||
// screen's origin — Wayland reports window.screenX as 0, so there is no offset
|
||||
// to subtract. The server path needs neither, because it asks the compositor
|
||||
// where the window actually is and how big, and derives the scale and the
|
||||
// browser-chrome offset from that. It works windowed, and at any zoom.
|
||||
|
||||
// The server screenshots its own screen, which is the player's screen only when
|
||||
// the two are the same machine. Opened from a laptop across the network, /shot
|
||||
@@ -33,8 +36,10 @@ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
||||
let serverShots = null;
|
||||
|
||||
if (LOCAL_HOSTS.has(location.hostname)) {
|
||||
// Any rect will do; only the status code is read.
|
||||
fetch('/shot?x=0&y=0&w=2&h=2&sw=0&sh=0', { method: 'GET', cache: 'no-store' })
|
||||
// Any rect will do; only the status code is read. 501 is "this machine cannot
|
||||
// screenshot", which is the one answer that rules the server path out — a 422
|
||||
// here would only mean the window was not found yet, which is transient.
|
||||
fetch(shotUrl({ x: 0, y: 0, width: 2, height: 2 }), { method: 'GET', cache: 'no-store' })
|
||||
.then((response) => {
|
||||
serverShots = response.status !== 501 && response.status !== 404;
|
||||
})
|
||||
@@ -43,6 +48,27 @@ if (LOCAL_HOSTS.has(location.hostname)) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A /shot URL for a viewport rect, in CSS pixels. The viewport size and the
|
||||
* page title travel with every request: the server needs the first to size a
|
||||
* CSS pixel and the second to find this page's window.
|
||||
*/
|
||||
function shotUrl({ x, y, width, height }, nonce = 0) {
|
||||
const query = new URLSearchParams({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(width),
|
||||
h: Math.round(height),
|
||||
vw: window.innerWidth,
|
||||
vh: window.innerHeight,
|
||||
title: document.title,
|
||||
// The server sends no-store, but a nonce also keeps two stills taken at the
|
||||
// same spot from collapsing into one request in flight.
|
||||
n: String(nonce),
|
||||
});
|
||||
return `/shot?${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a screenshot source. Resolves to a `{ grab, stop }` handle, or to
|
||||
* null if there is none — callers degrade rather than fail.
|
||||
@@ -61,9 +87,15 @@ export function requestCapture({ onIssue = () => {} } = {}) {
|
||||
|
||||
/**
|
||||
* True while the viewport covers the whole screen, which is the one case the
|
||||
* crop maths can handle. Tested by dimensions rather than by
|
||||
* *fallback's* crop maths can handle. Tested by dimensions rather than by
|
||||
* `document.fullscreenElement`, because F11 fullscreen is browser-level and
|
||||
* leaves that property null.
|
||||
*
|
||||
* Only the getDisplayMedia path consults this. Note that the equality is exact
|
||||
* and so holds only at devicePixelRatio 1: innerWidth is device pixels divided
|
||||
* by that ratio, while screen.width is not, so any browser zoom or fractional
|
||||
* display scale puts the two in different units. That is a real limit of the
|
||||
* fallback, and one more reason the server path is preferred.
|
||||
*/
|
||||
function fillsScreen() {
|
||||
return window.innerWidth === screen.width && window.innerHeight === screen.height;
|
||||
@@ -97,38 +129,68 @@ function trackAlignment(onIssue, initial) {
|
||||
// --- server screenshots -------------------------------------------------
|
||||
|
||||
function openServerCapture(onIssue) {
|
||||
const aligned = trackAlignment(onIssue, fillsScreen());
|
||||
let nonce = 0;
|
||||
|
||||
// One real request up front, so a server that cannot find this page's window
|
||||
// says so at the start rather than leaving the animations quietly still-less.
|
||||
// The <img> elements the stills go into report nothing we can read from here.
|
||||
checkServerCrop(onIssue);
|
||||
|
||||
// Debounced: every check costs a screenshot on the server, and a window drag
|
||||
// fires resize dozens of times. Only the settled size is worth testing.
|
||||
let recheck = 0;
|
||||
function onResize() {
|
||||
clearTimeout(recheck);
|
||||
recheck = setTimeout(() => checkServerCrop(onIssue), 500);
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return {
|
||||
/**
|
||||
* A URL for a fresh photograph of `rect`, in CSS pixels. Safe to hand
|
||||
* straight to an <img>: the server returns the crop itself, already sized.
|
||||
* Null while the viewport does not cover the screen.
|
||||
* A URL for a fresh photograph of `rect`, in CSS pixels relative to the
|
||||
* viewport. Safe to hand straight to an <img>: the server returns the crop
|
||||
* itself, already sized.
|
||||
*/
|
||||
grab({ x, y, width, height }) {
|
||||
if (!aligned()) return null;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
const query = new URLSearchParams({
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(width),
|
||||
h: Math.round(height),
|
||||
sw: screen.width,
|
||||
sh: screen.height,
|
||||
// The server sends no-store, but a nonce also keeps two stills taken at
|
||||
// the same spot from collapsing into one request in flight.
|
||||
n: String(++nonce),
|
||||
});
|
||||
return `/shot?${query}`;
|
||||
return shotUrl({ x, y, width, height }, ++nonce);
|
||||
},
|
||||
|
||||
// Nothing is held open: each screenshot is one request.
|
||||
stop() {},
|
||||
// No stream is held open — each screenshot is one request — but the resize
|
||||
// watcher still has to go, or a stopped handle keeps probing the server.
|
||||
stop() {
|
||||
clearTimeout(recheck);
|
||||
window.removeEventListener('resize', onResize);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Report only transitions, not every failure: this runs again on each resize,
|
||||
* and a stuck state would otherwise repeat the same notice.
|
||||
*/
|
||||
let cropWorked = true;
|
||||
|
||||
async function checkServerCrop(onIssue) {
|
||||
let ok = false;
|
||||
try {
|
||||
const response = await fetch(shotUrl({ x: 0, y: 0, width: 16, height: 16 }), {
|
||||
cache: 'no-store',
|
||||
});
|
||||
ok = response.ok;
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (ok === cropWorked) return;
|
||||
cropWorked = ok;
|
||||
onIssue(
|
||||
ok
|
||||
? 'Screenshots are working again.'
|
||||
: 'Screenshots paused: the server cannot find this page’s window to photograph.',
|
||||
);
|
||||
}
|
||||
|
||||
// --- getDisplayMedia fallback -------------------------------------------
|
||||
|
||||
function requestDisplayMedia(onIssue) {
|
||||
|
||||
@@ -64,12 +64,11 @@ async function start() {
|
||||
// chosen without one, so this ordering has to hold either way.
|
||||
const capturing = requestCapture({ onIssue: showNotice });
|
||||
|
||||
// Screenshots only line up while the viewport covers the whole screen, since
|
||||
// that is what makes a CSS pixel a screen pixel. Chrome's tab capture is the
|
||||
// one exception, and fullscreen does it no harm.
|
||||
document.documentElement.requestFullscreen?.().catch(() => {
|
||||
showNotice('Could not go fullscreen — press F11 so screenshots line up.');
|
||||
});
|
||||
// Cosmetic now rather than required: the server path locates the window
|
||||
// itself, so screenshots line up windowed too. It is still what you want on a
|
||||
// projector, so ask — but say nothing if it is refused, since nothing breaks.
|
||||
// (The getDisplayMedia fallback does still need it; that path says so itself.)
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
|
||||
gate.classList.add('hidden');
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Party Player — Queue</title>
|
||||
<!-- Queue first, deliberately: the server finds the player's window by
|
||||
matching this title as a prefix (see server/screen.js), and a queue tab
|
||||
titled "Party Player — …" would match it too. -->
|
||||
<title>Queue — Party Player</title>
|
||||
<link rel="stylesheet" href="/queue/queue.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+12
-2
@@ -51,14 +51,24 @@ app.get('/shot', async (req, res) => {
|
||||
y: Number(req.query.y),
|
||||
width: Number(req.query.w),
|
||||
height: Number(req.query.h),
|
||||
screenWidth: Number(req.query.sw),
|
||||
screenHeight: Number(req.query.sh),
|
||||
// The viewport the page measured its rect against. The crop is in CSS
|
||||
// pixels, and this is what lets the server work out what one of those is
|
||||
// worth on screen — see server/screen.js.
|
||||
viewportWidth: Number(req.query.vw),
|
||||
viewportHeight: Number(req.query.vh),
|
||||
};
|
||||
if (Object.values(rect).some((value) => !Number.isFinite(value))) {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// The page's own document.title, which is how its window is picked out.
|
||||
rect.title = String(req.query.title ?? '');
|
||||
if (!rect.title) {
|
||||
res.sendStatus(400);
|
||||
return;
|
||||
}
|
||||
|
||||
// 501, not 404: the player probes this route to decide whether to fall back
|
||||
// to getDisplayMedia, and needs to tell "wrong machine" from "no route".
|
||||
if (!(await screen.available())) {
|
||||
|
||||
+124
-17
@@ -9,6 +9,17 @@
|
||||
//
|
||||
// grim is the wlroots screencopy client (Hyprland, Sway, river). Elsewhere this
|
||||
// reports unavailable and the player falls back to getDisplayMedia.
|
||||
//
|
||||
// The player sends its crop in CSS pixels, which are its own private unit: they
|
||||
// are device pixels divided by devicePixelRatio, and that ratio moves with
|
||||
// browser zoom and with fractional display scaling. The page cannot convert
|
||||
// them itself — screen.width is reported in a different unit by every engine,
|
||||
// and Wayland reports window.screenX as 0, so there is no offset to add. The
|
||||
// compositor knows both numbers exactly, so we ask it: hyprctl gives the
|
||||
// window's position and size in logical pixels, and the ratio of that to the
|
||||
// viewport the page reports is the scale factor, whatever the zoom happens to
|
||||
// be. That is what lets the player run in an ordinary window instead of
|
||||
// insisting on fullscreen.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
@@ -101,6 +112,64 @@ async function monitors() {
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's window, in the compositor's logical coordinate space.
|
||||
*
|
||||
* `title` is the page's own document.title, which every browser puts at the
|
||||
* front of its window title. Matched as a prefix rather than a substring so the
|
||||
* queue tab — deliberately titled the other way round — cannot be mistaken for
|
||||
* the player. If several tabs are somehow showing the player, the focused one
|
||||
* wins, then the largest: on a projector that is the one the party can see.
|
||||
*
|
||||
* Null when nothing matches, which the caller reports rather than guessing at,
|
||||
* since cropping against the wrong window photographs the wrong thing.
|
||||
*/
|
||||
async function playerWindow(title) {
|
||||
const out = await run('hyprctl', ['clients', '-j']);
|
||||
if (!out) return null;
|
||||
|
||||
let clients = [];
|
||||
try {
|
||||
clients = JSON.parse(out.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = clients.filter(
|
||||
(client) =>
|
||||
typeof client.title === 'string' &&
|
||||
client.title.startsWith(title) &&
|
||||
Array.isArray(client.at) &&
|
||||
Array.isArray(client.size) &&
|
||||
client.size[0] > 0 &&
|
||||
client.size[1] > 0 &&
|
||||
// A window on another workspace is not on the glass. Photographing its
|
||||
// coordinates would return whatever is actually there instead.
|
||||
client.mapped !== false &&
|
||||
client.hidden !== true,
|
||||
);
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
// Best first: the one actually on screen, then the most recently focused
|
||||
// (focusHistoryID counts up from 0 — there is no `focused` field on clients),
|
||||
// then the largest, which on a projector is the one the party can see.
|
||||
const rank = (client) => [
|
||||
client.visible === false ? 1 : 0,
|
||||
Number.isFinite(client.focusHistoryID) ? client.focusHistoryID : Number.MAX_SAFE_INTEGER,
|
||||
-(client.size[0] * client.size[1]),
|
||||
];
|
||||
|
||||
const [best] = matches.sort((a, b) => {
|
||||
const [ra, rb] = [rank(a), rank(b)];
|
||||
for (let i = 0; i < ra.length; i += 1) {
|
||||
if (ra[i] !== rb[i]) return ra[i] - rb[i];
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { x: best.at[0], y: best.at[1], width: best.size[0], height: best.size[1] };
|
||||
}
|
||||
|
||||
/** Whether this machine can be screenshotted at all. */
|
||||
export async function available() {
|
||||
if (grimAvailable === null) {
|
||||
@@ -110,31 +179,67 @@ export async function available() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Photograph one rectangle of the screen the player is on.
|
||||
* Where the page's viewport sits on screen, in logical pixels, given the
|
||||
* player's window and the viewport size the page reports in CSS pixels.
|
||||
*
|
||||
* `rect` is in CSS pixels, which equal logical screen pixels while the page is
|
||||
* fullscreen — that equality is the whole reason the caller insists on
|
||||
* fullscreen. `screenWidth`/`screenHeight` name which monitor to measure
|
||||
* against, so a player on a projector is not cropped against the laptop panel.
|
||||
*
|
||||
* Resolves to a JPEG buffer, or null if grim is unusable or the rectangle
|
||||
* falls outside the screen.
|
||||
* The viewport spans the window's full width — browsers put no chrome down the
|
||||
* sides — so their ratio is the CSS-pixel scale outright. CSS pixels are square,
|
||||
* so that one number does for both axes, and whatever vertical room it does not
|
||||
* account for is the tab and address bars. Measuring the chrome rather than
|
||||
* assuming it is zero is what lets a windowed player work; fullscreen is then
|
||||
* just the case where the leftover happens to be nothing.
|
||||
*/
|
||||
export async function capture({ x, y, width, height, screenWidth, screenHeight }) {
|
||||
if (!(await available())) return null;
|
||||
export function viewportOrigin(window, viewportWidth, viewportHeight) {
|
||||
const scale = window.width / viewportWidth;
|
||||
const chrome = window.height - viewportHeight * scale;
|
||||
|
||||
return { x: window.x, y: window.y + chrome, scale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Photograph one rectangle of the player's viewport.
|
||||
*
|
||||
* `rect` is in CSS pixels, relative to the viewport — straight out of
|
||||
* getBoundingClientRect, with no fullscreen assumption behind it. `title` is the
|
||||
* page's document.title, used to find the window (see playerWindow).
|
||||
*
|
||||
* Resolves to a JPEG buffer, or null if grim is unusable, the window cannot be
|
||||
* found, or the rectangle falls outside the monitor.
|
||||
*/
|
||||
export async function capture({ x, y, width, height, viewportWidth, viewportHeight, title }) {
|
||||
if (!(await available())) return null;
|
||||
if (!(viewportWidth > 0 && viewportHeight > 0)) return null;
|
||||
|
||||
const window = await playerWindow(title);
|
||||
if (!window) return null;
|
||||
|
||||
const origin = viewportOrigin(window, viewportWidth, viewportHeight);
|
||||
|
||||
// Into global logical pixels, the space grim's -g takes.
|
||||
const gx = origin.x + x * origin.scale;
|
||||
const gy = origin.y + y * origin.scale;
|
||||
const gw = width * origin.scale;
|
||||
const gh = height * origin.scale;
|
||||
|
||||
// Which monitor the window is on, so a player on the projector is measured
|
||||
// against the projector and not the laptop panel.
|
||||
const screens = await monitors();
|
||||
const centreX = window.x + window.width / 2;
|
||||
const centreY = window.y + window.height / 2;
|
||||
const screen =
|
||||
screens.find((m) => m.width === screenWidth && m.height === screenHeight) ??
|
||||
screens.find(
|
||||
(m) =>
|
||||
centreX >= m.x && centreX < m.x + m.width && centreY >= m.y && centreY < m.y + m.height,
|
||||
) ??
|
||||
screens[0] ??
|
||||
{ x: 0, y: 0, scale: null, width: screenWidth, height: screenHeight };
|
||||
{ x: 0, y: 0, scale: null, width: Infinity, height: Infinity };
|
||||
|
||||
// The frame can hang off an edge mid-bounce. Clip rather than refuse: grim
|
||||
// fails outright on a region that leaves the output.
|
||||
const left = Math.max(0, Math.min(x, screen.width));
|
||||
const top = Math.max(0, Math.min(y, screen.height));
|
||||
const right = Math.max(left, Math.min(x + width, screen.width));
|
||||
const bottom = Math.max(top, Math.min(y + height, screen.height));
|
||||
const left = Math.max(screen.x, Math.min(gx, screen.x + screen.width));
|
||||
const top = Math.max(screen.y, Math.min(gy, screen.y + screen.height));
|
||||
const right = Math.max(left, Math.min(gx + gw, screen.x + screen.width));
|
||||
const bottom = Math.max(top, Math.min(gy + gh, screen.y + screen.height));
|
||||
const w = Math.round(right - left);
|
||||
const h = Math.round(bottom - top);
|
||||
if (w < 1 || h < 1) return null;
|
||||
@@ -146,7 +251,9 @@ export async function capture({ x, y, width, height, screenWidth, screenHeight }
|
||||
// asking grim to resample is an order of magnitude slower than not.
|
||||
// (-o would pin the scale too, but grim rejects it alongside -g.)
|
||||
if (screen.scale) args.push('-s', String(screen.scale));
|
||||
args.push('-g', `${Math.round(screen.x + left)},${Math.round(screen.y + top)} ${w}x${h}`, '-');
|
||||
// left/top are already global: the viewport origin was resolved against the
|
||||
// window's own position, which hyprctl reports in the same space.
|
||||
args.push('-g', `${Math.round(left)},${Math.round(top)} ${w}x${h}`, '-');
|
||||
|
||||
return run('grim', args);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user