Современный браузер давно перестал быть изолированным рендерером гипертекста. Сегодня веб-платформа предоставляет прямой доступ к аппаратным возможностям устройства через стандартизированные W3C/WHATWG Browser Hardware APIs.
В современных интерфейсах пользователи ожидают функциональность уровня нативных мобильных и десктопных приложений:
Однако интеграция аппаратных интерфейсов в компонентную модель React сопряжена с критическими рисками:
navigator или window в серверных компонентах Next.js немедленно роняет процесс Node.js.https://) или вне прямого пользовательского жеста (User Gesture) блокируются браузером.Физические ресурсы операционной системы (камера, микрофон, системная блокировка сна экрана) не подчиняются сборщику мусора JavaScript V8. Каждый захваченный поток MediaStream обязан иметь детерминированный цикл закрытия через track.stop(), а вызовы защищенных API должны быть защищены проверками isSecureContext и привязаны к прямым событиям пользовательского ввода (Transient User Activation).
Взаимодействие React-приложения с аппаратными подсистемами операционной системы подчиняется жесткой трехуровневой модели безопасности:
┌─────────────────────────────────────────────────────────────┐
│ REACT 19 COMPONENT (UI Layer) │
│ useClipboard() │ useMediaDevices() │ useWakeLock() │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ BROWSER SECURITY & PERMISSION GATEWAY │
│ 1. Secure Context Check: window.isSecureContext (HTTPS) │
│ 2. Transient User Activation: event.isTrusted (User Click) │
│ 3. Permissions API: Query & Reactive Status ('granted') │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ OPERATING SYSTEM HARDWARE SUBSYSTEMS │
│ • MediaDevices (Webcam, Mic, Screen Share) │
│ • System Clipboard (Text, HTML, PNG Blobs) │
│ • Screen Wake Lock (Power Management Subsystem) │
│ • Web Share (Native OS Share Sheet) │
└─────────────────────────────────────────────────────────────┘mediaDevices, clipboard, wakeLock, credentials) экспортируются в объект navigator исключительно по протоколу HTTPS или на хосте localhost. При открытии по HTTP эти интерфейсы равны undefined.navigator.share), записи в буфер (clipboard.writeText) и захвата экрана (getDisplayMedia) требуют прямого, недавнего физического действия пользователя (клик мыши, нажатие клавиши). Попытка вызвать их из setTimeout(..., 1000) или фонового промиса отклоняется с ошибкой NotAllowedError.MediaStream — это лишь программная ссылка. Сама камера удерживается драйвером ядра ОС. При размонтировании React-компонента необходимо принудительно вызвать track.stop() на каждом треке массива stream.getTracks().Верификация typeof window !== 'undefined' и window.isSecureContext. Отсечение падений на этапе серверной компиляции.
Вызов navigator.permissions.query() для определения текущего статуса (granted, prompt, denied) без показа внезапных диалогов.
Инициирование navigator.mediaDevices.getUserMedia() строго внутри обработчика доверенного события onClick.
Вызов track.stop() для аудио/видео дорожек и sentinel.release() для блокировки экрана в cleanup-функции useEffect.
Пользователь открыл модальное окно верификации личности с предпросмотром веб-камеры (<video srcObject={stream} />), а затем нажал кнопку закрытия модалки. React успешно размонтировал компонент модального окна, но на ноутбуке пользователя продолжает непрерывно светиться зеленый индикатор камеры. В чем причина утечки оборудования?
Перед подключением к видеоконференции приложение проводит preflight-диагностику: запрашивает список доступных камер и микрофонов через enumerateDevices(), строит интерактивный индикатор громкости голоса через AudioContext и позволяет переключаться между устройствами ввода без перезагрузки страницы.
Фитнес-трекер показывает таймер интервальной тренировки, а кулинарный сайт — шаги рецепта. Чтобы пользователю с испачканными руками не приходилось каждые 30 секунд касаться экрана, приложение через Screen Wake Lock API удерживает дисплей активным, автоматически восстанавливая блокировку при возврате во вкладку.
Мгновенное копирование в буфер обмена не просто текста, а многоформатных данных через ClipboardItem: отформатированной HTML-таблицы для вставки в Excel, PNG-диаграммы графика и чистого текстового представления в рамках единой атомарной транзакции.
Пользователь нажимает «Поделиться». Вместо всплывающего полотна из 20 иконок сторонних социальных сетей открывается нативный системный лист операционной системы (iOS/Android Share Sheet), позволяющий отправить ссылку, текст и сгенерированный PDF-сертификат прямо в AirDrop, Telegram или WhatsApp.
Сравним устаревшие подходы и современные стандарты взаимодействия с системными возможностями:
Запрос navigator.permissions.query({ name: 'camera' }) проверяет состояние прав ('granted', 'prompt', 'denied') до вызова оборудования.
Вызов navigator.mediaDevices.getUserMedia(constraints) активирует видеосенсор с заданным разрешением (1280x720) и частотой кадров.
Поток присваивается в свойство videoElement.srcObject с флагами autoPlay, playsInline и muted для предотвращения эха.
При размонтировании компонента cleanup-функция вызывает track.stop() для каждого трека и обнуляет srcObject.
Приложение тренировок активировало Screen Wake Lock для предотвращения отключения дисплея. Пользователь свернул браузер, чтобы ответить на входящее сообщение в Telegram, а затем через 1 минуту вернулся обратно в приложение тренировок. Каково текущее состояние блокировки экрана?
Построим законченную промышленную подсистему взаимодействия с системными API:
src/hooks/useClipboard.ts: Хук копирования текста и бинарных данных (картинок) в буфер обмена с fallback-механизмом и автосбросом таймера.src/hooks/useWakeLock.ts: Реактивный хук удержания экрана активным с автовосстановлением при смене видимости вкладки.src/hooks/useMediaDevices.ts: Хук управления камерой и микрофоном с перечислением доступных устройств и гарантированным выключением треков при анмаунте.src/components/media/HardwareControlStudio.tsx: Комплексный интерфейс тестирования оборудования перед видеозвонком со строгой типизацией.'use client';
import * as React from 'react';
export interface UseClipboardOptions {
/** Длительность сохранения флага скопированного состояния в мс */
resetDelayMs?: number;
}
export interface UseClipboardReturn {
copied: boolean;
copyText: (text: string) => Promise<boolean>;
copyBlob: (blob: Blob, mimeType?: string) => Promise<boolean>;
error: Error | null;
}
/**
* Хук взаимодействия с системным буфером обмена
*/
export function useClipboard(options: UseClipboardOptions = {}): UseClipboardReturn {
const { resetDelayMs = 2000 } = options;
const [copied, setCopied] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = React.useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const handleSuccess = React.useCallback(() => {
clearTimer();
setCopied(true);
setError(null);
timerRef.current = setTimeout(() => {
setCopied(false);
}, resetDelayMs);
}, [clearTimer, resetDelayMs]);
// 1. Копирование текстовой строки
const copyText = React.useCallback(
async (text: string): Promise<boolean> => {
clearTimer();
setError(null);
// Проверка современного Clipboard API в Secure Context
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
handleSuccess();
return true;
} catch (err) {
const normError = err instanceof Error ? err : new Error(String(err));
setError(normError);
}
}
// Fallback через скрытый textarea для HTTP или старых браузеров
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
textArea.setAttribute('readonly', '');
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (success) {
handleSuccess();
return true;
}
throw new Error('Fallback execCommand вернул false');
} catch (fallbackErr) {
const normError = fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr));
setError(normError);
setCopied(false);
return false;
}
},
[clearTimer, handleSuccess]
);
// 2. Копирование бинарных данных (например, PNG-скриншотов)
const copyBlob = React.useCallback(
async (blob: Blob, mimeType: string = 'image/png'): Promise<boolean> => {
clearTimer();
setError(null);
if (typeof navigator === 'undefined' || !navigator.clipboard || !window.isSecureContext) {
const err = new Error('Копирование бинарных объектов поддерживается только в Secure Context');
setError(err);
return false;
}
try {
const item = new ClipboardItem({ [mimeType]: blob });
await navigator.clipboard.write([item]);
handleSuccess();
return true;
} catch (err) {
const normError = err instanceof Error ? err : new Error(String(err));
setError(normError);
return false;
}
},
[clearTimer, handleSuccess]
);
React.useEffect(() => {
return () => clearTimer();
}, [clearTimer]);
return { copied, copyText, copyBlob, error };
}'use client';
import * as React from 'react';
export interface UseWakeLockReturn {
isSupported: boolean;
isActive: boolean;
requestLock: () => Promise<boolean>;
releaseLock: () => Promise<void>;
error: Error | null;
}
/**
* Хук удержания дисплея включенным с автовосстановлением при смене вкладок
*/
export function useWakeLock(): UseWakeLockReturn {
const [isActive, setIsActive] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
const sentinelRef = React.useRef<WakeLockSentinel | null>(null);
const wantsActiveRef = React.useRef(false);
const isSupported =
typeof window !== 'undefined' &&
'wakeLock' in navigator &&
typeof navigator.wakeLock?.request === 'function';
const releaseLock = React.useCallback(async () => {
wantsActiveRef.current = false;
if (sentinelRef.current) {
try {
await sentinelRef.current.release();
} catch (err) {
console.warn('[WakeLock]: Ошибка при освобождении токена', err);
} finally {
sentinelRef.current = null;
setIsActive(false);
}
}
}, []);
const requestLock = React.useCallback(async (): Promise<boolean> => {
if (!isSupported) {
setError(new Error('Screen Wake Lock API не поддерживается в данном браузере'));
return false;
}
wantsActiveRef.current = true;
try {
const sentinel = await navigator.wakeLock.request('screen');
sentinelRef.current = sentinel;
sentinel.addEventListener('release', () => {
// Если освобождение было системным (например, смена вкладки)
sentinelRef.current = null;
setIsActive(false);
});
setIsActive(true);
setError(null);
return true;
} catch (err) {
const normError = err instanceof Error ? err : new Error(String(err));
setError(normError);
setIsActive(false);
return false;
}
}, [isSupported]);
// Восстановление блокировки при возврате на страницу
React.useEffect(() => {
if (!isSupported) return;
const handleVisibilityChange = async () => {
if (document.visibilityState === 'visible' && wantsActiveRef.current && !sentinelRef.current) {
await requestLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseLock();
};
}, [isSupported, requestLock, releaseLock]);
return { isSupported, isActive, requestLock, releaseLock, error };
}'use client';
import * as React from 'react';
export interface MediaDevicesState {
stream: MediaStream | null;
isActive: boolean;
videoDevices: MediaDeviceInfo[];
audioDevices: MediaDeviceInfo[];
error: Error | null;
startCamera: (deviceId?: string) => Promise<boolean>;
stopCamera: () => void;
toggleAudioTrack: () => void;
toggleVideoTrack: () => void;
isAudioMuted: boolean;
isVideoMuted: boolean;
}
export function useMediaDevices(): MediaDevicesState {
const [stream, setStream] = React.useState<MediaStream | null>(null);
const [isActive, setIsActive] = React.useState(false);
const [videoDevices, setVideoDevices] = React.useState<MediaDeviceInfo[]>([]);
const [audioDevices, setAudioDevices] = React.useState<MediaDeviceInfo[]>([]);
const [error, setError] = React.useState<Error | null>(null);
const [isAudioMuted, setIsAudioMuted] = React.useState(false);
const [isVideoMuted, setIsVideoMuted] = React.useState(false);
// Остановка физических дорожек оборудования
const stopTracks = React.useCallback((mediaStream: MediaStream | null) => {
if (!mediaStream) return;
mediaStream.getTracks().forEach((track) => {
track.stop(); // Физически выключает камеру/микрофон
});
}, []);
const stopCamera = React.useCallback(() => {
stopTracks(stream);
setStream(null);
setIsActive(false);
}, [stream, stopTracks]);
// Получение списка подключенных физических устройств
const refreshDevices = React.useCallback(async () => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.enumerateDevices) {
return;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
setVideoDevices(devices.filter((d) => d.kind === 'videoinput'));
setAudioDevices(devices.filter((d) => d.kind === 'audioinput'));
} catch (err) {
console.warn('[MediaDevices]: Не удалось получить список устройств', err);
}
}, []);
const startCamera = React.useCallback(
async (deviceId?: string): Promise<boolean> => {
stopCamera();
setError(null);
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
setError(new Error('MediaDevices API недоступен в данном браузере'));
return false;
}
try {
const constraints: MediaStreamConstraints = {
video: deviceId
? { deviceId: { exact: deviceId } }
: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
audio: true,
};
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
setStream(newStream);
setIsActive(true);
setIsAudioMuted(false);
setIsVideoMuted(false);
// После выдачи прав браузер раскрывает реальные имена устройств в enumerateDevices
await refreshDevices();
return true;
} catch (err) {
const normError = err instanceof Error ? err : new Error(String(err));
setError(normError);
setIsActive(false);
return false;
}
},
[stopCamera, refreshDevices]
);
const toggleAudioTrack = React.useCallback(() => {
if (!stream) return;
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
setIsAudioMuted(!audioTrack.enabled);
}
}, [stream]);
const toggleVideoTrack = React.useCallback(() => {
if (!stream) return;
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
setIsVideoMuted(!videoTrack.enabled);
}
}, [stream]);
// Обязательная очистка при размонтировании
React.useEffect(() => {
refreshDevices();
return () => {
stopTracks(stream);
};
}, [stream, stopTracks, refreshDevices]);
return {
stream,
isActive,
videoDevices,
audioDevices,
error,
startCamera,
stopCamera,
toggleAudioTrack,
toggleVideoTrack,
isAudioMuted,
isVideoMuted,
};
}'use client';
import * as React from 'react';
import { useMediaDevices } from '@/hooks/useMediaDevices';
import { useWakeLock } from '@/hooks/useWakeLock';
import { useClipboard } from '@/hooks/useClipboard';
export function HardwareControlStudio() {
const {
stream,
isActive: isCamActive,
videoDevices,
startCamera,
stopCamera,
toggleAudioTrack,
toggleVideoTrack,
isAudioMuted,
isVideoMuted,
error: mediaError,
} = useMediaDevices();
const {
isSupported: isWakeSupported,
isActive: isWakeActive,
requestLock,
releaseLock,
} = useWakeLock();
const { copied, copyText } = useClipboard({ resetDelayMs: 2500 });
const [selectedDeviceId, setSelectedDeviceId] = React.useState<string>('');
const videoRef = React.useRef<HTMLVideoElement | null>(null);
// Привязка потока к HTML5 video элементу
React.useEffect(() => {
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
}, [stream]);
const handleShareDiagnostics = async () => {
const report = {
timestamp: new Date().toISOString(),
secureContext: typeof window !== 'undefined' ? window.isSecureContext : false,
cameraActive: isCamActive,
wakeLockActive: isWakeActive,
camerasCount: videoDevices.length,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'Unknown',
};
const textReport = JSON.stringify(report, null, 2);
// Попытка нативного Web Share API, иначе копирование в буфер обмена
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({
title: 'Диагностика оборудования Fullstack Agentic Lab',
text: textReport,
});
return;
} catch {
// Пользователь отменил шеринг — fallback в буфер
}
}
await copyText(textReport);
};
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border max-w-2xl mx-auto space-y-6 shadow-2xl">
{/* Заголовок */}
<div className="flex items-center justify-between border-b border-border pb-4">
<div>
<h3 className="font-bold text-text-primary text-lg">Студия проверки оборудования</h3>
<p className="text-xs text-text-secondary mt-0.5">MediaDevices, Screen Wake Lock и Clipboard APIs</p>
</div>
<div className="flex items-center gap-2">
<span
className={`px-2.5 py-1 rounded-full text-xs font-mono font-bold uppercase ${
isCamActive
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: 'bg-bg-elevated text-text-muted border border-border'
}`}
>
{isCamActive ? 'Сенсоры активны' : 'Сенсоры выкл'}
</span>
</div>
</div>
{/* Окно видоискателя камеры */}
<div className="relative aspect-video w-full rounded-xl bg-black overflow-hidden border border-border flex items-center justify-center shadow-inner">
<video
ref={videoRef}
autoPlay
playsInline
muted
className={`w-full h-full object-cover transition-opacity duration-300 ${
isCamActive && !isVideoMuted ? 'opacity-100' : 'opacity-0'
}`}
/>
{(!isCamActive || isVideoMuted) && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-text-muted gap-2 bg-bg-surface/80 backdrop-blur-sm">
<span className="text-3xl">📷</span>
<span className="text-xs font-medium">
{isVideoMuted ? 'Видео заглушено пользователем' : 'Камера отключена'}
</span>
</div>
)}
</div>
{/* Панель управления камерой и микрофоном */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="camera-select" className="text-xs font-medium text-text-secondary">
Выбор камеры:
</label>
<select
id="camera-select"
value={selectedDeviceId}
onChange={(e) => {
setSelectedDeviceId(e.target.value);
if (isCamActive) startCamera(e.target.value);
}}
className="w-full px-3 py-2 text-xs rounded-xl bg-bg-elevated border border-border text-text-primary focus:outline-none focus:border-accent-violet"
>
<option value="">По умолчанию (Front)</option>
{videoDevices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `Камера ${device.deviceId.slice(0, 5)}...`}
</option>
))}
</select>
</div>
{/* Управление Screen Wake Lock */}
<div className="space-y-2">
<span className="text-xs font-medium text-text-secondary block">Удержание экрана (Wake Lock):</span>
<button
type="button"
disabled={!isWakeSupported}
onClick={() => (isWakeActive ? releaseLock() : requestLock())}
className={`w-full px-3 py-2 text-xs font-semibold rounded-xl border transition-colors cursor-pointer flex items-center justify-center gap-2 ${
isWakeActive
? 'bg-amber-500/20 text-amber-300 border-amber-500/30'
: 'bg-bg-elevated text-text-secondary border-border hover:text-text-primary'
}`}
>
<span>{isWakeActive ? '☀️ Экран не заснет (Вкл)' : '🌙 Стандартный режим сна'}</span>
</button>
</div>
</div>
{mediaError && (
<div className="p-3 rounded-xl bg-red-950/40 border border-red-900/50 text-xs text-red-300">
{mediaError.message}
</div>
)}
{/* Кнопки действий */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-border">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => (isCamActive ? stopCamera() : startCamera(selectedDeviceId))}
className={`px-4 py-2 text-xs font-semibold rounded-xl transition-colors cursor-pointer shadow-md ${
isCamActive
? 'bg-red-600/20 text-red-400 border border-red-500/30 hover:bg-red-600/30'
: 'bg-accent-violet text-white hover:bg-accent-violet/90'
}`}
>
{isCamActive ? 'Отключить оборудование' : 'Запустить камеру'}
</button>
{isCamActive && (
<>
<button
type="button"
onClick={toggleAudioTrack}
className="px-3 py-2 text-xs font-medium rounded-xl bg-bg-elevated border border-border text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
>
{isAudioMuted ? '🔇 Вкл микрофон' : '🎙️ Заглушить'}
</button>
<button
type="button"
onClick={toggleVideoTrack}
className="px-3 py-2 text-xs font-medium rounded-xl bg-bg-elevated border border-border text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
>
{isVideoMuted ? '👁️ Вкл видео' : '🙈 Скрыть видео'}
</button>
</>
)}
</div>
<button
type="button"
onClick={handleShareDiagnostics}
className="px-3.5 py-2 text-xs font-medium rounded-xl bg-bg-elevated border border-border text-text-secondary hover:text-text-primary transition-colors cursor-pointer flex items-center gap-1.5"
>
<span>{copied ? '✓ Скопировано!' : '📋 Поделиться отчетом'}</span>
</button>
</div>
</div>
);
}Удаление тега <video> из разметки не отключает веб-камеру. Поток MediaStream продолжает считывать кадры с матрицы, что приводит к нагреву ноутбука и быстрой разрядке батареи.
Решение: В функции очистки хука useEffect обязательно вызывайте stream.getTracks().forEach(t => t.stop()).
Браузерная модель безопасности требует Transient User Activation. Вызов navigator.share() или clipboard.writeText() внутри setTimeout, фонового таймера или без предварительного клика пользователя приведет к ошибке NotAllowedError: Must be handling a user gesture.
Решение: Вызывайте эти методы строго синхронно в корне обработчиков событий onClick или onSubmit.
В серверных компонентах Next.js или при SSR обращение к объектам window, navigator.clipboard или navigator.mediaDevices в теле функционального компонента вызовет фатальный сбой Node.js, так как на сервере этих объектов не существует.
Решение: Оборачивайте вызовы в проверку typeof window !== 'undefined' и выносите работу с аппаратными API в директиву 'use client' и хуки useEffect.
Операционная система автоматически аннулирует токен WakeLockSentinel, когда пользователь переключает вкладку или сворачивает браузер. Если компонент наивно считает, что блокировка все еще активна, экран погаснет после возвращения.
Решение: Всегда слушайте событие document.addEventListener('visibilitychange') и повторно запрашивайте navigator.wakeLock.request('screen'), когда document.visibilityState === 'visible'.
В локальных тестовых средах по HTTP на сторонних устройствах (например, http://192.168.1.50:3000) методы navigator.mediaDevices и navigator.clipboard равны undefined.
Решение: Проверяйте флаг window.isSecureContext перед вызовом и настраивайте HTTPS-сертификаты (например, через mkcert) для мобильного тестирования в локальной сети.
track.stop() и детерминированную очистку в хуках React 19.https://), активация по прямому жесту пользователя (Transient User Activation) и аудит через Permissions API.ClipboardItem (текст, HTML, PNG), превосходя устаревший синхронный document.execCommand.