LoginWithQR: Fixes
This commit is contained in:
parent
0d469206f6
commit
2ae1062a28
4 changed files with 158 additions and 335 deletions
|
|
@ -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();
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue