Files
Luke Gustafson 6c40d2f0cc v1.4.0 (#31)
* fix: register physical Tab key on iPadOS/iOS hardware keyboards (#14)

The native module only registered Shift+Tab but not bare Tab, so iOS
intercepted it for UI focus navigation. Register an unmodified Tab
UIKeyCommand with wantsPriorityOverSystemBehavior and handle both
Tab and Shift+Tab in the same JS branch.

Fixes Termix-SSH/Support#557

* fix: pass jumpHosts, forceKeyboardInteractive, and overrideCredentialUsername to terminal connection (#15)

These fields were defined on SSHHost but omitted from the terminal
hostConfig passthrough. Without jumpHosts the backend cannot route
through jump servers; without forceKeyboardInteractive hosts that
require keyboard-interactive auth fail silently; without
overrideCredentialUsername shared credentials use the wrong username.

Fixes Termix-SSH/Support#422
Fixes Termix-SSH/Support#638

* fix: improve mobile terminal scroll recovery (#17)

* Add mobile remote desktop sessions (#18)

* feat: add mobile remote desktop sessions

* feat: add remote desktop input controls

* feat: polish remote desktop controls

* feat: add mobile terminal font selection (#19)

* feat: add mobile shift tab hotkey (#20)

* feat: initial UI redesign

* feat: revamp server login system

* chore: update app icon/background color

* fix: remove unused fields in the settings tab

* feat: improve the user pin system and update all settings modals to use new ui

* fix: active server not updating unless app restarts

* feat: add version in settings

* feat: improve host page UI (updated host form and added credential management)

* fix: preserve composed iOS terminal input

* ci: restore mobile checks

* feat: rewrite of connection system for all types

* fix: preserve mobile jump hosts (#30)

* fix: guard android user ca trust (#29)

* fix: capture ios hardware tab key (#27)

* fix: reset terminal input after special keys (#28)

* fix: open oidc in system browser (#26)

* fix: preserve websocket auth context (#25)

* fix: keep none-auth terminal prompts alive (#23)

* fix: allow local network SSL in Android app (#21)

* feat: allow oidc passkeys

* feat: bundeled xterm into app and added docker loading screen

* feat: add 2fa, api key, and active sessions to settings and addressed multiple bug fixes and inconsistencies with termix web

* chore: update repo images and readme

* chore: update readme to split table for screenshots

* chore: rename ios to apple in build workflow

* fix: android build failure

* feat: add snippet management and fix several bugs

* fix: web login and oidc incorrect logic

* chore: run linter

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:49:50 -05:00

167 lines
5.4 KiB
TypeScript

import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as LocalAuthentication from "expo-local-authentication";
import * as SecureStore from "expo-secure-store";
/**
* App Lock — optional biometric / PIN gate when the app is opened or returns
* from background. Addresses Support issues #496 / #338 (account security on
* mobile). The PIN is stored in the OS secure store; the enabled flag in
* AsyncStorage.
*/
const ENABLED_KEY = "appLockEnabled";
const PIN_KEY = "appLockPin"; // SecureStore
const LOCK_DELAY_MS = 15_000; // re-lock if backgrounded longer than this
interface AppLockContextValue {
enabled: boolean;
locked: boolean;
hasBiometrics: boolean;
/** Enable app lock with a numeric PIN (biometrics used when available). */
enable: (pin: string) => Promise<void>;
/**
* Tear down app lock. Performs no verification — callers MUST authenticate
* the user (via `verifyPin` / `authenticateBiometrics`) first.
*/
disable: () => Promise<void>;
/** Compare a PIN against the stored one. Does NOT change lock state. */
verifyPin: (pin: string) => Promise<boolean>;
/**
* Prompt for biometrics (no device-passcode fallback). Does NOT change lock
* state — used for re-authentication in Settings.
*/
authenticateBiometrics: () => Promise<boolean>;
/** Attempt unlock via biometrics; unlocks on success. Returns success. */
unlockWithBiometrics: () => Promise<boolean>;
/** Attempt unlock via PIN; unlocks on success. Returns success. */
unlockWithPin: (pin: string) => Promise<boolean>;
}
const AppLockContext = createContext<AppLockContextValue | null>(null);
export function AppLockProvider({ children }: { children: React.ReactNode }) {
const [enabled, setEnabled] = useState(false);
const [locked, setLocked] = useState(false);
const [hasBiometrics, setHasBiometrics] = useState(false);
const backgroundedAt = useRef<number | null>(null);
const appState = useRef(AppState.currentState);
useEffect(() => {
(async () => {
const [isEnabled, hw, enrolled] = await Promise.all([
AsyncStorage.getItem(ENABLED_KEY),
LocalAuthentication.hasHardwareAsync().catch(() => false),
LocalAuthentication.isEnrolledAsync().catch(() => false),
]);
const on = isEnabled === "true";
setEnabled(on);
// Only treat biometrics as available when the device has a sensor AND a
// biometric is actually enrolled — otherwise the fingerprint button would
// appear but do nothing.
setHasBiometrics(!!hw && !!enrolled);
if (on) setLocked(true); // lock on cold start
})();
}, []);
// Re-lock when returning from background after the delay.
useEffect(() => {
const sub = AppState.addEventListener("change", (next: AppStateStatus) => {
const prev = appState.current;
appState.current = next;
if (!enabled) return;
if (next.match(/inactive|background/)) {
backgroundedAt.current = Date.now();
} else if (prev.match(/inactive|background/) && next === "active") {
const elapsed = Date.now() - (backgroundedAt.current ?? 0);
if (elapsed >= LOCK_DELAY_MS) setLocked(true);
}
});
return () => sub.remove();
}, [enabled]);
const enable = useCallback(async (pin: string) => {
await SecureStore.setItemAsync(PIN_KEY, pin);
await AsyncStorage.setItem(ENABLED_KEY, "true");
setEnabled(true);
setLocked(false);
}, []);
const disable = useCallback(async () => {
await SecureStore.deleteItemAsync(PIN_KEY).catch(() => {});
await AsyncStorage.setItem(ENABLED_KEY, "false");
setEnabled(false);
setLocked(false);
}, []);
// Prompt biometrics without changing lock state. Device-passcode fallback is
// disabled so iOS never shows the phone passcode prompt — the in-app PIN is
// the only fallback.
const authenticateBiometrics = useCallback(async () => {
try {
const enrolled = await LocalAuthentication.isEnrolledAsync();
if (!enrolled) return false;
const res = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock Termix",
disableDeviceFallback: true,
cancelLabel: "Use PIN",
});
return res.success;
} catch {
return false;
}
}, []);
const unlockWithBiometrics = useCallback(async () => {
const ok = await authenticateBiometrics();
if (ok) setLocked(false);
return ok;
}, [authenticateBiometrics]);
const verifyPin = useCallback(async (pin: string) => {
const stored = await SecureStore.getItemAsync(PIN_KEY).catch(() => null);
return !!stored && stored === pin;
}, []);
const unlockWithPin = useCallback(
async (pin: string) => {
const ok = await verifyPin(pin);
if (ok) setLocked(false);
return ok;
},
[verifyPin],
);
return (
<AppLockContext.Provider
value={{
enabled,
locked,
hasBiometrics,
enable,
disable,
verifyPin,
authenticateBiometrics,
unlockWithBiometrics,
unlockWithPin,
}}
>
{children}
</AppLockContext.Provider>
);
}
export function useAppLock(): AppLockContextValue {
const ctx = useContext(AppLockContext);
if (!ctx) throw new Error("useAppLock must be used within AppLockProvider");
return ctx;
}