LoginWithQR: Fixes

This commit is contained in:
thororen1234 2026-01-30 08:02:43 -05:00
parent 0d469206f6
commit 2ae1062a28
No known key found for this signature in database
4 changed files with 158 additions and 335 deletions

View file

@ -7,41 +7,43 @@
import { plugins } from "@api/PluginManager";
import { definePluginSettings } from "@api/Settings";
import { Paragraph } from "@components/Paragraph";
import SettingsPlugin, { settingsSectionMap } from "@plugins/_core/settings";
import { EquicordDevs } from "@utils/constants";
import { getIntlMessage } from "@utils/discord";
import definePlugin, { OptionType } from "@utils/types";
import { Button, Menu } from "@webpack/common";
import { ReactElement } from "react";
import { Button } from "@webpack/common";
import { preload, unload } from "./images";
import { cl } from "./ui";
import { QrCodeIcon } from "./ui";
import openQrModal from "./ui/modals/QrModal";
const settings = definePluginSettings({
scanQr: {
type: OptionType.COMPONENT,
description: "Scan a QR code",
component() {
if (!plugins.LoginWithQR.started)
return (
<Paragraph>
Enable the plugin and restart your client to scan a login QR code
</Paragraph>
);
return (
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
{getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")}
</Button>
);
},
},
});
export default definePlugin({
name: "LoginWithQR",
description: "Allows you to login to another device by scanning a login QR code, just like on mobile!",
authors: [EquicordDevs.nexpid],
settings: definePluginSettings({
scanQr: {
type: OptionType.COMPONENT,
description: "Scan a QR code",
component() {
if (!plugins.LoginWithQR.started)
return (
<Paragraph>
Enable the plugin and restart your client to scan a login QR code
</Paragraph>
);
return (
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
{getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")}
</Button>
);
},
},
}),
settings,
patches: [
// Prevent paste event from firing when the QRModal is open
@ -52,69 +54,30 @@ export default definePlugin({
replace: "handleGlobalPaste:(...args)=>!$self.qrModalOpen&&$1(...args)",
},
},
// Insert a Scan QR Code button in the My Account tab
/* broken
{
find: "UserSettingsAccountProfileCard",
replacement: {
// Find the Edit User Profile button and insert our custom button.
// A bit jank, but whatever
match: /,(\(.{1,90}#{intl::USER_SETTINGS_EDIT_USER_PROFILE}\),onClick:\i\}\))/,
replace: ",$self.insertScanQrButton($1)",
},
},
*/
// Insert a Scan QR Code MenuItem in the Swith Accounts popout
{
find: 'id:"manage-accounts"',
replacement: {
match: /(id:"manage-accounts",.*?)}\)\)(,\i)/,
replace: "$1}),$self.ScanQrMenuItem)$2"
}
},
// Insert a Scan QR Code button in the Settings sheet
{
find: ".isInputProfileCustom()",
replacement: {
match: /\.CONNECTIONS/,
replace: "$&,\"SCAN_QR_CODE\""
}
},
// Insert a Scan QR Code button in the Settings sheet (part 2)
{
find: ".PRIVACY_ENCRYPTION_VERIFIED_DEVICES_V2]",
replacement: {
match: /\.PRIVACY_SAFETY_ALERTS_V2]:{.*?},/,
replace: "$&\"SCAN_QR_CODE\":$self.ScanQrSettingsSheet,"
}
}
],
qrModalOpen: false,
insertScanQrButton: (button: ReactElement) => (
<div className={cl("settings-btns")}>
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
{getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")}
</Button>
{button}
</div>
),
get ScanQrMenuItem() {
return <Menu.MenuItem id="scan-qr" label={getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")} action={openQrModal} />;
},
get ScanQrSettingsSheet() {
return {
section: getIntlMessage("USER_SETTINGS_SCAN_QR_CODE"),
onClick: openQrModal,
searchableTitles: [getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")],
label: getIntlMessage("USER_SETTINGS_SCAN_QR_CODE"),
ariaLabel: getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")
};
},
start() {
const { customEntries, customSections } = SettingsPlugin;
customEntries.push({
key: "equicord_login_with_qr",
title: getIntlMessage("USER_SETTINGS_SCAN_QR_CODE"),
Component: openQrModal,
Icon: QrCodeIcon
});
customSections.push(() => ({
section: "EquicordLoginWithQR",
label: getIntlMessage("USER_SETTINGS_SCAN_QR_CODE"),
searchableTitles: [getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")],
element: openQrModal,
id: "LoginWithQR",
}));
settingsSectionMap.push(["EquicordLoginWithQR", "equicord_login_with_qr"]);
preload();
},

View file

@ -5,20 +5,14 @@
*/
import { BaseText } from "@components/BaseText";
import { wrapTab } from "@components/settings";
import loginWithQR from "@equicordplugins/loginWithQR";
import { images } from "@equicordplugins/loginWithQR/images";
import { getIntlMessage } from "@utils/discord";
import {
ModalContent,
ModalHeader,
ModalProps,
ModalRoot,
ModalSize,
openModal,
} from "@utils/modal";
import { findByPropsLazy } from "@webpack";
import {
Button,
RestAPI,
useEffect,
useRef,
@ -33,7 +27,6 @@ import openVerifyModal from "./VerifyModal";
enum LoginStateType {
Idle,
Loading,
Camera,
}
interface Preview {
@ -51,7 +44,6 @@ interface QrModalProps {
media: HTMLImageElement | HTMLVideoElement | null,
location?: QRCode["location"]
) => Promise<void>;
closeMain: () => void;
}
type QrModalPropsRef = MutableRefObject<QrModalProps>;
@ -92,7 +84,6 @@ const verifyUrl = async (
body: { handshake_token: handshake },
});
},
modalProps.closeMain
);
};
@ -245,7 +236,6 @@ function QrModal(props: ModalProps) {
setTimeout(res, 500 + 300 + 300);
}
}),
closeMain: props.onClose,
});
useEffect(() => {
@ -284,243 +274,123 @@ function QrModal(props: ModalProps) {
return () => document.removeEventListener("paste", callback);
}, [state]);
useEffect(() => {
if (state !== LoginStateType.Camera) return;
const video = document.createElement("video");
video.width = 1280;
video.height = 720;
const canvas = document.createElement("canvas");
canvas.width = video.width;
canvas.height = video.height;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
let stream: MediaStream;
let snapshotTimeout: number;
let stopped = false;
const stop = (stream: MediaStream) => (
stream.getTracks().forEach(track => track.stop()),
modalProps.current.exit(null)
);
const snapshot = () => {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const { data, width, height } = ctx.getImageData(
0,
0,
canvas.width,
canvas.height
);
const code = jsQR(data, width, height);
const token = code?.data.match(tokenRegex)?.[1];
if (token) {
const img = new Image();
img.addEventListener("load", () =>
modalProps.current
.setPreview(img, code.location)
.then(
() => (
img.remove(),
verifyUrl(token, modalProps).catch(e =>
modalProps.current.exit(e?.message)
)
)
)
);
canvas.toBlob(blob => blob && (img.src = URL.createObjectURL(blob)));
} else snapshotTimeout = setTimeout(snapshot, 1e3) as any;
};
navigator.mediaDevices
.getUserMedia({
audio: false,
video: {
deviceId: getVideoDeviceId(),
width: canvas.width,
height: canvas.height,
frameRate: 30,
},
})
.then(str => {
if (stopped) return stop(str);
stream = str;
video.srcObject = str;
video.addEventListener("loadedmetadata", () => {
if (stopped) return stop(str);
video.play();
modalProps.current.setPreview(video);
snapshot();
});
})
.catch(() => setState(LoginStateType.Idle));
return () => {
stopped = true;
clearTimeout(snapshotTimeout);
if (stream) stop(stream);
video.remove();
canvas.remove();
};
}, [state]);
return (
<ModalRoot size={ModalSize.DYNAMIC} {...props}>
<ModalHeader separator={false} className={cl("modal-header")}>
<BaseText
size="lg"
weight="semibold"
color="text-strong"
tag="h1"
style={{ flexGrow: 1 }}
>
{getIntlMessage("USER_SETTINGS_SCAN_QR_CODE")}
</BaseText>
</ModalHeader>
<ModalContent scrollbarType="none">
<div
className={cl(
"modal-filepaste",
state === LoginStateType.Camera &&
!preview?.source &&
"modal-filepaste-disabled",
preview?.source && "modal-filepaste-preview",
preview?.crosses && "modal-filepaste-crosses"
)}
onClick={() =>
state === LoginStateType.Idle && inputRef.current?.click()
}
onDragEnter={e =>
e.currentTarget.classList.add(cl("modal-filepaste-drop"))
}
onDragLeave={e =>
e.currentTarget.classList.remove(cl("modal-filepaste-drop"))
}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove(cl("modal-filepaste-drop"));
<div className={cl("modal-container")}>
<div
className={cl(
"modal-filepaste",
preview?.source && "modal-filepaste-preview",
preview?.crosses && "modal-filepaste-crosses"
)}
onClick={() =>
state === LoginStateType.Idle && inputRef.current?.click()
}
onDragEnter={e =>
e.currentTarget.classList.add(cl("modal-filepaste-drop"))
}
onDragLeave={e =>
e.currentTarget.classList.remove(cl("modal-filepaste-drop"))
}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove(cl("modal-filepaste-drop"));
if (state !== LoginStateType.Idle) return;
if (state !== LoginStateType.Idle) return;
for (const item of e.dataTransfer.files) {
if (item.type.startsWith("image/")) {
setState(LoginStateType.Loading);
handleProcessImage(item, modalProps);
break;
}
for (const item of e.dataTransfer.files) {
if (item.type.startsWith("image/")) {
setState(LoginStateType.Loading);
handleProcessImage(item, modalProps);
break;
}
}}
role="button"
style={
preview?.size
? {
width: `${preview.size.width}rem`,
height: `${preview.size.height}rem`,
}
: {}
}
>
{preview?.source ? (
<div
style={{
width: "100%",
height: "100%",
position: "relative",
["--scale" as any]: preview.crosses
? Math.max(preview.crosses[0].size * 0.9, 1)
: undefined,
["--offset-x" as any]: preview.crosses
? `${-(preview.crosses.reduce((i, { x }) => i + x, 0) /
preview.crosses.length -
50)}%`
: undefined,
["--offset-y" as any]: preview.crosses
? `${-(preview.crosses.reduce((i, { y }) => i + y, 0) /
preview.crosses.length -
50)}%`
: undefined,
}}
className={cl(preview?.crosses && "preview-crosses")}
>
{preview.source}
{preview.crosses?.map(({ x, y, rot, size }) => (
<span
key={cl("preview-cross")}
className={cl("preview-cross")}
style={{
left: `${x}%`,
top: `${y}%`,
["--size" as any]: `${size}rem`,
["--rot" as any]: `${rot}deg`,
}}
>
<img src={images.cross} draggable={false} />
</span>
))}
</div>
) : state === LoginStateType.Loading ? (
<Spinner type={SpinnerTypes.WANDERING_CUBES} />
) : error.current ? (
<BaseText size="md" weight="semibold" color="text-danger">
{error.current}
</BaseText>
) : state === LoginStateType.Camera ? (
}}
role="button"
style={
preview?.size
? {
width: `${preview.size.width}rem`,
height: `${preview.size.height}rem`,
}
: {}
}
>
{preview?.source ? (
<div
style={{
width: "100%",
height: "100%",
position: "relative",
["--scale" as any]: preview.crosses
? Math.max(preview.crosses[0].size * 0.9, 1)
: undefined,
["--offset-x" as any]: preview.crosses
? `${-(preview.crosses.reduce((i, { x }) => i + x, 0) /
preview.crosses.length -
50)}%`
: undefined,
["--offset-y" as any]: preview.crosses
? `${-(preview.crosses.reduce((i, { y }) => i + y, 0) /
preview.crosses.length -
50)}%`
: undefined,
}}
className={cl(preview?.crosses && "preview-crosses")}
>
{preview.source}
{preview.crosses?.map(({ x, y, rot, size }) => (
<span
key={cl("preview-cross")}
className={cl("preview-cross")}
style={{
left: `${x}%`,
top: `${y}%`,
["--size" as any]: `${size}rem`,
["--rot" as any]: `${rot}deg`,
}}
>
<img src={images.cross} draggable={false} />
</span>
))}
</div>
) : state === LoginStateType.Loading ? (
<Spinner type={SpinnerTypes.WANDERING_CUBES} />
) : error.current ? (
<BaseText size="md" weight="semibold" color="text-danger">
{error.current}
</BaseText>
) : (
<>
<BaseText size="md" weight="semibold" color="text-strong">
Scanning...
Drag and drop an image here, or click to select an image
</BaseText>
) : (
<>
<BaseText size="md" weight="semibold" color="text-strong">
Drag and drop an image here, or click to select an image
</BaseText>
<BaseText size="sm" weight="medium" color="text-muted">
Or paste an image from your clipboard!
</BaseText>
<br />
<QrCodeIcon />
</>
)}
</div>
<input
type="file"
accept="image/*"
onChange={e => {
if (!e.target.files || state !== LoginStateType.Idle) return;
<BaseText size="sm" weight="medium" color="text-muted">
Or paste an image from your clipboard!
</BaseText>
<br />
<QrCodeIcon />
</>
)}
</div>
<input
type="file"
accept="image/*"
onChange={e => {
if (!e.target.files || state !== LoginStateType.Idle) return;
for (const item of e.target.files) {
if (item.type.startsWith("image/")) {
setState(LoginStateType.Loading);
handleProcessImage(item, modalProps);
break;
}
for (const item of e.target.files) {
if (item.type.startsWith("image/")) {
setState(LoginStateType.Loading);
handleProcessImage(item, modalProps);
break;
}
}}
ref={inputRef}
style={{ display: "none" }}
/>
<Button
size={Button.Sizes.MEDIUM}
className={cl("modal-button")}
disabled={state === LoginStateType.Loading}
onClick={() => {
if (state === LoginStateType.Idle) setState(LoginStateType.Camera);
else if (state === LoginStateType.Camera)
modalProps.current.exit(null);
}}
>
{state === LoginStateType.Camera
? "Stop scanning"
: "Scan using webcamera"}
</Button>
</ModalContent>
</ModalRoot>
}
}}
ref={inputRef}
style={{ display: "none" }}
/>
</div>
);
}
export default function openQrModal() {
return openModal(props => <QrModal {...props} />);
}
export default wrapTab(QrModal, "Scan QR Code");

View file

@ -37,12 +37,10 @@ enum VerifyState {
function VerifyModal({
token,
onAbort,
closeMain,
...props
}: {
token: string | null;
onAbort: () => void;
closeMain: () => void;
} & ModalProps) {
const [state, setState] = useState(
!token ? VerifyState.NotFound : VerifyState.Verifying
@ -77,7 +75,6 @@ function VerifyModal({
},
})
.then(() => {
closeMain();
setState(VerifyState.LoggedIn);
})
.catch(() => setState(VerifyState.NotFound))
@ -229,14 +226,12 @@ function VerifyModal({
export default function openVerifyModal(
token: string | null,
onAbort: () => void,
closeMain: () => void
) {
return openModal(props => (
<VerifyModal
{...props}
token={token}
onAbort={onAbort}
closeMain={closeMain}
/>
));
}

View file

@ -1,7 +1,3 @@
.qrlogin-modal-header {
padding: 16px;
}
.qrlogin-modal-filepaste {
width: 30rem;
height: 13rem;
@ -67,11 +63,6 @@
opacity: 0;
}
.qrlogin-modal-button {
margin: 1rem 0;
width: 100%;
}
.qrlogin-device-content {
padding: 24px;
display: flex;
@ -111,6 +102,10 @@
gap: 8px;
}
.qrlogin-modal-container {
margin-left: 100px;
}
[data-tab-id="Scan QR Code"]::before {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-mask: var(--si-scan-qr-code) center/contain no-repeat !important;
@ -133,4 +128,4 @@
to {
transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
}
}
}