From d32bdfbd6caa81e4655dcbba045e0ec5bcecc7dc Mon Sep 17 00:00:00 2001 From: Benson K B Date: Mon, 25 May 2026 23:46:58 -0300 Subject: [PATCH] fix(electron): Caps Lock indicator, Electron-aware reset message & suppress shell window (#2714) Thanks @benzntech. --- electron/main.js | 30 +++ .../(dashboard)/dashboard/onboarding/page.tsx | 13 ++ src/app/forgot-password/page.tsx | 206 +++++++++++++----- src/shared/components/Input.tsx | 62 +++++- 4 files changed, 257 insertions(+), 54 deletions(-) diff --git a/electron/main.js b/electron/main.js index 24cf54b2d7..ec744af8e5 100644 --- a/electron/main.js +++ b/electron/main.js @@ -68,6 +68,32 @@ const getServerUrl = () => `http://localhost:${serverPort}`; function resolveNodeExecutable(env = process.env) { // #1081: Ensure Next.js standalone runs using Electron's Node runtime // instead of a randomly found system Node to prevent ABI architecture mismatches. + // + // On macOS packaged builds, process.execPath is the main Electron binary + // (e.g. OmniRoute.app/Contents/MacOS/OmniRoute). Spawning it with + // ELECTRON_RUN_AS_NODE causes macOS to show a second dock icon and/or + // flash a shell window. Use the Helper binary instead — macOS treats + // Helper processes as background tasks with no visible UI artifacts. + if (process.platform === "darwin" && !isDev) { + const helperPath = path.join(path.dirname(process.execPath), `${app.getName()} Helper`); + if (fs.existsSync(helperPath)) { + return helperPath; + } + // Electron \u003e= 20 may use "(Renderer)" / "(GPU)" / "(Plugin)" suffixed helpers. + // The unsuffixed Helper is the one suitable for ELECTRON_RUN_AS_NODE. + const frameworkHelper = path.join( + path.dirname(process.execPath), + "..", + "Frameworks", + `${app.getName()} Helper.app`, + "Contents", + "MacOS", + `${app.getName()} Helper` + ); + if (fs.existsSync(frameworkHelper)) { + return frameworkHelper; + } + } return process.execPath; } @@ -594,6 +620,8 @@ function startNextServer() { sendToRenderer("server-status", { status: "starting", port: serverPort }); // Fix #10: Use pipe instead of inherit for logging & readiness detection + // windowsHide prevents a visible console window from spawning alongside the GUI app. + // shell: false avoids launching via a shell wrapper which can flash a terminal on macOS. nextServer = spawn(nodeExecutable, [serverScript], { cwd: NEXT_SERVER_PATH, env: { @@ -605,6 +633,8 @@ function startNextServer() { NODE_PATH: resolveServerNodePath(serverEnv), }, stdio: "pipe", + windowsHide: true, + shell: false, }); // Capture server output for logging diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index fc99f0a89a..36ace2b1c1 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -31,6 +31,7 @@ export default function OnboardingWizard() { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [skipSecurity, setSkipSecurity] = useState(false); + const [capsLockOn, setCapsLockOn] = useState(false); // Provider step state const [selectedProvider, setSelectedProvider] = useState(null); @@ -325,6 +326,8 @@ export default function OnboardingWizard() { placeholder={t("enterPassword")} value={password} onChange={(e) => setPassword(e.target.value)} + onKeyDown={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} + onKeyUp={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" /> setConfirmPassword(e.target.value)} + onKeyDown={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} + onKeyUp={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" /> + {capsLockOn && ( +

+ + Caps Lock is on +

+ )} {password && confirmPassword && password !== confirmPassword && (

{t("passwordsMismatch")}

)} diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx index 1d7984d6ef..d821f8fdf3 100644 --- a/src/app/forgot-password/page.tsx +++ b/src/app/forgot-password/page.tsx @@ -1,13 +1,15 @@ "use client"; import { useTranslations } from "next-intl"; +import { useState, useEffect } from "react"; +import { useIsElectron } from "@/shared/hooks/useElectron"; /** * Forgot Password Page — Phase 8.2 * - * Provides two recovery methods: - * 1. CLI reset via omniroute-reset-password command - * 2. Manual database reset instructions + * Provides recovery methods: + * - Web/CLI: CLI reset via omniroute-reset-password command + manual database reset + * - Electron: Data directory reset instructions */ import Link from "next/link"; @@ -15,6 +17,18 @@ import { Card } from "@/shared/components"; export default function ForgotPasswordPage() { const t = useTranslations("auth"); + const isElectron = useIsElectron(); + const [dataDir, setDataDir] = useState(null); + + useEffect(() => { + if (isElectron && typeof window !== "undefined" && (window as any).electronAPI?.getDataDir) { + (window as any).electronAPI + .getDataDir() + .then((dir: string) => setDataDir(dir)) + .catch(() => {}); + } + }, [isElectron]); + return (
@@ -23,58 +37,146 @@ export default function ForgotPasswordPage() {

{t("resetDescription")}

- {/* Method 1: CLI Reset */} - -
-
- -
-
-

{t("methodCliTitle")}

-

{t("methodCliDescription")}

-
- npx omniroute reset-password + {isElectron ? ( + <> + {/* Electron: App Reset Method */} + +
+
+ +
+
+

Reset via App Data

+

+ Delete the settings file from the app data directory to reset your password: +

+
    +
  1. Quit the OmniRoute desktop app completely
  2. +
  3. + Navigate to the app data directory: + {dataDir ? ( +
    + {dataDir} +
    + ) : ( +
    + + (Check your system app data folder) + +
    + )} +
  4. +
  5. + Delete{" "} + settings.json{" "} + ({t("orRemovePasswordHashField")}) +
  6. +
  7. Relaunch the OmniRoute desktop app — it will start fresh setup
  8. +
+
-

{t("methodCliHint")}

-
-
- + - {/* Method 2: Database Reset */} - -
-
- -
-
-

{t("methodManualTitle")}

-

{t("methodManualDescription")}

-
    -
  1. {t("stopServer")}
  2. -
  3. - {t("setPasswordInYour")}{" "} - .env{" "} - {t("fileLabelSuffix")} -
    - INITIAL_PASSWORD={t("newPasswordPlaceholder")} + {/* Electron: Env File Method */} + +
    +
    + +
    +
    +

    Alternative: Set New Password

    +

    + Set a new initial password via the server environment file: +

    +
      +
    1. Quit the OmniRoute desktop app completely
    2. +
    3. + Open{" "} + server.env in + the data directory + {dataDir && ( +
      + {dataDir}/server.env +
      + )} +
    4. +
    5. + Add or update: +
      + INITIAL_PASSWORD={t("newPasswordPlaceholder")} +
      +
    6. +
    7. + Delete{" "} + settings.json{" "} + from the data directory +
    8. +
    9. Relaunch the OmniRoute desktop app
    10. +
    +
    +
    +
    + + ) : ( + <> + {/* Method 1: CLI Reset */} + +
    +
    + +
    +
    +

    {t("methodCliTitle")}

    +

    {t("methodCliDescription")}

    +
    + npx omniroute reset-password
    -
  4. -
  5. - {t("deleteSettingsFile")}{" "} - - data/settings.json - {" "} - ({t("orRemovePasswordHashField")}) -
  6. -
  7. {t("restartServerWithNewPassword")}
  8. -
-
-
-
+

{t("methodCliHint")}

+
+
+ + + {/* Method 2: Database Reset */} + +
+
+ +
+
+

{t("methodManualTitle")}

+

{t("methodManualDescription")}

+
    +
  1. {t("stopServer")}
  2. +
  3. + {t("setPasswordInYour")}{" "} + .env{" "} + {t("fileLabelSuffix")} +
    + INITIAL_PASSWORD={t("newPasswordPlaceholder")} +
    +
  4. +
  5. + {t("deleteSettingsFile")}{" "} + + data/settings.json + {" "} + ({t("orRemovePasswordHashField")}) +
  6. +
  7. {t("restartServerWithNewPassword")}
  8. +
+
+
+
+ + )}
, "size"> { @@ -25,13 +25,47 @@ export default function Input({ className, inputClassName, id: externalId, + onKeyDown: externalOnKeyDown, + onKeyUp: externalOnKeyUp, ...props }: InputProps) { const generatedId = useId(); const inputId = externalId || generatedId; const errorId = error ? `${inputId}-error` : undefined; const hintId = hint && !error ? `${inputId}-hint` : undefined; - const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined; + const capsLockId = `${inputId}-capslock`; + const isPassword = type === "password"; + const [capsLockOn, setCapsLockOn] = useState(false); + const [inputFocused, setInputFocused] = useState(false); + + const detectCapsLock = useCallback( + (e: React.KeyboardEvent) => { + if (isPassword) { + setCapsLockOn(e.getModifierState("CapsLock")); + } + }, + [isPassword] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + detectCapsLock(e); + externalOnKeyDown?.(e); + }, + [detectCapsLock, externalOnKeyDown] + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + detectCapsLock(e); + externalOnKeyUp?.(e); + }, + [detectCapsLock, externalOnKeyUp] + ); + + const showCapsLock = isPassword && capsLockOn && inputFocused; + const describedBy = + [errorId, showCapsLock ? capsLockId : undefined, hintId].filter(Boolean).join(" ") || undefined; return (
@@ -64,6 +98,17 @@ export default function Input({ aria-required={required || undefined} aria-invalid={error ? true : undefined} aria-describedby={describedBy} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onFocus={(e) => { + setInputFocused(true); + props.onFocus?.(e); + }} + onBlur={(e) => { + setInputFocused(false); + setCapsLockOn(false); + props.onBlur?.(e); + }} className={cn( "w-full py-2 px-3 text-sm text-text-main", "bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md", @@ -79,6 +124,19 @@ export default function Input({ {...props} />
+ {showCapsLock && ( +

+ + Caps Lock is on +

+ )} {error && (