/**
* @name Lockscreen
* @author Haxurus
* @version 1.1.1
* @description Adds a local lockscreen to Discord with 4-digit PIN, 6-digit PIN, password, or Android-style pattern unlock.
*/
module.exports = class Lockscreen {
constructor() {
this.pluginName = "Lockscreen";
this.api = new BdApi(this.pluginName);
this.defaults = {
method: "pin4",
salt: null,
verifier: null,
lockOnStartup: true,
inactivityMinutes: 0,
lockoutSeconds: 30,
showLockButton: true,
enableShortcut: true
};
this.settings = {...this.defaults};
this.overlay = null;
this.lockButton = null;
this.locked = false;
this.lastActivity = Date.now();
this.inactivityTimer = null;
this.failedAttempts = 0;
this.lockoutUntil = 0;
this.lockoutTimer = null;
this.boundActivity = this.handleActivity.bind(this);
this.boundShortcut = this.handleGlobalKeydown.bind(this);
}
start() {
this.settings = {...this.defaults, ...(this.api.Data.load("settings") || {})};
if (Object.prototype.hasOwnProperty.call(this.settings, "lockOnBlur")) {
delete this.settings.lockOnBlur;
this.saveSettings();
}
this.addStyles();
this.attachListeners();
this.updateLockButton();
this.startInactivityWatcher();
const wasLocked = Boolean(this.api.Data.load("locked"));
if (this.hasCredential() && (this.settings.lockOnStartup || wasLocked)) {
setTimeout(() => this.lock(), 600);
}
else if (!this.hasCredential()) {
this.api.UI.showToast("Haxurus Lockscreen: configure an unlock method in the plugin settings.", {type: "warning", timeout: 7000});
}
}
stop() {
this.detachListeners();
this.stopInactivityWatcher();
this.clearTimers();
this.removeOverlay(false);
this.removeLockButton();
this.api.DOM.removeStyle("haxurus-lockscreen-styles");
document.documentElement.removeAttribute("data-haxurus-locked");
}
hasCredential() {
return Boolean(this.settings.salt && this.settings.verifier && this.settings.method);
}
saveSettings() {
this.api.Data.save("settings", this.settings);
}
attachListeners() {
const activityEvents = ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "pointerdown"];
for (const eventName of activityEvents) {
document.addEventListener(eventName, this.boundActivity, true);
}
document.addEventListener("keydown", this.boundShortcut, true);
}
detachListeners() {
const activityEvents = ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "pointerdown"];
for (const eventName of activityEvents) {
document.removeEventListener(eventName, this.boundActivity, true);
}
document.removeEventListener("keydown", this.boundShortcut, true);
}
handleActivity() {
if (!this.locked) this.lastActivity = Date.now();
}
handleGlobalKeydown(event) {
if (this.locked) {
if (this.overlay?.contains(event.target)) return;
event.preventDefault();
event.stopImmediatePropagation();
return;
}
if (
this.settings.enableShortcut &&
this.hasCredential() &&
event.ctrlKey &&
event.shiftKey &&
event.code === "KeyL"
) {
event.preventDefault();
event.stopImmediatePropagation();
this.lock();
}
}
startInactivityWatcher() {
this.stopInactivityWatcher();
this.inactivityTimer = setInterval(() => {
const minutes = Number(this.settings.inactivityMinutes) || 0;
if (!minutes || this.locked || !this.hasCredential()) return;
if (Date.now() - this.lastActivity >= minutes * 60_000) this.lock();
}, 5000);
}
stopInactivityWatcher() {
clearInterval(this.inactivityTimer);
this.inactivityTimer = null;
}
clearTimers() {
clearInterval(this.lockoutTimer);
this.lockoutTimer = null;
}
lock() {
if (this.locked) return;
if (!this.hasCredential()) {
this.api.UI.showToast("Configure a PIN, password, or pattern first.", {type: "warning"});
return;
}
this.locked = true;
this.failedAttempts = 0;
this.lockoutUntil = 0;
this.api.Data.save("locked", true);
document.documentElement.setAttribute("data-haxurus-locked", "true");
this.createOverlay();
}
unlock() {
this.locked = false;
this.failedAttempts = 0;
this.lockoutUntil = 0;
this.api.Data.save("locked", false);
document.documentElement.removeAttribute("data-haxurus-locked");
this.removeOverlay(true);
this.lastActivity = Date.now();
}
createOverlay() {
this.removeOverlay(false);
const overlay = document.createElement("div");
overlay.id = "haxurus-lockscreen-overlay";
overlay.tabIndex = -1;
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "Discord locked");
overlay.addEventListener("contextmenu", event => {
event.preventDefault();
event.stopPropagation();
});
overlay.addEventListener("keydown", event => event.stopPropagation());
overlay.addEventListener("keyup", event => event.stopPropagation());
overlay.addEventListener("keypress", event => event.stopPropagation());
const card = document.createElement("div");
card.className = "hls-card";
const shield = document.createElement("div");
shield.className = "hls-shield";
shield.innerHTML = `
`;
const title = document.createElement("h1");
title.textContent = "Discord locked";
const subtitle = document.createElement("p");
subtitle.className = "hls-subtitle";
subtitle.textContent = this.getMethodPrompt();
const inputArea = document.createElement("div");
inputArea.className = "hls-input-area";
const error = document.createElement("div");
error.className = "hls-error";
error.setAttribute("aria-live", "polite");
const footer = document.createElement("div");
footer.className = "hls-footer";
footer.textContent = this.settings.enableShortcut ? "Quickly lock with Ctrl + Shift + L" : "Haxurus Lockscreen";
card.append(shield, title, subtitle, inputArea, error, footer);
overlay.append(card);
document.body.append(overlay);
this.overlay = overlay;
if (this.settings.method === "pin4" || this.settings.method === "pin6") {
this.renderPinUnlock(inputArea, error);
}
else if (this.settings.method === "password") {
this.renderPasswordUnlock(inputArea, error);
}
else {
this.renderPatternUnlock(inputArea, error);
}
requestAnimationFrame(() => {
overlay.classList.add("hls-visible");
overlay.focus();
});
}
removeOverlay(animate = true) {
clearInterval(this.lockoutTimer);
this.lockoutTimer = null;
const overlay = this.overlay || document.getElementById("haxurus-lockscreen-overlay");
this.overlay = null;
if (!overlay) return;
if (animate) {
overlay.classList.remove("hls-visible");
overlay.classList.add("hls-leaving");
setTimeout(() => overlay.remove(), 180);
}
else {
overlay.remove();
}
}
getMethodPrompt() {
switch (this.settings.method) {
case "pin4": return "Enter the 4-digit PIN";
case "pin6": return "Enter the 6-digit PIN";
case "password": return "Enter the password";
case "pattern": return "Draw the pattern to unlock";
default: return "Enter your credentials";
}
}
renderPinUnlock(container, errorElement) {
const requiredLength = this.settings.method === "pin6" ? 6 : 4;
let value = "";
const dots = document.createElement("div");
dots.className = "hls-pin-dots";
const dotElements = [];
for (let i = 0; i < requiredLength; i++) {
const dot = document.createElement("span");
dots.append(dot);
dotElements.push(dot);
}
const keypad = document.createElement("div");
keypad.className = "hls-keypad";
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
const refresh = () => {
dotElements.forEach((dot, index) => dot.classList.toggle("filled", index < value.length));
};
const submit = async () => {
if (value.length !== requiredLength || this.isLockedOut()) return;
const candidate = value;
value = "";
refresh();
const valid = await this.verifySecret(candidate);
if (valid) this.unlock();
else this.registerFailure(errorElement, container);
};
for (const key of keys) {
const button = document.createElement("button");
button.type = "button";
button.className = "hls-key";
if (key === "clear") {
button.classList.add("hls-key-small");
button.textContent = "C";
button.setAttribute("aria-label", "Clear PIN");
}
else if (key === "back") {
button.classList.add("hls-key-small");
button.textContent = "⌫";
button.setAttribute("aria-label", "Delete last digit");
}
else {
button.textContent = key;
button.setAttribute("aria-label", `Digit ${key}`);
}
button.addEventListener("click", () => {
if (this.isLockedOut()) return;
errorElement.textContent = "";
if (key === "clear") value = "";
else if (key === "back") value = value.slice(0, -1);
else if (value.length < requiredLength) value += key;
refresh();
if (value.length === requiredLength) setTimeout(submit, 100);
});
keypad.append(button);
}
const keyboardHandler = event => {
if (this.isLockedOut()) return;
if (/^[0-9]$/.test(event.key) && value.length < requiredLength) {
value += event.key;
refresh();
if (value.length === requiredLength) setTimeout(submit, 100);
}
else if (event.key === "Backspace") {
value = value.slice(0, -1);
refresh();
}
else if (event.key === "Escape" || event.key === "Delete") {
value = "";
refresh();
}
event.preventDefault();
};
this.overlay.addEventListener("keydown", keyboardHandler);
container.append(dots, keypad);
}
renderPasswordUnlock(container, errorElement) {
const form = document.createElement("form");
form.className = "hls-password-form";
const inputWrap = document.createElement("div");
inputWrap.className = "hls-password-wrap";
const input = document.createElement("input");
input.type = "password";
input.autocomplete = "off";
input.placeholder = "Password";
input.setAttribute("aria-label", "Unlock password");
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "hls-password-toggle";
toggle.textContent = "Show";
toggle.addEventListener("click", () => {
const show = input.type === "password";
input.type = show ? "text" : "password";
toggle.textContent = show ? "Hide" : "Show";
input.focus();
});
const submit = document.createElement("button");
submit.type = "submit";
submit.className = "hls-primary-button";
submit.textContent = "Unlock";
form.addEventListener("submit", async event => {
event.preventDefault();
event.stopPropagation();
if (this.isLockedOut() || !input.value) return;
submit.disabled = true;
const valid = await this.verifySecret(input.value);
submit.disabled = false;
if (valid) this.unlock();
else {
input.value = "";
input.focus();
this.registerFailure(errorElement, form);
}
});
inputWrap.append(input, toggle);
form.append(inputWrap, submit);
container.append(form);
setTimeout(() => input.focus(), 80);
}
renderPatternUnlock(container, errorElement) {
const hint = document.createElement("div");
hint.className = "hls-pattern-hint";
hint.textContent = "Connect at least 4 dots";
const pattern = this.createPatternPad({
onComplete: async sequence => {
if (this.isLockedOut()) return false;
if (sequence.length < 4) {
errorElement.textContent = "The pattern must contain at least 4 dots.";
return false;
}
const valid = await this.verifySecret(sequence.join("-"));
if (valid) {
this.unlock();
return true;
}
this.registerFailure(errorElement, pattern);
return false;
},
disabled: () => this.isLockedOut()
});
container.append(hint, pattern);
}
createPatternPad({onComplete, disabled = () => false, compact = false} = {}) {
const pad = document.createElement("div");
pad.className = `hls-pattern-pad${compact ? " compact" : ""}`;
pad.setAttribute("role", "application");
pad.setAttribute("aria-label", "Pattern drawing area");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 300 300");
svg.setAttribute("preserveAspectRatio", "none");
const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
polyline.setAttribute("fill", "none");
polyline.setAttribute("stroke-linecap", "round");
polyline.setAttribute("stroke-linejoin", "round");
svg.append(polyline);
pad.append(svg);
const centers = [];
const nodes = [];
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const index = row * 3 + col + 1;
const x = 50 + col * 100;
const y = 50 + row * 100;
centers.push({x, y});
const node = document.createElement("div");
node.className = "hls-pattern-node";
node.dataset.index = String(index);
node.style.left = `${(x / 300) * 100}%`;
node.style.top = `${(y / 300) * 100}%`;
node.innerHTML = "";
pad.append(node);
nodes.push(node);
}
}
let drawing = false;
let selected = [];
let pointer = null;
const updateLine = () => {
const points = selected.map(index => centers[index - 1]);
if (drawing && pointer) points.push(pointer);
polyline.setAttribute("points", points.map(point => `${point.x},${point.y}`).join(" "));
};
const addNode = index => {
if (selected.includes(index)) return;
selected.push(index);
nodes[index - 1].classList.add("selected");
updateLine();
};
const findNode = event => {
const rect = pad.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 300;
const y = ((event.clientY - rect.top) / rect.height) * 300;
pointer = {x: Math.max(0, Math.min(300, x)), y: Math.max(0, Math.min(300, y))};
let best = null;
let bestDistance = compact ? 38 : 34;
centers.forEach((center, idx) => {
const distance = Math.hypot(center.x - x, center.y - y);
if (distance <= bestDistance) {
bestDistance = distance;
best = idx + 1;
}
});
return best;
};
const clearSelection = () => {
selected = [];
nodes.forEach(node => node.classList.remove("selected"));
polyline.setAttribute("points", "");
pad.classList.remove("success", "failure");
};
const reset = (state = "") => {
drawing = false;
pointer = null;
pad.classList.remove("success", "failure");
if (!state) {
clearSelection();
return;
}
pad.classList.add(state);
setTimeout(clearSelection, 450);
};
pad.addEventListener("pointerdown", event => {
if (disabled()) return;
event.preventDefault();
pad.setPointerCapture?.(event.pointerId);
reset();
drawing = true;
const index = findNode(event);
if (index) addNode(index);
updateLine();
});
pad.addEventListener("pointermove", event => {
if (!drawing || disabled()) return;
event.preventDefault();
const index = findNode(event);
if (index) addNode(index);
updateLine();
});
const finish = async event => {
if (!drawing) return;
event?.preventDefault();
drawing = false;
pointer = null;
updateLine();
const sequence = [...selected];
const result = await onComplete?.(sequence);
reset(result === true ? "success" : result === false ? "failure" : "");
};
pad.addEventListener("pointerup", finish);
pad.addEventListener("pointercancel", () => reset());
pad.addEventListener("lostpointercapture", event => {
if (drawing) finish(event);
});
return pad;
}
async verifySecret(secret) {
try {
const calculated = await this.deriveVerifier(secret, this.settings.salt);
return this.constantTimeEqual(calculated, this.settings.verifier);
}
catch (error) {
this.api.Logger.error("Unable to verify credential", error);
return false;
}
}
registerFailure(errorElement, shakeElement) {
this.failedAttempts += 1;
shakeElement.classList.remove("hls-shake");
void shakeElement.offsetWidth;
shakeElement.classList.add("hls-shake");
if (this.failedAttempts >= 5) {
this.failedAttempts = 0;
const lockoutSeconds = Math.max(0, Number(this.settings.lockoutSeconds) || 0);
if (lockoutSeconds > 0) {
this.lockoutUntil = Date.now() + lockoutSeconds * 1000;
this.startLockoutCountdown(errorElement);
}
else {
this.lockoutUntil = 0;
errorElement.textContent = "Incorrect credential. You can try again.";
}
}
else {
const remaining = 5 - this.failedAttempts;
errorElement.textContent = `Incorrect credential. ${remaining} ${remaining === 1 ? "attempt" : "attempts"} remaining before the temporary lockout.`;
}
}
isLockedOut() {
return Date.now() < this.lockoutUntil;
}
startLockoutCountdown(errorElement) {
clearInterval(this.lockoutTimer);
const update = () => {
const seconds = Math.max(0, Math.ceil((this.lockoutUntil - Date.now()) / 1000));
if (!seconds) {
clearInterval(this.lockoutTimer);
this.lockoutTimer = null;
errorElement.textContent = "You can try again.";
return;
}
errorElement.textContent = `Too many attempts. Try again in ${seconds} ${seconds === 1 ? "second" : "seconds"}.`;
};
update();
this.lockoutTimer = setInterval(update, 250);
}
async setCredential(method, secret) {
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
const salt = this.bytesToBase64(saltBytes);
const verifier = await this.deriveVerifier(secret, salt);
this.settings.method = method;
this.settings.salt = salt;
this.settings.verifier = verifier;
this.saveSettings();
this.api.Data.save("locked", false);
}
async deriveVerifier(secret, saltBase64) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{name: "PBKDF2"},
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
salt: this.base64ToBytes(saltBase64),
iterations: 210000
},
keyMaterial,
256
);
return this.bytesToBase64(new Uint8Array(bits));
}
constantTimeEqual(a, b) {
if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
let difference = 0;
for (let i = 0; i < a.length; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
return difference === 0;
}
bytesToBase64(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
base64ToBytes(value) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
updateLockButton() {
this.removeLockButton();
if (!this.settings.showLockButton || !this.hasCredential()) return;
const button = document.createElement("button");
button.id = "haxurus-lockscreen-button";
button.type = "button";
button.title = "Lock Discord";
button.setAttribute("aria-label", "Lock Discord");
button.innerHTML = `
`;
button.addEventListener("click", () => this.lock());
document.body.append(button);
this.lockButton = button;
}
removeLockButton() {
this.lockButton?.remove();
document.getElementById("haxurus-lockscreen-button")?.remove();
this.lockButton = null;
}
getSettingsPanel() {
const panel = document.createElement("div");
panel.className = "hls-settings";
const heading = document.createElement("div");
heading.className = "hls-settings-heading";
heading.innerHTML = `
Haxurus Lockscreen
Local protection for the Discord interface.
`;
const warning = document.createElement("div");
warning.className = "hls-settings-warning";
warning.innerHTML = "Security notice: this is a local visual protection layer. Anyone who can disable BetterDiscord, remove the plugin, or modify its data can bypass it.";
const credentialCard = this.createSettingsCard("Unlock method", "Set or replace the credential used by the lockscreen.");
const methodRow = this.createSettingRow("Type", "Choose a PIN, password, or pattern.");
const methodSelect = document.createElement("select");
methodSelect.className = "hls-settings-input";
methodSelect.innerHTML = `
`;
methodSelect.value = this.settings.method || "pin4";
methodRow.append(methodSelect);
const setupArea = document.createElement("div");
setupArea.className = "hls-credential-setup";
credentialCard.body.append(methodRow, setupArea);
const renderCredentialSetup = () => {
setupArea.replaceChildren();
const method = methodSelect.value;
const status = document.createElement("div");
status.className = "hls-settings-status";
if (method === "pattern") {
let firstPattern = null;
let confirmedPattern = null;
let stage = 1;
const instructions = document.createElement("div");
instructions.className = "hls-pattern-setup-title";
instructions.textContent = "Draw the new pattern";
const pad = this.createPatternPad({
compact: true,
onComplete: sequence => {
if (sequence.length < 4) {
status.textContent = "The pattern must contain at least 4 dots.";
status.className = "hls-settings-status error";
return false;
}
if (stage === 1) {
firstPattern = sequence.join("-");
stage = 2;
instructions.textContent = "Draw the pattern again to confirm";
status.textContent = "First pattern recorded.";
status.className = "hls-settings-status success";
return true;
}
confirmedPattern = sequence.join("-");
if (firstPattern !== confirmedPattern) {
firstPattern = null;
confirmedPattern = null;
stage = 1;
instructions.textContent = "The patterns do not match. Start again";
status.textContent = "Confirmation failed.";
status.className = "hls-settings-status error";
return false;
}
instructions.textContent = "Pattern confirmed";
status.textContent = "Press Save pattern to apply it.";
status.className = "hls-settings-status success";
return true;
}
});
const buttons = document.createElement("div");
buttons.className = "hls-settings-actions";
const reset = this.createButton("Start again", "secondary", () => {
firstPattern = null;
confirmedPattern = null;
stage = 1;
instructions.textContent = "Draw the new pattern";
status.textContent = "";
});
const save = this.createButton("Save pattern", "primary", async () => {
if (!firstPattern || !confirmedPattern || firstPattern !== confirmedPattern) {
status.textContent = "Draw and confirm the pattern first.";
status.className = "hls-settings-status error";
return;
}
save.disabled = true;
status.textContent = "Saving...";
status.className = "hls-settings-status";
try {
await this.setCredential("pattern", firstPattern);
status.textContent = "Pattern updated successfully.";
status.className = "hls-settings-status success";
this.updateLockButton();
}
catch (error) {
this.api.Logger.error("Unable to save pattern", error);
status.textContent = "Unable to save the pattern.";
status.className = "hls-settings-status error";
}
save.disabled = false;
});
buttons.append(reset, save);
setupArea.append(instructions, pad, status, buttons);
return;
}
const requiredLength = method === "pin4" ? 4 : method === "pin6" ? 6 : null;
const firstRow = this.createSettingRow("New credential", requiredLength ? `Enter exactly ${requiredLength} digits.` : "Use at least 4 characters.");
const firstInput = document.createElement("input");
firstInput.className = "hls-settings-input";
firstInput.type = "password";
firstInput.autocomplete = "new-password";
firstInput.placeholder = requiredLength ? "•".repeat(requiredLength) : "New password";
if (requiredLength) {
firstInput.inputMode = "numeric";
firstInput.maxLength = requiredLength;
firstInput.addEventListener("input", () => firstInput.value = firstInput.value.replace(/\D/g, "").slice(0, requiredLength));
}
firstRow.append(firstInput);
const confirmRow = this.createSettingRow("Confirm", "Enter the same credential again.");
const confirmInput = document.createElement("input");
confirmInput.className = "hls-settings-input";
confirmInput.type = "password";
confirmInput.autocomplete = "new-password";
confirmInput.placeholder = "Confirm";
if (requiredLength) {
confirmInput.inputMode = "numeric";
confirmInput.maxLength = requiredLength;
confirmInput.addEventListener("input", () => confirmInput.value = confirmInput.value.replace(/\D/g, "").slice(0, requiredLength));
}
confirmRow.append(confirmInput);
const actions = document.createElement("div");
actions.className = "hls-settings-actions";
const save = this.createButton("Save credential", "primary", async () => {
const first = firstInput.value;
const second = confirmInput.value;
status.className = "hls-settings-status";
if (requiredLength && !new RegExp(`^\\d{${requiredLength}}$`).test(first)) {
status.textContent = `The PIN must contain exactly ${requiredLength} digits.`;
status.classList.add("error");
return;
}
if (!requiredLength && first.length < 4) {
status.textContent = "The password must contain at least 4 characters.";
status.classList.add("error");
return;
}
if (first !== second) {
status.textContent = "The credentials do not match.";
status.classList.add("error");
return;
}
save.disabled = true;
status.textContent = "Saving...";
try {
await this.setCredential(method, first);
firstInput.value = "";
confirmInput.value = "";
status.textContent = "Credential updated successfully.";
status.className = "hls-settings-status success";
this.updateLockButton();
}
catch (error) {
this.api.Logger.error("Unable to save credential", error);
status.textContent = "Unable to save the credential.";
status.className = "hls-settings-status error";
}
save.disabled = false;
});
actions.append(save);
setupArea.append(firstRow, confirmRow, status, actions);
};
methodSelect.addEventListener("change", renderCredentialSetup);
renderCredentialSetup();
const behaviorCard = this.createSettingsCard("Behavior", "Choose when Discord should lock.");
behaviorCard.body.append(
this.createToggleRow("Lock on startup", "Show the lockscreen when Discord starts.", "lockOnStartup"),
this.createNumberRow("Lock after inactivity", "Minutes without activity before locking. Use 0 to disable.", "inactivityMinutes", 0, 1440),
this.createNumberRow("Lockout duration", "Seconds to wait after 5 failed attempts. Use 0 to disable.", "lockoutSeconds", 0, 3600),
this.createToggleRow("Show lock button", "Adds a small button in the bottom-right corner.", "showLockButton", () => this.updateLockButton()),
this.createToggleRow("Ctrl + Shift + L shortcut", "Allows Discord to be locked from the keyboard.", "enableShortcut")
);
const actionsCard = this.createSettingsCard("Actions", "Immediate lockscreen controls.");
const actionButtons = document.createElement("div");
actionButtons.className = "hls-settings-actions hls-settings-actions-wide";
const lockNow = this.createButton("Lock now", "primary", () => {
if (!this.hasCredential()) {
this.api.UI.showToast("Configure a credential first.", {type: "warning"});
return;
}
this.lock();
});
const removeCredential = this.createButton("Remove credential", "danger", () => {
this.api.UI.showConfirmationModal(
"Remove the credential?",
"The lockscreen will remain disabled until you configure a new unlock method.",
{
danger: true,
confirmText: "Remove",
cancelText: "Cancel",
onConfirm: () => {
this.settings.salt = null;
this.settings.verifier = null;
this.saveSettings();
this.api.Data.save("locked", false);
this.updateLockButton();
this.api.UI.showToast("Credential removed.", {type: "success"});
}
}
);
});
actionButtons.append(lockNow, removeCredential);
actionsCard.body.append(actionButtons);
panel.append(heading, warning, credentialCard.card, behaviorCard.card, actionsCard.card);
return panel;
}
createSettingsCard(title, description) {
const card = document.createElement("section");
card.className = "hls-settings-card";
const header = document.createElement("div");
header.className = "hls-settings-card-header";
const heading = document.createElement("h3");
heading.textContent = title;
const text = document.createElement("p");
text.textContent = description;
header.append(heading, text);
const body = document.createElement("div");
body.className = "hls-settings-card-body";
card.append(header, body);
return {card, body};
}
createSettingRow(title, description) {
const row = document.createElement("label");
row.className = "hls-setting-row";
const text = document.createElement("div");
text.className = "hls-setting-copy";
const heading = document.createElement("div");
heading.className = "hls-setting-title";
heading.textContent = title;
const detail = document.createElement("div");
detail.className = "hls-setting-description";
detail.textContent = description;
text.append(heading, detail);
row.append(text);
return row;
}
createToggleRow(title, description, key, callback) {
const row = this.createSettingRow(title, description);
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.className = "hls-toggle";
toggle.checked = Boolean(this.settings[key]);
toggle.addEventListener("change", () => {
this.settings[key] = toggle.checked;
this.saveSettings();
callback?.(toggle.checked);
});
row.append(toggle);
return row;
}
createNumberRow(title, description, key, min, max) {
const row = this.createSettingRow(title, description);
const input = document.createElement("input");
input.type = "number";
input.className = "hls-settings-input hls-number-input";
input.min = String(min);
input.max = String(max);
input.step = "1";
input.value = String(Number(this.settings[key]) || 0);
input.addEventListener("change", () => {
const value = Math.max(min, Math.min(max, Number.parseInt(input.value, 10) || 0));
input.value = String(value);
this.settings[key] = value;
this.saveSettings();
this.lastActivity = Date.now();
});
row.append(input);
return row;
}
createButton(text, type, onClick) {
const button = document.createElement("button");
button.type = "button";
button.className = `hls-settings-button ${type}`;
button.textContent = text;
button.addEventListener("click", onClick);
return button;
}
addStyles() {
this.api.DOM.addStyle("haxurus-lockscreen-styles", `
#haxurus-lockscreen-overlay {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
color: #f2f3f5;
background:
radial-gradient(circle at 20% 10%, rgba(35, 165, 90, .22), transparent 35%),
radial-gradient(circle at 85% 85%, rgba(88, 101, 242, .25), transparent 40%),
rgba(12, 14, 18, .84);
backdrop-filter: blur(18px) saturate(.8);
-webkit-backdrop-filter: blur(18px) saturate(.8);
opacity: 0;
transition: opacity .18s ease;
user-select: none;
font-family: var(--font-primary, "gg sans", sans-serif);
}
#haxurus-lockscreen-overlay.hls-visible { opacity: 1; }
#haxurus-lockscreen-overlay.hls-leaving { opacity: 0; }
#haxurus-lockscreen-overlay * { box-sizing: border-box; }
.hls-card {
width: min(430px, 100%);
max-height: calc(100vh - 48px);
overflow: auto;
padding: 30px 32px 24px;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 24px;
background: rgba(24, 26, 32, .92);
box-shadow: 0 24px 80px rgba(0, 0, 0, .5);
text-align: center;
transform: translateY(8px) scale(.985);
transition: transform .18s ease;
}
.hls-visible .hls-card { transform: translateY(0) scale(1); }
.hls-shield {
width: 56px;
height: 56px;
display: grid;
place-items: center;
margin: 0 auto 14px;
border-radius: 18px;
background: linear-gradient(145deg, #23a55a, #157a40);
box-shadow: 0 10px 28px rgba(35, 165, 90, .28);
}
.hls-shield svg { width: 32px; height: 32px; fill: white; }
.hls-card h1 { margin: 24px 0 6px; font-size: 24px; line-height: 1.2; }
.hls-subtitle { margin: 0; color: #b5bac1; font-size: 15px; }
.hls-input-area { margin-top: 24px; }
.hls-error { min-height: 38px; margin-top: 16px; color: #fa777c; font-size: 13px; line-height: 1.35; }
.hls-footer { margin-top: 6px; color: #80848e; font-size: 12px; }
.hls-pin-dots { display: flex; justify-content: center; gap: 14px; margin: 0 auto 24px; }
.hls-pin-dots span {
width: 13px;
height: 13px;
border: 2px solid #6d6f78;
border-radius: 50%;
transition: .12s ease;
}
.hls-pin-dots span.filled { border-color: #23a55a; background: #23a55a; transform: scale(1.08); }
.hls-keypad {
display: grid;
grid-template-columns: repeat(3, 68px);
justify-content: center;
gap: 12px 18px;
}
.hls-key {
width: 68px;
height: 56px;
border: 1px solid rgba(255, 255, 255, .09);
border-radius: 18px;
color: #f2f3f5;
background: rgba(255, 255, 255, .075);
font: inherit;
font-size: 20px;
font-weight: 600;
cursor: pointer;
transition: background .12s ease, transform .08s ease, border-color .12s ease;
}
.hls-key:hover { background: rgba(255, 255, 255, .13); border-color: rgba(255, 255, 255, .16); }
.hls-key:active { transform: scale(.95); }
.hls-key-small { color: #b5bac1; font-size: 16px; }
.hls-password-form { display: grid; gap: 14px; }
.hls-password-wrap { position: relative; }
.hls-password-wrap input {
width: 100%;
height: 48px;
padding: 0 82px 0 14px;
border: 1px solid #3f4147;
border-radius: 10px;
outline: none;
color: #f2f3f5;
background: #111318;
font: inherit;
font-size: 16px;
}
.hls-password-wrap input:focus { border-color: #23a55a; box-shadow: 0 0 0 3px rgba(35, 165, 90, .16); }
.hls-password-toggle {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
border: 0;
color: #b5bac1;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 600;
}
.hls-primary-button {
height: 44px;
border: 0;
border-radius: 10px;
color: white;
background: #23a55a;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: background .12s ease, transform .08s ease;
}
.hls-primary-button:hover { background: #1a8f4b; }
.hls-primary-button:active { transform: scale(.985); }
.hls-primary-button:disabled { opacity: .55; cursor: wait; }
.hls-pattern-hint { margin-bottom: 10px; color: #80848e; font-size: 12px; }
.hls-pattern-pad {
position: relative;
width: min(300px, 76vw);
aspect-ratio: 1;
margin: 0 auto;
touch-action: none;
cursor: crosshair;
}
.hls-pattern-pad.compact { width: min(270px, 72vw); }
.hls-pattern-pad svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.hls-pattern-pad polyline { stroke: #23a55a; stroke-width: 8; opacity: .85; }
.hls-pattern-node {
position: absolute;
width: 52px;
height: 52px;
display: grid;
place-items: center;
transform: translate(-50%, -50%);
border: 2px solid #6d6f78;
border-radius: 50%;
background: #1a1d23;
transition: border-color .1s ease, background .1s ease, transform .1s ease;
pointer-events: none;
}
.hls-pattern-node span { width: 12px; height: 12px; border-radius: 50%; background: #6d6f78; }
.hls-pattern-node.selected { border-color: #23a55a; background: rgba(35, 165, 90, .16); transform: translate(-50%, -50%) scale(1.06); }
.hls-pattern-node.selected span { background: #23a55a; }
.hls-pattern-pad.failure polyline { stroke: #da373c; }
.hls-pattern-pad.failure .hls-pattern-node.selected { border-color: #da373c; background: rgba(218, 55, 60, .16); }
.hls-pattern-pad.failure .hls-pattern-node.selected span { background: #da373c; }
.hls-pattern-pad.success polyline { stroke: #23a55a; }
.hls-shake { animation: hls-shake .32s ease; }
@keyframes hls-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(7px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(3px); }
}
#haxurus-lockscreen-button {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 9999;
width: 40px;
height: 40px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 14px;
color: #dbdee1;
background: rgba(30, 31, 34, .92);
box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
cursor: pointer;
transition: transform .12s ease, background .12s ease, color .12s ease;
}
#haxurus-lockscreen-button:hover { color: white; background: #23a55a; transform: translateY(-2px); }
#haxurus-lockscreen-button svg { width: 20px; height: 20px; fill: currentColor; }
html[data-haxurus-locked="true"] #haxurus-lockscreen-button { display: none; }
.hls-settings {
max-width: 820px;
padding: 4px 4px 40px;
color: var(--text-primary);
font-family: var(--font-primary, "gg sans", sans-serif);
}
.hls-settings * { box-sizing: border-box; }
.hls-settings-heading { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }
.hls-settings-heading h2 { margin: 0 0 4px; font-size: 22px; }
.hls-settings-heading p { margin: 0; color: var(--text-muted); }
.hls-settings-icon {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 14px;
background: #23a55a;
}
.hls-settings-icon svg { width: 28px; height: 28px; fill: white; }
.hls-settings-warning {
margin-bottom: 16px;
padding: 12px 14px;
border: 1px solid rgba(250, 168, 26, .35);
border-radius: 10px;
color: var(--text-normal);
background: rgba(250, 168, 26, .09);
line-height: 1.45;
}
.hls-settings-card {
margin-bottom: 16px;
overflow: hidden;
border: 1px solid var(--background-modifier-accent);
border-radius: 12px;
background: var(--background-secondary);
}
.hls-settings-card-header { padding: 16px 18px 13px; border-bottom: 1px solid var(--background-modifier-accent); }
.hls-settings-card-header h3 { margin: 0 0 4px; font-size: 16px; }
.hls-settings-card-header p { margin: 0; color: var(--text-muted); font-size: 13px; }
.hls-settings-card-body { padding: 0 18px; }
.hls-setting-row {
min-height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 13px 0;
border-bottom: 1px solid var(--background-modifier-accent);
}
.hls-setting-row:last-child { border-bottom: 0; }
.hls-setting-copy { min-width: 0; }
.hls-setting-title { color: var(--text-primary); font-weight: 600; }
.hls-setting-description { margin-top: 4px; color: var(--text-muted); font-size: 12px; line-height: 1.35; }
.hls-settings-input {
width: min(260px, 48%);
min-width: 140px;
height: 38px;
padding: 0 10px;
border: 1px solid var(--input-border, var(--background-modifier-accent));
border-radius: 8px;
outline: 0;
color: var(--text-primary);
background: var(--input-background, var(--background-tertiary));
font: inherit;
}
.hls-settings-input:focus { border-color: #23a55a; box-shadow: 0 0 0 2px rgba(35, 165, 90, .15); }
.hls-number-input { width: 96px; min-width: 96px; }
.hls-toggle {
width: 42px;
height: 24px;
flex: 0 0 auto;
appearance: none;
position: relative;
border-radius: 999px;
background: var(--background-modifier-selected);
cursor: pointer;
transition: background .15s ease;
}
.hls-toggle::after {
content: "";
position: absolute;
width: 18px;
height: 18px;
top: 3px;
left: 3px;
border-radius: 50%;
background: white;
transition: transform .15s ease;
}
.hls-toggle:checked { background: #23a55a; }
.hls-toggle:checked::after { transform: translateX(18px); }
.hls-credential-setup { padding-bottom: 16px; }
.hls-pattern-setup-title { margin: 18px 0 8px; text-align: center; font-weight: 600; }
.hls-settings-status { min-height: 20px; margin-top: 10px; color: var(--text-muted); font-size: 13px; text-align: center; }
.hls-settings-status.success { color: #23a55a; }
.hls-settings-status.error { color: #da373c; }
.hls-settings-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
.hls-settings-actions-wide { justify-content: flex-start; padding: 18px 0; margin: 0; }
.hls-settings-button {
min-height: 38px;
padding: 0 14px;
border: 0;
border-radius: 8px;
color: white;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.hls-settings-button.primary { background: #23a55a; }
.hls-settings-button.primary:hover { background: #1a8f4b; }
.hls-settings-button.secondary { background: var(--button-secondary-background, #4e5058); }
.hls-settings-button.danger { background: #da373c; }
.hls-settings-button.danger:hover { background: #a1282c; }
.hls-settings-button:disabled { opacity: .55; cursor: wait; }
@media (max-width: 520px) {
.hls-card { padding: 24px 18px 20px; border-radius: 18px; }
.hls-setting-row { align-items: flex-start; flex-direction: column; gap: 10px; }
.hls-settings-input { width: 100%; }
}
`);
}
};