mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-17 02:17:03 -04:00
feat: update LoginWithQR (#17)
* feat: update LoginWithQR * Apperantly Catbox.moe doesn't like me --------- Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
This commit is contained in:
parent
e3e255f5e7
commit
7c8e14f3ec
7 changed files with 356 additions and 105 deletions
41
src/equicordplugins/loginWithQR/images.ts
Normal file
41
src/equicordplugins/loginWithQR/images.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2024 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const images = {
|
||||||
|
cross: "https://i.imgur.com/XxRnu3b.png",
|
||||||
|
deviceImage: {
|
||||||
|
success:
|
||||||
|
"https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_succeeded.png",
|
||||||
|
notFound:
|
||||||
|
"https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_not_found.png",
|
||||||
|
loading:
|
||||||
|
"https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_loaded.png",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export let unload: () => void;
|
||||||
|
export function preload() {
|
||||||
|
const elements = new Array<HTMLElement>();
|
||||||
|
|
||||||
|
// Normally, we'd use link:preload (or link:prefetch), but
|
||||||
|
// Discord blocks third party prefetch domains and link:preload
|
||||||
|
// throws a warning, so we'll just put the images in the head
|
||||||
|
const browse = (dir: Record<string, any>) => {
|
||||||
|
for (const entry of Object.values(dir)) {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
const img = new Image();
|
||||||
|
img.setAttribute("data-purpose", "prefetch");
|
||||||
|
img.setAttribute("data-added-by", "LoginWithQR");
|
||||||
|
img.src = entry;
|
||||||
|
document.head.appendChild(img);
|
||||||
|
elements.push(img);
|
||||||
|
} else if (typeof entry === "object") browse(entry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
browse(images);
|
||||||
|
|
||||||
|
unload = () => elements.forEach(element => element.remove());
|
||||||
|
}
|
|
@ -5,19 +5,22 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { definePluginSettings } from "@api/Settings";
|
import { definePluginSettings } from "@api/Settings";
|
||||||
import { EquicordDevs } from "@utils/constants";
|
|
||||||
import definePlugin, { OptionType } from "@utils/types";
|
import definePlugin, { OptionType } from "@utils/types";
|
||||||
import { findByProps } from "@webpack";
|
import { findByProps } from "@webpack";
|
||||||
import { Button, Forms, i18n, Menu, TabBar } from "@webpack/common";
|
import { Button, Forms, i18n, Menu, TabBar } from "@webpack/common";
|
||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
import { preload, unload } from "./images";
|
||||||
import { cl, QrCodeCameraIcon } from "./ui";
|
import { cl, QrCodeCameraIcon } from "./ui";
|
||||||
import openQrModal from "./ui/modals/QrModal";
|
import openQrModal from "./ui/modals/QrModal";
|
||||||
|
import { EquicordDevs } from "@utils/constants";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
name: "LoginWithQR",
|
name: "LoginWithQR",
|
||||||
description: "Allows you to login to another device by scanning a login QR code, just like on mobile!",
|
description:
|
||||||
|
"Allows you to login to another device by scanning a login QR code, just like on mobile!",
|
||||||
authors: [EquicordDevs.nexpid],
|
authors: [EquicordDevs.nexpid],
|
||||||
|
|
||||||
settings: definePluginSettings({
|
settings: definePluginSettings({
|
||||||
scanQr: {
|
scanQr: {
|
||||||
type: OptionType.COMPONENT,
|
type: OptionType.COMPONENT,
|
||||||
|
@ -26,7 +29,7 @@ export default definePlugin({
|
||||||
if (!Vencord.Plugins.plugins.LoginWithQR.started)
|
if (!Vencord.Plugins.plugins.LoginWithQR.started)
|
||||||
return (
|
return (
|
||||||
<Forms.FormText>
|
<Forms.FormText>
|
||||||
Scan a login QR code
|
Enable the plugin and restart your client to scan a login QR code
|
||||||
</Forms.FormText>
|
</Forms.FormText>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -38,6 +41,7 @@ export default definePlugin({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
patches: [
|
patches: [
|
||||||
// Prevent paste event from firing when the QRModal is open
|
// Prevent paste event from firing when the QRModal is open
|
||||||
{
|
{
|
||||||
|
@ -80,7 +84,9 @@ export default definePlugin({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
qrModalOpen: false,
|
qrModalOpen: false,
|
||||||
|
|
||||||
insertScanQrButton: (button: ReactElement) => (
|
insertScanQrButton: (button: ReactElement) => (
|
||||||
<div className={cl("settings-btns")}>
|
<div className={cl("settings-btns")}>
|
||||||
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
|
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
|
||||||
|
@ -89,8 +95,11 @@ export default definePlugin({
|
||||||
{button}
|
{button}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|
||||||
get ScanQrMenuItem() {
|
get ScanQrMenuItem() {
|
||||||
const { menuItemFocused, subMenuIcon } = findByProps("menuItemFocused") as Record<string, string>;
|
const { menuItemFocused, subMenuIcon } = findByProps(
|
||||||
|
"menuItemFocused"
|
||||||
|
) as Record<string, string>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.MenuGroup>
|
<Menu.MenuGroup>
|
||||||
|
@ -106,9 +115,19 @@ export default definePlugin({
|
||||||
</Menu.MenuGroup>
|
</Menu.MenuGroup>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
ScanQrTabBarComponent: () => (
|
ScanQrTabBarComponent: () => (
|
||||||
<TabBar.Item id="Scan QR Code" onClick={openQrModal}>
|
<TabBar.Item id="Scan QR Code" onClick={openQrModal}>
|
||||||
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
||||||
</TabBar.Item>
|
</TabBar.Item>
|
||||||
),
|
),
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// Preload images
|
||||||
|
preload();
|
||||||
|
},
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
unload?.();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
12
src/equicordplugins/loginWithQR/jsqr.d.ts
vendored
Normal file
12
src/equicordplugins/loginWithQR/jsqr.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2024 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare module "jsqr" {
|
||||||
|
import jsQR, { QRCode } from "jsqr/dist";
|
||||||
|
|
||||||
|
export default jsQR;
|
||||||
|
export { QRCode };
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import "./style.css";
|
import "./styles.css";
|
||||||
|
|
||||||
import { classNameFactory } from "@api/Styles";
|
import { classNameFactory } from "@api/Styles";
|
||||||
import { proxyLazy } from "@utils/lazy";
|
import { proxyLazy } from "@utils/lazy";
|
||||||
|
|
|
@ -22,8 +22,10 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "@webpack/common";
|
} from "@webpack/common";
|
||||||
import jsQR from "jsqr";
|
import jsQR, { QRCode } from "jsqr";
|
||||||
|
import { MutableRefObject, ReactElement } from "react";
|
||||||
|
|
||||||
|
import { images } from "../../images";
|
||||||
import { cl, Spinner, SpinnerTypes } from "..";
|
import { cl, Spinner, SpinnerTypes } from "..";
|
||||||
import openVerifyModal from "./VerifyModal";
|
import openVerifyModal from "./VerifyModal";
|
||||||
|
|
||||||
|
@ -33,14 +35,41 @@ enum LoginStateType {
|
||||||
Camera,
|
Camera,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Preview {
|
||||||
|
source: ReactElement;
|
||||||
|
size: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
crosses?: { x: number; y: number; rot: number; size: number; }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QrModalProps {
|
||||||
|
exit: (err: string | null) => void;
|
||||||
|
setPreview: (
|
||||||
|
media: HTMLImageElement | HTMLVideoElement | null,
|
||||||
|
location?: QRCode["location"]
|
||||||
|
) => Promise<void>;
|
||||||
|
closeMain: () => void;
|
||||||
|
}
|
||||||
|
type QrModalPropsRef = MutableRefObject<QrModalProps>;
|
||||||
|
|
||||||
|
const limitSize = (width: number, height: number) => {
|
||||||
|
if (width > height) {
|
||||||
|
const w = Math.min(width, 1280);
|
||||||
|
return { w, h: (height / width) * w };
|
||||||
|
} else {
|
||||||
|
const h = Math.min(height, 1280);
|
||||||
|
return { h, w: (width / height) * h };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { getVideoDeviceId } = findByPropsLazy("getVideoDeviceId");
|
const { getVideoDeviceId } = findByPropsLazy("getVideoDeviceId");
|
||||||
|
|
||||||
const tokenRegex = /^https:\/\/discord\.com\/ra\/(.+)$/;
|
const tokenRegex = /^https:\/\/discord\.com\/ra\/([\w-]+)$/;
|
||||||
|
|
||||||
const verifyUrl = async (
|
const verifyUrl = async (
|
||||||
token: string,
|
token: string,
|
||||||
exit: (err: string | null) => void,
|
{ current: modalProps }: QrModalPropsRef
|
||||||
closeMain: () => void
|
|
||||||
) => {
|
) => {
|
||||||
// yay
|
// yay
|
||||||
let handshake: string | null = null;
|
let handshake: string | null = null;
|
||||||
|
@ -52,36 +81,41 @@ const verifyUrl = async (
|
||||||
if (res.ok && res.status === 200) handshake = res.body?.handshake_token;
|
if (res.ok && res.status === 200) handshake = res.body?.handshake_token;
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
|
modalProps.setPreview(null);
|
||||||
openVerifyModal(
|
openVerifyModal(
|
||||||
handshake,
|
handshake,
|
||||||
() => {
|
() => {
|
||||||
exit(null);
|
modalProps.exit(null);
|
||||||
RestAPI.post({
|
RestAPI.post({
|
||||||
url: "/users/@me/remote-auth/cancel",
|
url: "/users/@me/remote-auth/cancel",
|
||||||
body: { handshake_token: handshake },
|
body: { handshake_token: handshake },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
closeMain
|
modalProps.closeMain
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProcessImage = (
|
const handleProcessImage = (file: File, modalPropsRef: QrModalPropsRef) => {
|
||||||
file: File,
|
const { current: modalProps } = modalPropsRef;
|
||||||
exit: (err: string | null) => void,
|
|
||||||
closeMain: () => void
|
|
||||||
) => {
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.addEventListener("load", () => {
|
reader.addEventListener("load", () => {
|
||||||
if (!reader.result) return;
|
if (!reader.result) return;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.addEventListener("load", () => {
|
img.addEventListener("load", () => {
|
||||||
|
modalProps.setPreview(img);
|
||||||
|
const { w, h } = limitSize(img.width, img.height);
|
||||||
|
img.width = w;
|
||||||
|
img.height = h;
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = img.width;
|
canvas.width = img.width;
|
||||||
canvas.height = img.height;
|
canvas.height = img.height;
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d")!;
|
||||||
ctx.drawImage(img, 0, 0);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const { data, width, height } = ctx.getImageData(
|
const { data, width, height } = ctx.getImageData(
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
|
@ -92,10 +126,15 @@ const handleProcessImage = (
|
||||||
|
|
||||||
const token = code?.data.match(tokenRegex)?.[1];
|
const token = code?.data.match(tokenRegex)?.[1];
|
||||||
if (token)
|
if (token)
|
||||||
verifyUrl(token, exit, closeMain).catch(e =>
|
modalProps
|
||||||
exit(e?.message)
|
.setPreview(img, code.location)
|
||||||
);
|
.then(() =>
|
||||||
else exit(null);
|
verifyUrl(token, modalPropsRef).catch(e =>
|
||||||
|
modalProps.exit(e?.message)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
else modalProps.exit(null);
|
||||||
|
|
||||||
canvas.remove();
|
canvas.remove();
|
||||||
});
|
});
|
||||||
img.src = reader.result as string;
|
img.src = reader.result as string;
|
||||||
|
@ -105,9 +144,103 @@ const handleProcessImage = (
|
||||||
|
|
||||||
function QrModal(props: ModalProps) {
|
function QrModal(props: ModalProps) {
|
||||||
const [state, setState] = useState(LoginStateType.Idle);
|
const [state, setState] = useState(LoginStateType.Idle);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const error = useRef<string | null>(null);
|
const error = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const modalProps = useRef<QrModalProps>({
|
||||||
|
exit: err => {
|
||||||
|
error.current = err;
|
||||||
|
setState(LoginStateType.Idle);
|
||||||
|
setPreview(null);
|
||||||
|
},
|
||||||
|
setPreview: (media, location) =>
|
||||||
|
new Promise(res => {
|
||||||
|
if (!media) return res(setPreview(null));
|
||||||
|
|
||||||
|
const size = {} as Preview["size"];
|
||||||
|
if (media.width > media.height) {
|
||||||
|
size.width = 34;
|
||||||
|
size.height = (media.height / media.width) * 34;
|
||||||
|
} else {
|
||||||
|
size.height = 34;
|
||||||
|
size.width = (media.width / media.height) * 34;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source: ReactElement;
|
||||||
|
if (media instanceof HTMLImageElement)
|
||||||
|
source = (
|
||||||
|
<img src={media.src} style={{ width: "100%", height: "100%" }} />
|
||||||
|
);
|
||||||
|
else
|
||||||
|
source = (
|
||||||
|
<video
|
||||||
|
controls={false}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
ref={e =>
|
||||||
|
e &&
|
||||||
|
((e.srcObject = media.srcObject), !media.paused && e.play())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!location) return res(setPreview({ source, size }));
|
||||||
|
else {
|
||||||
|
const combinations = [
|
||||||
|
[location.topLeftCorner, location.bottomRightCorner],
|
||||||
|
[location.topRightCorner, location.bottomLeftCorner],
|
||||||
|
];
|
||||||
|
|
||||||
|
const crossSize =
|
||||||
|
(Math.sqrt(
|
||||||
|
Math.abs(location.topLeftCorner.x - location.topRightCorner.x) **
|
||||||
|
2 +
|
||||||
|
Math.abs(
|
||||||
|
location.topLeftCorner.y - location.topRightCorner.y
|
||||||
|
) **
|
||||||
|
2
|
||||||
|
) +
|
||||||
|
Math.sqrt(
|
||||||
|
Math.abs(
|
||||||
|
location.topRightCorner.x - location.bottomRightCorner.x
|
||||||
|
) **
|
||||||
|
2 +
|
||||||
|
Math.abs(
|
||||||
|
location.topRightCorner.y - location.bottomRightCorner.y
|
||||||
|
) **
|
||||||
|
2
|
||||||
|
)) /
|
||||||
|
3 /
|
||||||
|
media.height;
|
||||||
|
|
||||||
|
const crosses = [] as NonNullable<Preview["crosses"]>;
|
||||||
|
for (const combination of combinations) {
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const current = combination[i];
|
||||||
|
const opposite = combination[1 - i];
|
||||||
|
|
||||||
|
const rot =
|
||||||
|
(Math.atan2(opposite.y - current.y, opposite.x - current.x) -
|
||||||
|
Math.PI / 4) *
|
||||||
|
(180 / Math.PI);
|
||||||
|
|
||||||
|
crosses.push({
|
||||||
|
x: (current.x / media.width) * 100,
|
||||||
|
y: (current.y / media.height) * 100,
|
||||||
|
rot,
|
||||||
|
size: Math.min(crossSize * size.height, 7),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreview({ source, size, crosses });
|
||||||
|
setTimeout(res, 500 + 300 + 300);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
closeMain: props.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const plugin = Vencord.Plugins.plugins.LoginWithQR as any;
|
const plugin = Vencord.Plugins.plugins.LoginWithQR as any;
|
||||||
|
|
||||||
|
@ -123,13 +256,19 @@ function QrModal(props: ModalProps) {
|
||||||
for (const item of e.clipboardData.items) {
|
for (const item of e.clipboardData.items) {
|
||||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||||
setState(LoginStateType.Loading);
|
setState(LoginStateType.Loading);
|
||||||
handleProcessImage(
|
handleProcessImage(item.getAsFile()!, modalProps);
|
||||||
item.getAsFile()!,
|
break;
|
||||||
err => (
|
} else if (item.kind === "string" && item.type === "text/plain") {
|
||||||
(error.current = err), setState(LoginStateType.Idle)
|
item.getAsString(text => {
|
||||||
),
|
setState(LoginStateType.Loading);
|
||||||
props.onClose
|
|
||||||
);
|
const token = text.match(tokenRegex)?.[1];
|
||||||
|
if (token)
|
||||||
|
verifyUrl(token, modalProps).catch(e =>
|
||||||
|
modalProps.current.exit(e?.message)
|
||||||
|
);
|
||||||
|
else modalProps.current.exit(null);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -143,15 +282,13 @@ function QrModal(props: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state !== LoginStateType.Camera) return;
|
if (state !== LoginStateType.Camera) return;
|
||||||
|
|
||||||
const exit = (err: string | null) => (
|
|
||||||
(error.current = err), setState(LoginStateType.Idle)
|
|
||||||
);
|
|
||||||
|
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
|
video.width = 1280;
|
||||||
|
video.height = 720;
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = 1280;
|
canvas.width = video.width;
|
||||||
canvas.height = 720;
|
canvas.height = video.height;
|
||||||
const ctx = canvas.getContext("2d")!;
|
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||||
|
|
||||||
let stream: MediaStream;
|
let stream: MediaStream;
|
||||||
let snapshotTimeout: number;
|
let snapshotTimeout: number;
|
||||||
|
@ -159,8 +296,9 @@ function QrModal(props: ModalProps) {
|
||||||
|
|
||||||
const stop = (stream: MediaStream) => (
|
const stop = (stream: MediaStream) => (
|
||||||
stream.getTracks().forEach(track => track.stop()),
|
stream.getTracks().forEach(track => track.stop()),
|
||||||
setState(LoginStateType.Idle)
|
modalProps.current.exit(null)
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapshot = () => {
|
const snapshot = () => {
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
const { data, width, height } = ctx.getImageData(
|
const { data, width, height } = ctx.getImageData(
|
||||||
|
@ -173,11 +311,21 @@ function QrModal(props: ModalProps) {
|
||||||
|
|
||||||
const token = code?.data.match(tokenRegex)?.[1];
|
const token = code?.data.match(tokenRegex)?.[1];
|
||||||
if (token) {
|
if (token) {
|
||||||
setState(LoginStateType.Loading);
|
const img = new Image();
|
||||||
verifyUrl(token, exit, props.onClose).catch(e =>
|
img.addEventListener("load", () =>
|
||||||
exit(e?.message)
|
modalProps.current
|
||||||
|
.setPreview(img, code.location)
|
||||||
|
.then(
|
||||||
|
() => (
|
||||||
|
img.remove(),
|
||||||
|
verifyUrl(token, modalProps).catch(e =>
|
||||||
|
modalProps.current.exit(e?.message)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
} else snapshotTimeout = setTimeout(snapshot, 1000) as any;
|
canvas.toBlob(blob => blob && (img.src = URL.createObjectURL(blob)));
|
||||||
|
} else snapshotTimeout = setTimeout(snapshot, 1e3) as any;
|
||||||
};
|
};
|
||||||
|
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
|
@ -187,7 +335,7 @@ function QrModal(props: ModalProps) {
|
||||||
deviceId: getVideoDeviceId(),
|
deviceId: getVideoDeviceId(),
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
frameRate: 5
|
frameRate: 30,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(str => {
|
.then(str => {
|
||||||
|
@ -197,6 +345,7 @@ function QrModal(props: ModalProps) {
|
||||||
video.srcObject = str;
|
video.srcObject = str;
|
||||||
video.addEventListener("loadedmetadata", () => {
|
video.addEventListener("loadedmetadata", () => {
|
||||||
video.play();
|
video.play();
|
||||||
|
modalProps.current.setPreview(video);
|
||||||
snapshot();
|
snapshot();
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -229,73 +378,78 @@ function QrModal(props: ModalProps) {
|
||||||
className={cl(
|
className={cl(
|
||||||
"modal-filepaste",
|
"modal-filepaste",
|
||||||
state === LoginStateType.Camera &&
|
state === LoginStateType.Camera &&
|
||||||
"modal-filepaste-disabled"
|
!preview?.source &&
|
||||||
|
"modal-filepaste-disabled",
|
||||||
|
preview?.source && "modal-filepaste-preview"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
state === LoginStateType.Idle &&
|
state === LoginStateType.Idle && inputRef.current?.click()
|
||||||
inputRef.current?.click()
|
|
||||||
}
|
}
|
||||||
onDragEnter={e =>
|
onDragEnter={e =>
|
||||||
e.currentTarget.classList.add(
|
e.currentTarget.classList.add(cl("modal-filepaste-drop"))
|
||||||
cl("modal-filepaste-drop")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onDragLeave={e =>
|
onDragLeave={e =>
|
||||||
e.currentTarget.classList.remove(
|
e.currentTarget.classList.remove(cl("modal-filepaste-drop"))
|
||||||
cl("modal-filepaste-drop")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.currentTarget.classList.remove(
|
e.currentTarget.classList.remove(cl("modal-filepaste-drop"));
|
||||||
cl("modal-filepaste-drop")
|
|
||||||
);
|
|
||||||
|
|
||||||
if (state !== LoginStateType.Idle) return;
|
if (state !== LoginStateType.Idle) return;
|
||||||
|
|
||||||
for (const item of e.dataTransfer.files) {
|
for (const item of e.dataTransfer.files) {
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith("image/")) {
|
||||||
setState(LoginStateType.Loading);
|
setState(LoginStateType.Loading);
|
||||||
handleProcessImage(
|
handleProcessImage(item, modalProps);
|
||||||
item,
|
|
||||||
err => (
|
|
||||||
(error.current = err),
|
|
||||||
setState(LoginStateType.Idle)
|
|
||||||
),
|
|
||||||
props.onClose
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
|
style={
|
||||||
|
preview?.size
|
||||||
|
? {
|
||||||
|
width: `${preview.size.width}rem`,
|
||||||
|
height: `${preview.size.height}rem`,
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{state === LoginStateType.Loading ? (
|
{preview?.source ? (
|
||||||
|
<div
|
||||||
|
style={{ width: "100%", height: "100%", position: "relative" }}
|
||||||
|
>
|
||||||
|
{preview.source}
|
||||||
|
{preview.crosses?.map(({ x, y, rot, size }, i) => (
|
||||||
|
<span
|
||||||
|
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} />
|
<Spinner type={SpinnerTypes.WANDERING_CUBES} />
|
||||||
) : error.current ? (
|
) : error.current ? (
|
||||||
<Text color="text-danger" variant="heading-md/semibold">
|
<Text color="text-danger" variant="heading-md/semibold">
|
||||||
{error.current}
|
{error.current}
|
||||||
</Text>
|
</Text>
|
||||||
) : state === LoginStateType.Camera ? (
|
) : state === LoginStateType.Camera ? (
|
||||||
<Text
|
<Text color="header-primary" variant="heading-md/semibold">
|
||||||
color="header-primary"
|
|
||||||
variant="heading-md/semibold"
|
|
||||||
>
|
|
||||||
Scanning...
|
Scanning...
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text color="header-primary" variant="heading-md/semibold">
|
||||||
color="header-primary"
|
Drag and drop an image here, or click to select an image
|
||||||
variant="heading-md/semibold"
|
|
||||||
>
|
|
||||||
Drag and drop an image here, or click to select
|
|
||||||
an image
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text color="text-muted" variant="heading-sm/medium">
|
||||||
color="text-muted"
|
|
||||||
variant="heading-sm/medium"
|
|
||||||
>
|
|
||||||
Or paste an image from your clipboard!
|
Or paste an image from your clipboard!
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
@ -305,20 +459,12 @@ function QrModal(props: ModalProps) {
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
if (!e.target.files || state !== LoginStateType.Idle)
|
if (!e.target.files || state !== LoginStateType.Idle) return;
|
||||||
return;
|
|
||||||
|
|
||||||
for (const item of e.target.files) {
|
for (const item of e.target.files) {
|
||||||
if (item.type.startsWith("image/")) {
|
if (item.type.startsWith("image/")) {
|
||||||
setState(LoginStateType.Loading);
|
setState(LoginStateType.Loading);
|
||||||
handleProcessImage(
|
handleProcessImage(item, modalProps);
|
||||||
item,
|
|
||||||
err => (
|
|
||||||
(error.current = err),
|
|
||||||
setState(LoginStateType.Idle)
|
|
||||||
),
|
|
||||||
props.onClose
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -331,10 +477,9 @@ function QrModal(props: ModalProps) {
|
||||||
className={cl("modal-button")}
|
className={cl("modal-button")}
|
||||||
disabled={state === LoginStateType.Loading}
|
disabled={state === LoginStateType.Loading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (state === LoginStateType.Idle)
|
if (state === LoginStateType.Idle) setState(LoginStateType.Camera);
|
||||||
setState(LoginStateType.Camera);
|
|
||||||
else if (state === LoginStateType.Camera)
|
else if (state === LoginStateType.Camera)
|
||||||
setState(LoginStateType.Idle);
|
modalProps.current.exit(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{state === LoginStateType.Camera
|
{state === LoginStateType.Camera
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from "@webpack/common";
|
} from "@webpack/common";
|
||||||
|
|
||||||
|
import { images } from "../../images";
|
||||||
import { cl } from "..";
|
import { cl } from "..";
|
||||||
|
|
||||||
const { Controller } = findByPropsLazy("Controller");
|
const { Controller } = findByPropsLazy("Controller");
|
||||||
|
@ -33,12 +34,6 @@ enum VerifyState {
|
||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AbortStatus {
|
|
||||||
Abort,
|
|
||||||
Fail,
|
|
||||||
Success,
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerifyModal({
|
function VerifyModal({
|
||||||
token,
|
token,
|
||||||
onAbort,
|
onAbort,
|
||||||
|
@ -126,7 +121,7 @@ function VerifyModal({
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
className={cl("device-image")}
|
className={cl("device-image")}
|
||||||
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_succeeded.png"
|
src={images.deviceImage.success}
|
||||||
key="img-success"
|
key="img-success"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
@ -150,7 +145,7 @@ function VerifyModal({
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
className={cl("device-image")}
|
className={cl("device-image")}
|
||||||
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_not_found.png"
|
src={images.deviceImage.notFound}
|
||||||
key="img-not_found"
|
key="img-not_found"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
@ -174,7 +169,7 @@ function VerifyModal({
|
||||||
<>
|
<>
|
||||||
<img
|
<img
|
||||||
className={cl("device-image")}
|
className={cl("device-image")}
|
||||||
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_loaded.png"
|
src={images.deviceImage.loading}
|
||||||
key="img-loaded"
|
key="img-loaded"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
@ -187,8 +182,7 @@ function VerifyModal({
|
||||||
{i18n.Messages.QR_CODE_LOGIN_CONFIRM}
|
{i18n.Messages.QR_CODE_LOGIN_CONFIRM}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="text-md/semibold" color="text-danger">
|
<Text variant="text-md/semibold" color="text-danger">
|
||||||
Never scan a login QR code from another user or
|
Never scan a login QR code from another user or application.
|
||||||
application.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
size={Button.Sizes.LARGE}
|
size={Button.Sizes.LARGE}
|
||||||
|
@ -235,6 +229,11 @@ export default function openVerifyModal(
|
||||||
closeMain: () => void
|
closeMain: () => void
|
||||||
) {
|
) {
|
||||||
return openModal(props => (
|
return openModal(props => (
|
||||||
<VerifyModal {...props} token={token} onAbort={onAbort} closeMain={closeMain} />
|
<VerifyModal
|
||||||
|
{...props}
|
||||||
|
token={token}
|
||||||
|
onAbort={onAbort}
|
||||||
|
closeMain={closeMain}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: 200ms background-color ease-in-out, 200ms border-color ease-in-out,
|
transition: 200ms background-color ease-in-out, 200ms border-color ease-in-out,
|
||||||
200ms opacity ease-in-out;
|
200ms opacity ease-in-out, 200ms border-width ease-in-out,
|
||||||
|
250ms width cubic-bezier(0.68, -0.6, 0.32, 1.6),
|
||||||
|
250ms height cubic-bezier(0.68, -0.6, 0.32, 1.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qrlogin-modal-filepaste * {
|
.qrlogin-modal-filepaste * {
|
||||||
|
@ -34,6 +36,29 @@
|
||||||
cursor: unset;
|
cursor: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qrlogin-modal-filepaste-preview {
|
||||||
|
border-width: 0;
|
||||||
|
background-color: #0005;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-preview-cross {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--rot));
|
||||||
|
|
||||||
|
--size: 0rem;
|
||||||
|
--rot: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-preview-cross img {
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
animation: 300ms cross ease-out forwards 500ms;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.qrlogin-modal-button {
|
.qrlogin-modal-button {
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -84,5 +109,15 @@
|
||||||
-webkit-mask: var(--si-scan-qr-code) center/contain no-repeat !important;
|
-webkit-mask: var(--si-scan-qr-code) center/contain no-repeat !important;
|
||||||
mask: var(--si-scan-qr-code) center/contain no-repeat !important;
|
mask: var(--si-scan-qr-code) center/contain no-repeat !important;
|
||||||
|
|
||||||
--si-scan-qr-code: url("data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgcm9sZT0iaW1nIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0icmVkIiBkPSJNNCA2YzAtMS4xLjktMiAyLTJoM2ExIDEgMCAwIDAgMC0ySDZhNCA0IDAgMCAwLTQgNHYzYTEgMSAwIDAgMCAyIDBWNlpNNCAxOGMwIDEuMS45IDIgMiAyaDNhMSAxIDAgMSAxIDAgMkg2YTQgNCAwIDAgMS00LTR2LTNhMSAxIDAgMSAxIDIgMHYzWk0yMCA2YTIgMiAwIDAgMC0yLTJoLTNhMSAxIDAgMSAxIDAtMmgzYTQgNCAwIDAgMSA0IDR2M2ExIDEgMCAxIDEtMiAwVjZaIiBjbGFzcz0iIj48L3BhdGg+PHBhdGggZmlsbD0icmVkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01IDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAySDdhMiAyIDAgMCAxLTItMlY3Wm0yIDBoMnYySDdWN1pNNSAxNWMwLTEuMS45LTIgMi0yaDJhMiAyIDAgMCAxIDIgMnYyYTIgMiAwIDAgMS0yIDJIN2EyIDIgMCAwIDEtMi0ydi0yWm0yIDBoMnYySDd2LTJaTTEzIDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAyaC0yYTIgMiAwIDAgMS0yLTJWN1ptMiAwaDJ2MmgtMlY3Wk0xNy4wOCAxM2ExLjUgMS41IDAgMCAwLTEuNDIgMS4wM2MtLjA5LjI1LS4zLjQ3LS41Ni40N0gxNWEyIDIgMCAwIDAtMiAyVjIwYzAgMS4xLjkgMiAyIDJoNmEyIDIgMCAwIDAgMi0ydi0zLjVhMiAyIDAgMCAwLTItMmgtLjFjLS4yNiAwLS40Ny0uMjItLjU2LS40N0ExLjUgMS41IDAgMCAwIDE4LjkyIDEzaC0xLjg0Wk0yMCAxOGEyIDIgMCAxIDEtNCAwIDIgMiAwIDAgMSA0IDBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGNsYXNzPSIiPjwvcGF0aD48L3N2Zz4=")
|
--si-scan-qr-code: url("data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgcm9sZT0iaW1nIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0icmVkIiBkPSJNNCA2YzAtMS4xLjktMiAyLTJoM2ExIDEgMCAwIDAgMC0ySDZhNCA0IDAgMCAwLTQgNHYzYTEgMSAwIDAgMCAyIDBWNlpNNCAxOGMwIDEuMS45IDIgMiAyaDNhMSAxIDAgMSAxIDAgMkg2YTQgNCAwIDAgMS00LTR2LTNhMSAxIDAgMSAxIDIgMHYzWk0yMCA2YTIgMiAwIDAgMC0yLTJoLTNhMSAxIDAgMSAxIDAtMmgzYTQgNCAwIDAgMSA0IDR2M2ExIDEgMCAxIDEtMiAwVjZaIiBjbGFzcz0iIj48L3BhdGg+PHBhdGggZmlsbD0icmVkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01IDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAySDdhMiAyIDAgMCAxLTItMlY3Wm0yIDBoMnYySDdWN1pNNSAxNWMwLTEuMS45LTIgMi0yaDJhMiAyIDAgMCAxIDIgMnYyYTIgMiAwIDAgMS0yIDJIN2EyIDIgMCAwIDEtMi0ydi0yWm0yIDBoMnYySDd2LTJaTTEzIDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAyaC0yYTIgMiAwIDAgMS0yLTJWN1ptMiAwaDJ2MmgtMlY3Wk0xNy4wOCAxM2ExLjUgMS41IDAgMCAwLTEuNDIgMS4wM2MtLjA5LjI1LS4zLjQ3LS41Ni40N0gxNWEyIDIgMCAwIDAtMiAyVjIwYzAgMS4xLjkgMiAyIDJoNmEyIDIgMCAwIDAgMi0ydi0zLjVhMiAyIDAgMCAwLTItMmgtLjFjLS4yNiAwLS40Ny0uMjItLjU2LS40N0ExLjUgMS41IDAgMCAwIDE4LjkyIDEzaC0xLjg0Wk0yMCAxOGEyIDIgMCAxIDEtNCAwIDIgMiAwIDAgMSA0IDBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGNsYXNzPSIiPjwvcGF0aD48L3N2Zz4=");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes cross {
|
||||||
|
0% {
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue