commit a60305b7c88ce95afd964e7caaae6a6556600113
parent 39d83c4c86d93def618d729268185ff3faaafba3
Author: Jean Schurger <jean@schurger.org>
Date: Thu, 5 Nov 2026 11:24:03 -0500
Page : une seule zone « Déposer ou choisir un fichier », type détecté et lancement adapté
Remplace le sélecteur « cartouche / cassette » + champ fichier par une zone de dépôt (clic ou
glisser-déposer, aussi sur l'écran). Le type est détecté par l'extension puis le contenu :
cassette (.k7, .wav ; leader $55) -> ExelBASIC + auto-chargement ; cartouche (.bin/.rom ;
8/16/32 Ko) -> démarrage direct ; image Exelmémoire (.cram 16 Ko, image 64 Ko DCExel,
.bkp webmo = 514 octets d'en-tête + 16 Ko à l'envers) -> volume chargé puis rechargement ;
disquette (.fd, .td0) -> « pas encore prise en charge ». Message d'état explicite. Le bouton
« Démarrer » générique de dsp est masqué. Test : test/upload.mjs (injection CDP d'un fichier).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QyJB9yqUAYm3wRQi4uytj9
Diffstat:
2 files changed, 106 insertions(+), 8 deletions(-)
diff --git a/test/upload.mjs b/test/upload.mjs
@@ -0,0 +1,25 @@
+// Chargement de fichier unifié : injecte un fichier dans #media (comme un choix ou un dépôt), lit le
+// message d'état, capture l'écran. Pour une image Exelmémoire, vérifie le localStorage après rechargement.
+// node test/upload.mjs <fichier> [url] [secondes] [capture.png]
+import { open, sleep } from './cdp.mjs';
+import { resolve } from 'node:path';
+
+const file = resolve(process.argv[2]);
+const url = process.argv[3] ?? 'http://127.0.0.1:8195/';
+const secs = Number(process.argv[4] ?? 14);
+const shot = process.argv[5] ?? '/tmp/upload.png';
+const page = await open(url, { width: 900, height: 1100 });
+await sleep(6000);
+const doc = await page.send('DOM.getDocument', { depth: 1 });
+const { nodeId } = await page.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#media' });
+await page.send('DOM.setFileInputFiles', { files: [file], nodeId });
+await sleep(1500);
+const status1 = await page.evaluate("document.getElementById('status').textContent");
+console.log('état immédiat :', status1);
+await sleep(secs * 1000);
+const status2 = await page.evaluate("document.getElementById('status').textContent");
+const ls = await page.evaluate("(() => { try { return Object.keys(localStorage).filter((k) => k.startsWith('exl100.cram')).map((k) => k + '=' + localStorage.getItem(k).length).join(' '); } catch (e) { return ''; } })()");
+console.log('état final :', status2, '| cram localStorage :', ls || '(aucun)');
+await page.screenshot(shot);
+console.log('Capture :', shot);
+page.close();
diff --git a/web/shell.html.in b/web/shell.html.in
@@ -47,6 +47,9 @@
.toolbar { display: flex; flex-wrap: wrap; gap: 8px 12px; justify-content: center;
align-items: center; margin: 14px auto 16px; max-width: 900px; }
.toolbar label { color: var(--muted); font-size: .9rem; display: inline-flex; gap: 6px; align-items: center; }
+ .toolbar .drop { cursor: pointer; border: 1px dashed var(--muted); border-radius: 8px; padding: 6px 12px;
+ color: var(--fg, #ddd); transition: border-color .15s, background .15s; }
+ .toolbar .drop:hover, .toolbar .drop.over { border-color: #6cf; background: rgba(102, 204, 255, .08); }
.toolbar select, .toolbar input[type=file], .toolbar button {
background: #2b2d35; color: #fff; border: 1px solid #40434e; border-radius: 6px;
padding: 7px 10px; font: inherit; font-size: .92rem; cursor: pointer; }
@@ -143,16 +146,17 @@
<label class="hidden">ROM <input id="rom" type="file"></label>
<button id="pick" type="button" class="pick-btn" title="Parcourir les cartouches et cassettes">
📂 Choisir un programme…</button>
- <label>ou votre fichier
- <select id="mediatype">
- <option value="--tape">cartouche / cassette</option>
- <option value="--cart">cartouche (--cart)</option>
- <option value="--disk">disquette (--disk)</option>
- </select>
- <input id="media" type="file" accept=".bin,.rom,.cart,.k7,.wav">
+ <label id="drop" class="drop" title="Cassette (.k7, .wav), cartouche (.bin, .rom), image Exelmémoire (.cram, .bkp) ou disquette (.fd, .td0) : le type est détecté et le fichier lancé comme il faut. Vous pouvez aussi le déposer sur l'écran.">
+ 📄 Déposer ou choisir un fichier
+ <input id="media" type="file" accept=".bin,.rom,.cart,.k7,.wav,.cram,.bkp,.fd,.td0" hidden>
</label>
+ <select id="mediatype" hidden>
+ <option value="--tape">cartouche / cassette</option>
+ <option value="--cart">cartouche (--cart)</option>
+ <option value="--disk">disquette (--disk)</option>
+ </select>
<input id="extra" type="hidden" value="">
- <button id="start" title="Démarrer l'EXL 100 avec le média choisi">Démarrer</button>
+ <button id="start" hidden title="Démarrer l'EXL 100 avec le média choisi">Démarrer</button>
<details id="more" class="hidden"><summary>options</summary></details>
</div>
@@ -562,6 +566,75 @@
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
} catch (e) { statusEl.textContent = 'Export .cram impossible : ' + e; }
};
+ // Fichier de l'utilisateur : une seule zone (clic ou dépôt, aussi sur l'écran), type détecté par
+ // l'extension puis le contenu, puis lancé comme il faut : cassette = ExelBASIC + auto-chargement,
+ // cartouche = démarrage direct, image Exelmémoire (.cram 16 Ko, image 64 Ko DCExel, .bkp webmo =
+ // 514 octets d'en-tête + 16 Ko à l'envers) = volume chargé puis rechargement, disquette = étape à venir.
+ const CART_SIZES = new Set([8192, 16384, 32256, 32768]);
+ function detectMedia(name, data) {
+ const ext = (name.toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || '';
+ const n = data.length;
+ const isWav = n > 12 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46;
+ const leader = n > 64 && data.subarray(0, 32).every((b) => b === 0x55);
+ const bkp = n === 17408 && data[0] === 0xfd;
+ const cramCtl = n === 16384 && (data[0x3fe1] === 0xaa || (data[0] === 0xaa && data[1] === 0x8c));
+ if (ext === 'wav' || isWav) return { kind: 'cassette', why: 'enregistrement .wav' };
+ if (ext === 'k7' || leader) return { kind: 'cassette', why: 'cassette .k7' };
+ if (ext === 'bkp' || bkp) return { kind: 'exelmemoire', why: 'sauvegarde BKP', bkp: true };
+ if (ext === 'cram' || n === 65536 || cramCtl) return { kind: 'exelmemoire', why: n === 65536 ? 'image 64 Ko' : 'image .cram 16 Ko' };
+ if (ext === 'fd' || ext === 'td0' || (n > 2 && data[0] === 0x54 && data[1] === 0x44)) return { kind: 'disquette', why: ext === 'td0' ? 'image Teledisk' : 'image de disquette' };
+ if (['bin', 'rom', 'cart'].includes(ext) || CART_SIZES.has(n)) return { kind: 'cartouche', why: n + ' octets' };
+ return { kind: 'inconnu', why: n + ' octets' };
+ }
+ async function writeMedia(file, data) {
+ try { fs().mkdir('/uploads'); } catch (e) {}
+ const path = '/uploads/' + file.name.replace(/[^\w.-]+/g, '_');
+ fs().writeFile(path, data);
+ return path;
+ }
+ async function openMedia(file) {
+ if (!file) return;
+ const data = new Uint8Array(await file.arrayBuffer());
+ const d = detectMedia(file.name, data);
+ const label = { cassette: 'Cassette', cartouche: 'Cartouche', exelmemoire: 'Exelmémoire', disquette: 'Disquette', inconnu: 'Fichier' }[d.kind];
+ if (d.kind === 'disquette') {
+ statusEl.textContent = label + ' détectée (' + d.why + ') : les disquettes Exeldisk ne sont pas encore prises en charge.';
+ return;
+ }
+ if (d.kind === 'inconnu') {
+ statusEl.textContent = 'Type de fichier non reconnu (' + d.why + ') : cassette .k7/.wav, cartouche .bin/.rom, Exelmémoire .cram/.bkp.';
+ return;
+ }
+ if (d.kind === 'exelmemoire') {
+ let vol = data;
+ if (d.bkp) { vol = new Uint8Array(16384); for (let i = 0; i < 16384; i++) vol[i] = data[514 + 16383 - i]; }
+ else if (data.length === 65536) vol = data.subarray(0x8000, 0xc000);
+ if (vol.length !== 16384) { statusEl.textContent = 'Image Exelmémoire inattendue (' + data.length + ' octets).'; return; }
+ let s = '';
+ for (let i = 0; i < vol.length; i += 4096) s += String.fromCharCode.apply(null, vol.subarray(i, i + 4096));
+ try { localStorage.setItem(cramKey, btoa(s)); if (!onExeltel) localStorage.setItem('exl100.exelmem', '1'); } catch (e) {}
+ statusEl.textContent = label + ' détectée (' + d.why + ') : volume chargé, redémarrage…';
+ location.reload();
+ return;
+ }
+ const token = ++bootToken;
+ markActive('');
+ const path = await writeMedia(file, data);
+ const args = ['--game', gameName];
+ if (d.kind === 'cassette') args.push('--tape', '/roms/cartridges/exelbasic.bin', '--tape', path);
+ else args.push('--tape', path);
+ args.push(biosZip);
+ launch(args);
+ statusEl.textContent = label + ' détectée (' + d.why + ') : ' + (d.kind === 'cassette' ? 'chargement dans ExelBASIC…' : 'démarrage…');
+ $('pick').textContent = '📄 ' + file.name;
+ if (d.kind === 'cassette') autoLoadCassette(token);
+ }
+ $('media').onchange = () => openMedia($('media').files[0]);
+ for (const el of [$('drop'), canvas]) {
+ el.addEventListener('dragover', (e) => { e.preventDefault(); $('drop').classList.add('over'); });
+ el.addEventListener('dragleave', () => $('drop').classList.remove('over'));
+ el.addEventListener('drop', (e) => { e.preventDefault(); $('drop').classList.remove('over'); openMedia(e.dataTransfer.files[0]); });
+ }
$('cram-import').onclick = () => $('cram-file').click();
$('cram-file').onchange = async () => {
const file = $('cram-file').files[0];