Compare commits
No commits in common. "e89a4b25c0ad6d895e2a7a1dc086ff5ad57f1e9f" and "faa2e41be42883904ed7d3e5fe58c466da40563d" have entirely different histories.
e89a4b25c0
...
faa2e41be4
|
|
@ -1,63 +1,19 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Pause, Play } from 'lucide-react';
|
||||||
import { listDirectory, getWebDAVBaseUrl, type EntryInfo } from '../webdav';
|
import { listDirectory, getWebDAVBaseUrl, type EntryInfo } from '../webdav';
|
||||||
import { IMAGE_EXTS } from './MediaEntry';
|
import { IMAGE_EXTS } from './MediaEntry';
|
||||||
|
|
||||||
// Module-level cache: persists for the lifetime of the page session.
|
|
||||||
// Preloading via a detached Image warms the browser's HTTP cache, so setting
|
|
||||||
// the same src on a visible <img> later is instantaneous.
|
|
||||||
const imgCache = new Map<string, HTMLImageElement>();
|
|
||||||
|
|
||||||
function preloadImage(url: string, onLoad?: () => void) {
|
|
||||||
let el = imgCache.get(url);
|
|
||||||
if (!el) {
|
|
||||||
el = new Image();
|
|
||||||
imgCache.set(url, el);
|
|
||||||
el.src = url;
|
|
||||||
}
|
|
||||||
if (el.complete) {
|
|
||||||
onLoad?.();
|
|
||||||
} else if (onLoad) {
|
|
||||||
el.addEventListener('load', onLoad, { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DirectorySlideshow({ path }: Props) {
|
export default function DirectorySlideshow({ path }: Props) {
|
||||||
const [images, setImages] = useState<EntryInfo[]>([]);
|
const [images, setImages] = useState<EntryInfo[]>([]);
|
||||||
const [dotIdx, setDotIdx] = useState(0); // dot indicator (advances immediately)
|
const [idx, setIdx] = useState(0);
|
||||||
const [showIdx, setShowIdx] = useState(0); // displayed image (advances when loaded)
|
|
||||||
const [paused, setPaused] = useState(() => localStorage.getItem('slideshow.paused') === '1');
|
const [paused, setPaused] = useState(() => localStorage.getItem('slideshow.paused') === '1');
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
|
const touchTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
const touchTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
||||||
const pendingRef = useRef(-1); // target we are currently loading toward
|
|
||||||
const imagesRef = useRef<EntryInfo[]>([]);
|
|
||||||
const showIdxRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => { imagesRef.current = images; }, [images]);
|
|
||||||
useEffect(() => { showIdxRef.current = showIdx; }, [showIdx]);
|
|
||||||
|
|
||||||
// Navigate to target: dots move immediately, image waits until loaded.
|
|
||||||
const navigateTo = useCallback((target: number) => {
|
|
||||||
const imgs = imagesRef.current;
|
|
||||||
if (!imgs.length) return;
|
|
||||||
target = ((target % imgs.length) + imgs.length) % imgs.length;
|
|
||||||
pendingRef.current = target;
|
|
||||||
setDotIdx(target);
|
|
||||||
preloadImage(getWebDAVBaseUrl() + imgs[target].path, () => {
|
|
||||||
if (pendingRef.current === target) {
|
|
||||||
setShowIdx(target);
|
|
||||||
showIdxRef.current = target;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch directory listing and preload all images eagerly.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!path) { setImages([]); return; }
|
if (!path) { setImages([]); return; }
|
||||||
listDirectory(path)
|
listDirectory(path)
|
||||||
|
|
@ -67,48 +23,31 @@ export default function DirectorySlideshow({ path }: Props) {
|
||||||
const ext = e.name.split('.').pop()?.toLowerCase() ?? '';
|
const ext = e.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
return IMAGE_EXTS.has(ext);
|
return IMAGE_EXTS.has(ext);
|
||||||
});
|
});
|
||||||
const base = getWebDAVBaseUrl();
|
|
||||||
// Warm the cache for every image — no callback, fire and forget.
|
|
||||||
imgs.forEach(img => preloadImage(base + img.path));
|
|
||||||
setImages(imgs);
|
setImages(imgs);
|
||||||
|
|
||||||
const saved = parseInt(localStorage.getItem(`slideshow.idx:${path}`) ?? '0', 10);
|
const saved = parseInt(localStorage.getItem(`slideshow.idx:${path}`) ?? '0', 10);
|
||||||
const start = imgs.length > 0 ? Math.min(saved, imgs.length - 1) : 0;
|
setIdx(imgs.length > 0 ? Math.min(saved, imgs.length - 1) : 0);
|
||||||
// Navigate to the initial index through the normal path so the
|
|
||||||
// onLoad guard is respected even on first display.
|
|
||||||
pendingRef.current = start;
|
|
||||||
setDotIdx(start);
|
|
||||||
preloadImage(base + (imgs[start]?.path ?? ''), () => {
|
|
||||||
if (pendingRef.current === start) {
|
|
||||||
setShowIdx(start);
|
|
||||||
showIdxRef.current = start;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(() => setImages([]));
|
.catch(() => setImages([]));
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
// Persist dot position.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!images.length) return;
|
|
||||||
localStorage.setItem(`slideshow.idx:${path}`, String(dotIdx));
|
|
||||||
}, [dotIdx, path, images.length]);
|
|
||||||
|
|
||||||
// Auto-advance: fires 4 s after the previous image actually appeared.
|
|
||||||
useEffect(() => {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
if (images.length <= 1 || paused) return;
|
if (images.length <= 1 || paused) return;
|
||||||
intervalRef.current = setInterval(() => {
|
const t = setInterval(() => setIdx(i => (i + 1) % images.length), 4000);
|
||||||
navigateTo(showIdxRef.current + 1);
|
return () => clearInterval(t);
|
||||||
}, 4000);
|
}, [images.length, paused]);
|
||||||
return () => clearInterval(intervalRef.current);
|
|
||||||
}, [images.length, paused, navigateTo]);
|
useEffect(() => {
|
||||||
|
if (images.length === 0) return;
|
||||||
|
localStorage.setItem(`slideshow.idx:${path}`, String(idx));
|
||||||
|
}, [idx, path, images.length]);
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(touchTimer.current), []);
|
useEffect(() => () => clearTimeout(touchTimer.current), []);
|
||||||
|
|
||||||
if (images.length === 0) return null;
|
if (images.length === 0) return null;
|
||||||
const currentImage = images[showIdx];
|
|
||||||
if (!currentImage) return null;
|
const current = images[idx];
|
||||||
|
const prev = () => setIdx(i => (i - 1 + images.length) % images.length);
|
||||||
|
const next = () => setIdx(i => (i + 1) % images.length);
|
||||||
|
|
||||||
const handleTouch = () => {
|
const handleTouch = () => {
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
|
|
@ -121,44 +60,42 @@ export default function DirectorySlideshow({ path }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 rounded-lg overflow-hidden">
|
<div className="mb-3 rounded-lg overflow-hidden">
|
||||||
<div className="relative group h-48" onPointerDown={handleTouch}>
|
<div className="relative group h-48" onPointerDown={handleTouch}>
|
||||||
{/* No key prop — keep the element mounted across slides so the browser
|
|
||||||
applies the cached src synchronously with no blank flash. */}
|
|
||||||
<img
|
<img
|
||||||
src={getWebDAVBaseUrl() + currentImage.path}
|
key={current.path}
|
||||||
alt={currentImage.name}
|
src={getWebDAVBaseUrl() + current.path}
|
||||||
|
alt={current.name}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
<>
|
<>
|
||||||
|
{/* Prev / next */}
|
||||||
<div className={`absolute inset-0 flex items-center justify-between px-2 pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
<div className={`absolute inset-0 flex items-center justify-between px-2 pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
||||||
<button onClick={() => navigateTo(dotIdx - 1)} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
<button onClick={prev} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
||||||
<ChevronLeft className="w-5 h-5" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => navigateTo(dotIdx + 1)} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
<button onClick={next} className="pointer-events-auto p-1 rounded-full bg-black/50 text-white hover:bg-black/70">
|
||||||
<ChevronRight className="w-5 h-5" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pause / play — centered */}
|
||||||
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
<div className={`absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-200 ${controlsVisible}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPaused(p => {
|
onClick={() => setPaused(p => { const next = !p; localStorage.setItem('slideshow.paused', next ? '1' : '0'); return next; })}
|
||||||
const next = !p;
|
|
||||||
localStorage.setItem('slideshow.paused', next ? '1' : '0');
|
|
||||||
return next;
|
|
||||||
})}
|
|
||||||
className="pointer-events-auto p-3 rounded-full bg-black/50 text-white hover:bg-black/70"
|
className="pointer-events-auto p-3 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
>
|
>
|
||||||
{paused ? <Play className="w-7 h-7" /> : <Pause className="w-7 h-7" />}
|
{paused ? <Play className="w-7 h-7" /> : <Pause className="w-7 h-7" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dots */}
|
||||||
<div className={`absolute bottom-0 left-0 right-0 flex justify-center gap-1.5 py-1.5 transition-opacity duration-200 ${controlsVisible}`}>
|
<div className={`absolute bottom-0 left-0 right-0 flex justify-center gap-1.5 py-1.5 transition-opacity duration-200 ${controlsVisible}`}>
|
||||||
{images.map((_, i) => (
|
{images.map((_, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
onClick={() => navigateTo(i)}
|
onClick={() => setIdx(i)}
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-colors ${i === dotIdx ? 'bg-white' : 'bg-white/40'}`}
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${i === idx ? 'bg-white' : 'bg-white/40'}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -40,83 +40,6 @@ const SAVED_INDICATOR_MS = 1500;
|
||||||
|
|
||||||
export type SettingsConfig = Record<string, any>;
|
export type SettingsConfig = Record<string, any>;
|
||||||
|
|
||||||
const BUNDLED_VERSION: string = (bundledConfig as any).version ?? '0';
|
|
||||||
|
|
||||||
// ─── Config migration ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Recursively fill missing keys/sub-objects from `defaults` into `target`. */
|
|
||||||
function deepMergeDefaults(target: Record<string, any>, defaults: Record<string, any>): Record<string, any> {
|
|
||||||
const result = { ...target };
|
|
||||||
for (const [k, v] of Object.entries(defaults)) {
|
|
||||||
if (k === 'version') continue;
|
|
||||||
if (!(k in result)) {
|
|
||||||
result[k] = v;
|
|
||||||
} else if (
|
|
||||||
v !== null && typeof v === 'object' && !Array.isArray(v) &&
|
|
||||||
result[k] !== null && typeof result[k] === 'object' && !Array.isArray(result[k])
|
|
||||||
) {
|
|
||||||
result[k] = deepMergeDefaults(result[k] as Record<string, any>, v as Record<string, any>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrate a config loaded from an older format to the current structure.
|
|
||||||
*
|
|
||||||
* Handles:
|
|
||||||
* - Top-level renames: general→preferences, iec_config→settings
|
|
||||||
* - Sub-key renames inside settings: drive_rom→drive_roms
|
|
||||||
* - Moving peripherals into devices: hardware.userport, cassette, bluetooth, modem
|
|
||||||
* - Old devices.json shape: { iec: { devices:{} } } → devices.iec
|
|
||||||
* - Deep-merging any missing keys from the bundled defaults
|
|
||||||
*/
|
|
||||||
function migrateConfig(raw: Record<string, any>): Record<string, any> {
|
|
||||||
let c = { ...raw };
|
|
||||||
|
|
||||||
// Top-level key renames
|
|
||||||
if ('general' in c && !('preferences' in c)) { c.preferences = c.general; delete c.general; }
|
|
||||||
if ('iec_config' in c && !('settings' in c)) { c.settings = c.iec_config; delete c.iec_config; }
|
|
||||||
|
|
||||||
// settings sub-key rename
|
|
||||||
if (c.settings && typeof c.settings === 'object') {
|
|
||||||
const s = { ...c.settings } as Record<string, any>;
|
|
||||||
if ('drive_rom' in s && !('drive_roms' in s)) { s.drive_roms = s.drive_rom; delete s.drive_rom; }
|
|
||||||
c.settings = s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old devices.json merged as config.iec = { devices:{} } — move to config.devices.iec
|
|
||||||
if (c.iec && typeof c.iec === 'object') {
|
|
||||||
if (!c.devices) c.devices = {};
|
|
||||||
const oldIec = c.iec as Record<string, any>;
|
|
||||||
if ('devices' in oldIec && !c.devices.iec) c.devices.iec = oldIec.devices;
|
|
||||||
delete c.iec;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move peripheral config into devices
|
|
||||||
if (!c.devices) c.devices = {};
|
|
||||||
if (c.hardware && typeof c.hardware === 'object') {
|
|
||||||
const hw = c.hardware as Record<string, any>;
|
|
||||||
if (hw.userport !== undefined && c.devices.userport === undefined) c.devices.userport = hw.userport;
|
|
||||||
if (hw.ps2 !== undefined && c.devices.ps2 === undefined) c.devices.ps2 = hw.ps2;
|
|
||||||
delete c.hardware;
|
|
||||||
}
|
|
||||||
if (c.cassette && !c.devices.cassette) { c.devices.cassette = c.cassette; delete c.cassette; }
|
|
||||||
if (c.bluetooth && !c.devices.bluetooth) { c.devices.bluetooth = c.bluetooth; delete c.bluetooth; }
|
|
||||||
if (c.modem && !c.devices.modem) { c.devices.modem = c.modem; delete c.modem; }
|
|
||||||
if ('boip' in c) delete c.boip;
|
|
||||||
|
|
||||||
// Fill any missing keys/sub-objects from bundled defaults
|
|
||||||
c = deepMergeDefaults(c, bundledConfig as Record<string, any>);
|
|
||||||
|
|
||||||
// Stamp the current version
|
|
||||||
c.version = BUNDLED_VERSION;
|
|
||||||
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── I/O helpers ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Try primary path first, fall back to secondary. Returns null if both fail. */
|
/** Try primary path first, fall back to secondary. Returns null if both fail. */
|
||||||
async function getTextWithFallback(primary: string, fallback: string): Promise<string | null> {
|
async function getTextWithFallback(primary: string, fallback: string): Promise<string | null> {
|
||||||
try { return await getFileContents(primary).then(b => b.text()); } catch { /* try fallback */ }
|
try { return await getFileContents(primary).then(b => b.text()); } catch { /* try fallback */ }
|
||||||
|
|
@ -124,14 +47,8 @@ async function getTextWithFallback(primary: string, fallback: string): Promise<s
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadSettingsResult {
|
|
||||||
config: SettingsConfig;
|
|
||||||
/** True when the loaded config was an older version and has been migrated. */
|
|
||||||
migrated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read both config files from the WebDAV server and merge them. Returns null on failure. */
|
/** Read both config files from the WebDAV server and merge them. Returns null on failure. */
|
||||||
export async function readSettings(): Promise<ReadSettingsResult | null> {
|
export async function readSettings(): Promise<SettingsConfig | null> {
|
||||||
try {
|
try {
|
||||||
// Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/
|
// Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/
|
||||||
const [configText, devicesText] = await Promise.all([
|
const [configText, devicesText] = await Promise.all([
|
||||||
|
|
@ -139,10 +56,10 @@ export async function readSettings(): Promise<ReadSettingsResult | null> {
|
||||||
getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH),
|
getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH),
|
||||||
]);
|
]);
|
||||||
if (!configText) return null;
|
if (!configText) return null;
|
||||||
let config = JSON.parse(configText) as Record<string, any>;
|
const config = JSON.parse(configText);
|
||||||
if (!config || typeof config !== 'object') return null;
|
if (!config || typeof config !== 'object') return null;
|
||||||
if (devicesText) {
|
if (devicesText) {
|
||||||
const devices = JSON.parse(devicesText) as Record<string, any>;
|
const devices = JSON.parse(devicesText);
|
||||||
if (devices && typeof devices === 'object') {
|
if (devices && typeof devices === 'object') {
|
||||||
// One-level deep merge: devices.json keys are merged into matching
|
// One-level deep merge: devices.json keys are merged into matching
|
||||||
// top-level objects in config (e.g. devices.devices.iec → config.devices.iec).
|
// top-level objects in config (e.g. devices.devices.iec → config.devices.iec).
|
||||||
|
|
@ -156,9 +73,7 @@ export async function readSettings(): Promise<ReadSettingsResult | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const needsMigration = !config.version || config.version !== BUNDLED_VERSION;
|
return config as SettingsConfig;
|
||||||
if (needsMigration) config = migrateConfig(config);
|
|
||||||
return { config: config as SettingsConfig, migrated: needsMigration };
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -249,30 +164,10 @@ export function useSettings(): UseSettingsResult {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setSaveStatus('loading');
|
setSaveStatus('loading');
|
||||||
const result = await readSettings();
|
const fromServer = await readSettings();
|
||||||
if (result) {
|
if (fromServer) {
|
||||||
configRef.current = result.config;
|
configRef.current = fromServer;
|
||||||
setConfigState(result.config);
|
setConfigState(fromServer);
|
||||||
if (result.migrated) {
|
|
||||||
// Persist the migrated config immediately so the device is updated.
|
|
||||||
setSaveStatus('saving');
|
|
||||||
try {
|
|
||||||
await writeSettings(result.config);
|
|
||||||
setSaveStatus('saved');
|
|
||||||
savedTimerRef.current = window.setTimeout(() => {
|
|
||||||
setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
|
|
||||||
}, SAVED_INDICATOR_MS);
|
|
||||||
} catch {
|
|
||||||
// If the write fails, mark dirty so the save-status badge appears.
|
|
||||||
dirtyRef.current = true;
|
|
||||||
pendingRef.current = 1;
|
|
||||||
setPendingCount(1);
|
|
||||||
setSaveStatus('unsaved');
|
|
||||||
}
|
|
||||||
loadedRef.current = true;
|
|
||||||
setLoaded(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
{
|
{
|
||||||
"version": "0.5.0",
|
|
||||||
"preferences": {
|
"preferences": {
|
||||||
"appearance": "auto",
|
"appearance": "auto",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
|
|
|
||||||
78
webdav3.py
78
webdav3.py
|
|
@ -51,56 +51,6 @@ _ws_clients: set = set() # connected WebSocket sockets
|
||||||
# Debug message ( True / False )
|
# Debug message ( True / False )
|
||||||
sys_debug = False
|
sys_debug = False
|
||||||
|
|
||||||
# ── Logging ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _log(tag: str, msg: str):
|
|
||||||
print(f'[{strftime("%H:%M:%S")}] [{tag}] {msg}', flush=True)
|
|
||||||
|
|
||||||
# ── WebSocket broadcast (module-level so the REPL can call it) ─────────────────
|
|
||||||
|
|
||||||
def ws_broadcast(text: str) -> int:
|
|
||||||
"""Send a UTF-8 text frame to every connected WebSocket client."""
|
|
||||||
payload = text.encode('utf-8')
|
|
||||||
n = len(payload)
|
|
||||||
if n < 126:
|
|
||||||
header = bytes([0x81, n])
|
|
||||||
elif n < 65536:
|
|
||||||
header = bytes([0x81, 126]) + struct.pack('>H', n)
|
|
||||||
else:
|
|
||||||
header = bytes([0x81, 127]) + struct.pack('>Q', n)
|
|
||||||
frame = header + payload
|
|
||||||
with _ws_lock:
|
|
||||||
clients = set(_ws_clients)
|
|
||||||
sent = 0
|
|
||||||
for sock in clients:
|
|
||||||
try:
|
|
||||||
sock.send(frame)
|
|
||||||
sent += 1
|
|
||||||
except Exception:
|
|
||||||
with _ws_lock:
|
|
||||||
_ws_clients.discard(sock)
|
|
||||||
return sent
|
|
||||||
|
|
||||||
# ── REPL ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _repl_thread():
|
|
||||||
"""Read lines from stdin and broadcast them to all connected WS clients."""
|
|
||||||
_log('REPL', 'ready — type a message and press Enter to broadcast, Ctrl-D to quit')
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
sys.stdout.write('> ')
|
|
||||||
sys.stdout.flush()
|
|
||||||
line = sys.stdin.readline()
|
|
||||||
if not line: # EOF / Ctrl-D
|
|
||||||
break
|
|
||||||
line = line.rstrip('\r\n')
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
n = ws_broadcast(line)
|
|
||||||
_log('REPL', f'sent to {n} client(s): {line}')
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
break
|
|
||||||
|
|
||||||
# get localhost IP address
|
# get localhost IP address
|
||||||
def get_localip():
|
def get_localip():
|
||||||
try:
|
try:
|
||||||
|
|
@ -754,7 +704,6 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
||||||
return message # echo by default
|
return message # echo by default
|
||||||
|
|
||||||
def _ws_broadcast(self, payload: bytes):
|
def _ws_broadcast(self, payload: bytes):
|
||||||
# Thin wrapper kept for binary payloads; text goes through ws_broadcast().
|
|
||||||
with _ws_lock:
|
with _ws_lock:
|
||||||
clients = set(_ws_clients)
|
clients = set(_ws_clients)
|
||||||
for sock in clients:
|
for sock in clients:
|
||||||
|
|
@ -788,7 +737,7 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
||||||
sock.settimeout(None)
|
sock.settimeout(None)
|
||||||
with _ws_lock:
|
with _ws_lock:
|
||||||
_ws_clients.add(sock)
|
_ws_clients.add(sock)
|
||||||
_log('WS', f'{client} connected (clients: {len(_ws_clients)})')
|
print(f'[WS] {client} connected (total: {len(_ws_clients)})')
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
b0, b1 = self._ws_recv(sock, 2)
|
b0, b1 = self._ws_recv(sock, 2)
|
||||||
|
|
@ -804,23 +753,23 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
||||||
if masked:
|
if masked:
|
||||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||||
if opcode == 0x8: # close
|
if opcode == 0x8: # close
|
||||||
_log('WS', f'{client} close frame')
|
print(f'[WS] {client} closed')
|
||||||
sock.send(b'\x88\x00')
|
sock.send(b'\x88\x00')
|
||||||
break
|
break
|
||||||
elif opcode == 0x9: # ping → pong
|
elif opcode == 0x9: # ping → pong
|
||||||
sock.send(bytes([0x8A, len(payload)]) + payload)
|
sock.send(bytes([0x8A, len(payload)]) + payload)
|
||||||
elif opcode in (0x1, 0x2): # text or binary
|
elif opcode in (0x1, 0x2): # text or binary
|
||||||
message = payload.decode('utf-8', errors='replace')
|
message = payload.decode('utf-8', errors='replace')
|
||||||
_log('WS', f'{client} recv: {message}')
|
print(f'[WS] {client} recv ({length}b): {message}')
|
||||||
response = self.ws_process_message(message)
|
response = self.ws_process_message(message)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
ws_broadcast(response)
|
self._ws_broadcast(response.encode('utf-8'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
with _ws_lock:
|
with _ws_lock:
|
||||||
_ws_clients.discard(sock)
|
_ws_clients.discard(sock)
|
||||||
_log('WS', f'{client} disconnected (clients: {len(_ws_clients)})')
|
print(f'[WS] {client} disconnected (total: {len(_ws_clients)})')
|
||||||
|
|
||||||
def do_GET(self, onlyhead=False):
|
def do_GET(self, onlyhead=False):
|
||||||
if (self.path == '/ws'
|
if (self.path == '/ws'
|
||||||
|
|
@ -953,16 +902,9 @@ class DAVRequestHandler(BaseHTTPRequestHandler):
|
||||||
break
|
break
|
||||||
return (path, elem)
|
return (path, elem)
|
||||||
|
|
||||||
|
# disable log info output to screen
|
||||||
def log_message(self,format,*args):
|
def log_message(self,format,*args):
|
||||||
# args: requestline, status_code, content_length (from log_request)
|
pass
|
||||||
try:
|
|
||||||
parts = args[0].split() # e.g. ['GET', '/path', 'HTTP/1.1']
|
|
||||||
method = parts[0]
|
|
||||||
path = urllib.parse.unquote(parts[1]) if len(parts) > 1 else '?'
|
|
||||||
status = args[1]
|
|
||||||
_log('DAV', f'{method} {path} → {status}')
|
|
||||||
except Exception:
|
|
||||||
_log('DAV', format % args) # fallback for unexpected call shapes
|
|
||||||
|
|
||||||
class BufWriter:
|
class BufWriter:
|
||||||
def __init__(self, w, debug=False):
|
def __init__(self, w, debug=False):
|
||||||
|
|
@ -1022,9 +964,7 @@ if __name__ == '__main__':
|
||||||
# **** Change First ./ to your dir , etc :/mnt/flash/public, d:/share_file/
|
# **** Change First ./ to your dir , etc :/mnt/flash/public, d:/share_file/
|
||||||
root = DirCollection('files/', '/')
|
root = DirCollection('files/', '/')
|
||||||
httpd = DAVServer(server_address, DAVRequestHandler, root, userpwd)
|
httpd = DAVServer(server_address, DAVRequestHandler, root, userpwd)
|
||||||
repl = threading.Thread(target=_repl_thread, daemon=True, name='ws-repl')
|
|
||||||
repl.start()
|
|
||||||
try:
|
try:
|
||||||
httpd.serve_forever()
|
httpd.serve_forever() # todo: add some control over starting and stopping the server
|
||||||
except:
|
except:
|
||||||
_log('SRV', 'stopped')
|
print('# WebDav Server Stop.')
|
||||||
Loading…
Reference in New Issue
Block a user