feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
81
server/web/src/context/AmbientMusicContext.tsx
Normal file
81
server/web/src/context/AmbientMusicContext.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ambientMusicPlayer,
|
||||
loadBgmEnabled,
|
||||
loadBgmVolume,
|
||||
} from '../audio/ambientMusic';
|
||||
|
||||
type AmbientMusicContextValue = {
|
||||
enabled: boolean;
|
||||
playing: boolean;
|
||||
volume: number;
|
||||
setEnabled: (v: boolean) => void;
|
||||
setVolume: (v: number) => void;
|
||||
togglePlay: () => void;
|
||||
};
|
||||
|
||||
const AmbientMusicContext = createContext<AmbientMusicContextValue | null>(null);
|
||||
|
||||
export function AmbientMusicProvider({ children }: { children: React.ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(loadBgmEnabled);
|
||||
const [playing, setPlaying] = useState(() => ambientMusicPlayer.isPlaying());
|
||||
const [volume, setVolumeState] = useState(loadBgmVolume);
|
||||
|
||||
const setEnabled = useCallback((v: boolean) => {
|
||||
ambientMusicPlayer.setEnabled(v);
|
||||
setEnabledState(v);
|
||||
if (v) ambientMusicPlayer.unlock();
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((v: number) => {
|
||||
ambientMusicPlayer.setVolume(v);
|
||||
setVolumeState(ambientMusicPlayer.getVolume());
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
ambientMusicPlayer.unlock();
|
||||
ambientMusicPlayer.togglePlay();
|
||||
setPlaying(ambientMusicPlayer.isPlaying());
|
||||
setEnabledState(ambientMusicPlayer.isEnabled());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return ambientMusicPlayer.subscribe(setPlaying);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
ambientMusicPlayer.setEnabled(enabled);
|
||||
ambientMusicPlayer.setVolume(volume);
|
||||
}, [enabled, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlock = () => ambientMusicPlayer.unlock();
|
||||
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
|
||||
window.addEventListener('keydown', unlock, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', unlock);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, playing, volume, setEnabled, setVolume, togglePlay }),
|
||||
[enabled, playing, volume, setEnabled, setVolume, togglePlay]
|
||||
);
|
||||
|
||||
return <AmbientMusicContext.Provider value={value}>{children}</AmbientMusicContext.Provider>;
|
||||
}
|
||||
|
||||
const noopAmbient: AmbientMusicContextValue = {
|
||||
enabled: false,
|
||||
playing: false,
|
||||
volume: 0,
|
||||
setEnabled: () => {},
|
||||
setVolume: () => {},
|
||||
togglePlay: () => {},
|
||||
};
|
||||
|
||||
export function useAmbientMusic() {
|
||||
const ctx = useContext(AmbientMusicContext);
|
||||
return ctx ?? noopAmbient;
|
||||
}
|
||||
35
server/web/src/context/SoundContext.test.tsx
Normal file
35
server/web/src/context/SoundContext.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { SoundProvider, SFX_INTERACTIVE_SELECTOR } from './SoundContext';
|
||||
import * as hapticModule from '../audio/hapticEngine';
|
||||
|
||||
describe('SoundProvider click cues', () => {
|
||||
const play = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
play.mockClear();
|
||||
vi.spyOn(hapticModule.hapticEngine, 'isEnabled').mockReturnValue(true);
|
||||
vi.spyOn(hapticModule.hapticEngine, 'play').mockImplementation(play);
|
||||
});
|
||||
|
||||
it('covers card-style interactive rows', () => {
|
||||
expect(SFX_INTERACTIVE_SELECTOR).toContain('.agent-list-item.compact-row');
|
||||
expect(SFX_INTERACTIVE_SELECTOR).toContain('.crucible-node-card');
|
||||
expect(SFX_INTERACTIVE_SELECTOR).toContain('.pt-agent-card');
|
||||
});
|
||||
|
||||
it('plays click on agent list row', () => {
|
||||
render(
|
||||
<SoundProvider>
|
||||
<div className="agent-list-item compact-row" data-testid="row">
|
||||
Fleet node
|
||||
</div>
|
||||
</SoundProvider>
|
||||
);
|
||||
fireEvent.click(document.querySelector('[data-testid="row"]')!);
|
||||
expect(play).toHaveBeenCalledWith('click');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,52 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { hapticEngine, loadSoundEnabled, loadSoundVolume, type SoundCue } from '../audio/hapticEngine';
|
||||
import { hoverSfxEngine, loadHoverEnabled, loadHoverVolume } from '../audio/hoverSfx';
|
||||
|
||||
type SoundContextValue = {
|
||||
enabled: boolean;
|
||||
volume: number;
|
||||
hoverEnabled: boolean;
|
||||
hoverVolume: number;
|
||||
setEnabled: (v: boolean) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setHoverEnabled: (v: boolean) => void;
|
||||
setHoverVolume: (v: number) => void;
|
||||
play: (cue: SoundCue) => void;
|
||||
preview: (cue?: SoundCue) => void;
|
||||
previewHover: () => void;
|
||||
};
|
||||
|
||||
const SoundContext = createContext<SoundContextValue | null>(null);
|
||||
|
||||
/** Elements that should emit the global UI click/nav cue (see SoundProvider listener). */
|
||||
export const SFX_INTERACTIVE_SELECTOR = [
|
||||
'button:not(:disabled)',
|
||||
'.btn:not(:disabled)',
|
||||
'[role="button"]:not([aria-disabled="true"])',
|
||||
'.nav-link',
|
||||
'.mobile-nav__link',
|
||||
'.mobile-nav__more-btn',
|
||||
'.agent-list-item.compact-row',
|
||||
'.crucible-node-card',
|
||||
'.crucible-group-item',
|
||||
'.pt-agent-card:not([style*="cursor: not-allowed"])',
|
||||
'.endpoint-chip:not(:disabled)',
|
||||
'.fleet-group-chip:not(:disabled)',
|
||||
].join(', ');
|
||||
|
||||
/** Elements that emit hover highlight SFX (debounced). */
|
||||
export const HOVER_INTERACTIVE_SELECTOR = [
|
||||
SFX_INTERACTIVE_SELECTOR,
|
||||
'.neon-card',
|
||||
'.card',
|
||||
'a[href]:not([data-sfx="off"])',
|
||||
].join(', ');
|
||||
|
||||
export function SoundProvider({ children }: { children: React.ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(loadSoundEnabled);
|
||||
const [volume, setVolumeState] = useState(loadSoundVolume);
|
||||
const [hoverEnabled, setHoverEnabledState] = useState(loadHoverEnabled);
|
||||
const [hoverVolume, setHoverVolumeState] = useState(loadHoverVolume);
|
||||
|
||||
const setEnabled = useCallback((v: boolean) => {
|
||||
hapticEngine.setEnabled(v);
|
||||
@@ -35,11 +67,31 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
|
||||
hapticEngine.play(cue);
|
||||
}, []);
|
||||
|
||||
const setHoverEnabled = useCallback((v: boolean) => {
|
||||
hoverSfxEngine.setEnabled(v);
|
||||
setHoverEnabledState(v);
|
||||
}, []);
|
||||
|
||||
const setHoverVolume = useCallback((v: number) => {
|
||||
hoverSfxEngine.setVolume(v);
|
||||
setHoverVolumeState(hoverSfxEngine.getVolume());
|
||||
}, []);
|
||||
|
||||
const previewHover = useCallback(() => {
|
||||
hoverSfxEngine.unlock();
|
||||
hoverSfxEngine.preview(enabled);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
hapticEngine.setEnabled(enabled);
|
||||
hapticEngine.setVolume(volume);
|
||||
}, [enabled, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
hoverSfxEngine.setEnabled(hoverEnabled);
|
||||
hoverSfxEngine.setVolume(hoverVolume);
|
||||
}, [hoverEnabled, hoverVolume]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlock = () => hapticEngine.unlock();
|
||||
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
|
||||
@@ -56,9 +108,7 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('[data-sfx="off"]')) return;
|
||||
const interactive = target.closest(
|
||||
'button:not(:disabled), .btn:not(:disabled), [role="button"]:not([aria-disabled="true"]), .nav-link, .mobile-nav__link, .mobile-nav__more-btn'
|
||||
);
|
||||
const interactive = target.closest(SFX_INTERACTIVE_SELECTOR);
|
||||
if (!interactive) return;
|
||||
const isNav =
|
||||
interactive.classList.contains('nav-link') ||
|
||||
@@ -69,9 +119,49 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
|
||||
return () => document.removeEventListener('click', onClick, true);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseOver = (e: MouseEvent) => {
|
||||
if (!hapticEngine.isEnabled() || !hoverSfxEngine.isEnabled()) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('[data-sfx="off"]')) return;
|
||||
const interactive = target.closest(HOVER_INTERACTIVE_SELECTOR);
|
||||
if (!interactive) return;
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (related && interactive.contains(related)) return;
|
||||
hoverSfxEngine.play(true);
|
||||
};
|
||||
document.addEventListener('mouseover', onMouseOver, true);
|
||||
return () => document.removeEventListener('mouseover', onMouseOver, true);
|
||||
}, [enabled, hoverEnabled]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, volume, setEnabled, setVolume, play, preview }),
|
||||
[enabled, volume, setEnabled, setVolume, play, preview]
|
||||
() => ({
|
||||
enabled,
|
||||
volume,
|
||||
hoverEnabled,
|
||||
hoverVolume,
|
||||
setEnabled,
|
||||
setVolume,
|
||||
setHoverEnabled,
|
||||
setHoverVolume,
|
||||
play,
|
||||
preview,
|
||||
previewHover,
|
||||
}),
|
||||
[
|
||||
enabled,
|
||||
volume,
|
||||
hoverEnabled,
|
||||
hoverVolume,
|
||||
setEnabled,
|
||||
setVolume,
|
||||
setHoverEnabled,
|
||||
setHoverVolume,
|
||||
play,
|
||||
preview,
|
||||
previewHover,
|
||||
]
|
||||
);
|
||||
|
||||
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
|
||||
@@ -80,10 +170,15 @@ export function SoundProvider({ children }: { children: React.ReactNode }) {
|
||||
const noopSound: SoundContextValue = {
|
||||
enabled: false,
|
||||
volume: 0,
|
||||
hoverEnabled: false,
|
||||
hoverVolume: 0,
|
||||
setEnabled: () => {},
|
||||
setVolume: () => {},
|
||||
setHoverEnabled: () => {},
|
||||
setHoverVolume: () => {},
|
||||
play: () => {},
|
||||
preview: () => {},
|
||||
previewHover: () => {},
|
||||
};
|
||||
|
||||
export function useSound() {
|
||||
|
||||
Reference in New Issue
Block a user