Сучасний браузер давно перестав бути лише ізольованим середовищем рендерингу гіпертексту. Сьогодні веб-платформа надає прямий доступ до апаратних можливостей пристрою через стандартизовані 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 та дозволяє перемикатися між пристроями введення без перезавантаження сторінки.
Фітнес-трекер відображає таймер інтервального тренування, а кулінарний сайт — кроки рецепта. Щоб користувачеві з забрудненими руками не доводилося щохвилини торкатися екрана, застосунок через 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 для запобігання вимкненню дисплея. Користувач згорнув браузер, щоб відповісти на вхідне повідомлення в месенджері, а потім через 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.