Add GTK-backed CEF file dialog experiments
- commit
- eb6293ed2eb1f77600edf5312d5bc5c7042ecd9b
- author
- brice · · 2026-05-07 16:25 UTC
- parent
- 4069a5d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +902 -4
A assets/manual-tests/file-dialogs.html +233 -0
| @@ -0,0 +1,233 @@ | |||
| 1 | + | <!DOCTYPE html> | |
| 2 | + | <html lang="fr"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="UTF-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + | <title>Test des dialogues de fichiers — Dumber</title> | |
| 7 | + | <style> | |
| 8 | + | body { | |
| 9 | + | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| 10 | + | max-width: 800px; | |
| 11 | + | margin: 2rem auto; | |
| 12 | + | padding: 0 1rem; | |
| 13 | + | background: #fafafa; | |
| 14 | + | color: #222; | |
| 15 | + | line-height: 1.5; | |
| 16 | + | } | |
| 17 | + | h1 { font-size: 1.4rem; margin-bottom: 0.25rem; } | |
| 18 | + | h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; } | |
| 19 | + | section { | |
| 20 | + | background: #fff; | |
| 21 | + | border: 1px solid #ddd; | |
| 22 | + | border-radius: 6px; | |
| 23 | + | padding: 1rem 1.25rem; | |
| 24 | + | margin-bottom: 1rem; | |
| 25 | + | } | |
| 26 | + | label { display: block; margin-bottom: 0.5rem; font-weight: 600; } | |
| 27 | + | input[type="file"] { display: block; margin-bottom: 0.75rem; } | |
| 28 | + | button { | |
| 29 | + | font-size: 0.95rem; | |
| 30 | + | padding: 0.4rem 1rem; | |
| 31 | + | border: 1px solid #888; | |
| 32 | + | border-radius: 4px; | |
| 33 | + | background: #eee; | |
| 34 | + | cursor: pointer; | |
| 35 | + | } | |
| 36 | + | button:active { background: #ddd; } | |
| 37 | + | #log { | |
| 38 | + | background: #1e1e1e; | |
| 39 | + | color: #d4d4d4; | |
| 40 | + | font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace; | |
| 41 | + | font-size: 0.85rem; | |
| 42 | + | padding: 0.75rem 1rem; | |
| 43 | + | border-radius: 6px; | |
| 44 | + | max-height: 20em; | |
| 45 | + | overflow-y: auto; | |
| 46 | + | white-space: pre-wrap; | |
| 47 | + | overflow-wrap: anywhere; | |
| 48 | + | } | |
| 49 | + | #log .info { color: #6bc; } | |
| 50 | + | #log .ok { color: #6c6; } | |
| 51 | + | #log .warn { color: #da5; } | |
| 52 | + | #log .error { color: #e55; } | |
| 53 | + | .instructions { | |
| 54 | + | background: #f0f6ff; | |
| 55 | + | border: 1px solid #b8d4fe; | |
| 56 | + | font-size: 0.9rem; | |
| 57 | + | padding: 0.75rem 1rem; | |
| 58 | + | border-radius: 6px; | |
| 59 | + | margin-bottom: 1.5rem; | |
| 60 | + | } | |
| 61 | + | .instructions code { | |
| 62 | + | background: #e3edf7; | |
| 63 | + | padding: 0.1em 0.4em; | |
| 64 | + | border-radius: 3px; | |
| 65 | + | font-size: 0.85rem; | |
| 66 | + | } | |
| 67 | + | hr { border: none; border-top: 1px solid #ddd; margin: 1.5rem 0; } | |
| 68 | + | </style> | |
| 69 | + | </head> | |
| 70 | + | <body> | |
| 71 | + | ||
| 72 | + | <h1>🧪 Test des dialogues de fichiers</h1> | |
| 73 | + | <p>Page HTML simple pour tester les sélecteurs de fichiers et l'emplacement de sauvegarde dans Dumber.</p> | |
| 74 | + | ||
| 75 | + | <div class="instructions"> | |
| 76 | + | <strong>Ouvrir cette page dans Dumber :</strong><br> | |
| 77 | + | <code>ENV=dev DUMBER_LOG_LEVEL=debug ./dist/dumber browse /home/brice/dev/projects/dumber/assets/manual-tests/file-dialogs.html</code> | |
| 78 | + | <p style="margin:0.6rem 0 0; color:#555;"> | |
| 79 | + | Note : le choix de dossier via <code>webkitdirectory</code> et la boîte de dialogue de sauvegarde via <code>showSaveFilePicker()</code> | |
| 80 | + | dépendent du moteur navigateur embarqué. | |
| 81 | + | </p> | |
| 82 | + | </div> | |
| 83 | + | ||
| 84 | + | <!-- ───── Upload section ───── --> | |
| 85 | + | <section> | |
| 86 | + | <h2>📤 Upload / Ouverture</h2> | |
| 87 | + | ||
| 88 | + | <label for="single-file">Un seul fichier</label> | |
| 89 | + | <input type="file" id="single-file" onclick="this.value = null"> | |
| 90 | + | ||
| 91 | + | <label for="multi-file">Plusieurs fichiers</label> | |
| 92 | + | <input type="file" id="multi-file" multiple onclick="this.value = null"> | |
| 93 | + | ||
| 94 | + | <label for="dir-picker">Dossier (webkitdirectory)</label> | |
| 95 | + | <input type="file" id="dir-picker" webkitdirectory multiple onclick="this.value = null"> | |
| 96 | + | </section> | |
| 97 | + | ||
| 98 | + | <!-- ───── Save section ───── --> | |
| 99 | + | <section> | |
| 100 | + | <h2>💾 Emplacement de sauvegarde</h2> | |
| 101 | + | <p style="margin:0 0 0.75rem; font-size:0.9rem; color:#555;"> | |
| 102 | + | Utilise <code>window.showSaveFilePicker()</code> — disponible dans les navigateurs basés sur Chromium. | |
| 103 | + | </p> | |
| 104 | + | <button type="button" id="save-btn">Choisir un emplacement et écrire le fichier</button> | |
| 105 | + | </section> | |
| 106 | + | ||
| 107 | + | <!-- ───── Status log ───── --> | |
| 108 | + | <section> | |
| 109 | + | <div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:0.75rem;"> | |
| 110 | + | <h2 style="margin:0;">📋 Journal des événements</h2> | |
| 111 | + | <button type="button" id="clear-log-btn">Vider le journal</button> | |
| 112 | + | </div> | |
| 113 | + | <div id="log">[journal prêt]</div> | |
| 114 | + | </section> | |
| 115 | + | ||
| 116 | + | <script> | |
| 117 | + | const logger = (() => { | |
| 118 | + | const el = document.getElementById('log'); | |
| 119 | + | return (level, msg) => { | |
| 120 | + | const line = document.createElement('div'); | |
| 121 | + | line.className = level; | |
| 122 | + | line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; | |
| 123 | + | el.appendChild(line); | |
| 124 | + | el.scrollTop = el.scrollHeight; | |
| 125 | + | }; | |
| 126 | + | })(); | |
| 127 | + | ||
| 128 | + | function info(msg) { logger('info', msg); } | |
| 129 | + | function ok(msg) { logger('ok', msg); } | |
| 130 | + | function warn(msg) { logger('warn', msg); } | |
| 131 | + | function error(msg) { logger('error', msg); } | |
| 132 | + | ||
| 133 | + | document.getElementById('clear-log-btn').addEventListener('click', function () { | |
| 134 | + | document.getElementById('log').textContent = '[journal vidé]'; | |
| 135 | + | }); | |
| 136 | + | ||
| 137 | + | if (!('webkitdirectory' in document.createElement('input'))) { | |
| 138 | + | warn('Le support de webkitdirectory ne semble pas disponible dans ce navigateur.'); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | if (!window.showSaveFilePicker) { | |
| 142 | + | warn('showSaveFilePicker() ne semble pas disponible dans ce navigateur.'); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // ── Single file ── | |
| 146 | + | document.getElementById('single-file').addEventListener('change', function (e) { | |
| 147 | + | const file = this.files[0]; | |
| 148 | + | if (!file) { info('Single file: selection cleared'); return; } | |
| 149 | + | ok(`Single file: "${file.name}" (${(file.size / 1024).toFixed(1)} KB, type: ${file.type || 'unknown'})`); | |
| 150 | + | }); | |
| 151 | + | ||
| 152 | + | // ── Multiple files ── | |
| 153 | + | document.getElementById('multi-file').addEventListener('change', function () { | |
| 154 | + | const files = this.files; | |
| 155 | + | if (!files.length) { info('Multiple files: selection cleared'); return; } | |
| 156 | + | ok(`Multiple files: ${files.length} selected`); | |
| 157 | + | for (let i = 0; i < files.length; i++) { | |
| 158 | + | const f = files[i]; | |
| 159 | + | ok(` [${i}] "${f.name}" (${(f.size / 1024).toFixed(1)} KB)`); | |
| 160 | + | } | |
| 161 | + | }); | |
| 162 | + | ||
| 163 | + | // ── Directory ── | |
| 164 | + | document.getElementById('dir-picker').addEventListener('change', function () { | |
| 165 | + | const files = this.files; | |
| 166 | + | if (!files.length) { info('Folder: selection cleared'); return; } | |
| 167 | + | ok(`Folder: ${files.length} entries selected`); | |
| 168 | + | ||
| 169 | + | // Collect folder names | |
| 170 | + | const dirs = new Set(); | |
| 171 | + | for (let i = 0; i < files.length; i++) { | |
| 172 | + | const path = files[i].webkitRelativePath; | |
| 173 | + | const parts = path.split('/'); | |
| 174 | + | if (parts.length > 1) dirs.add(parts[0]); | |
| 175 | + | } | |
| 176 | + | for (const d of dirs) { | |
| 177 | + | ok(` folder: "${d}"`); | |
| 178 | + | } | |
| 179 | + | // Show up to 5 filenames | |
| 180 | + | const maxShow = 5; | |
| 181 | + | for (let i = 0; i < Math.min(files.length, maxShow); i++) { | |
| 182 | + | const f = files[i]; | |
| 183 | + | ok(` file: "${f.webkitRelativePath}" (${(f.size / 1024).toFixed(1)} KB)`); | |
| 184 | + | } | |
| 185 | + | if (files.length > maxShow) { | |
| 186 | + | ok(` … and ${files.length - maxShow} more entries`); | |
| 187 | + | } | |
| 188 | + | }); | |
| 189 | + | ||
| 190 | + | // ── Save file picker ── | |
| 191 | + | const saveButton = document.getElementById('save-btn'); | |
| 192 | + | saveButton.addEventListener('click', async function () { | |
| 193 | + | if (saveButton.disabled) return; | |
| 194 | + | ||
| 195 | + | saveButton.disabled = true; | |
| 196 | + | info('Ouverture de la boîte de dialogue de sauvegarde…'); | |
| 197 | + | ||
| 198 | + | if (!window.showSaveFilePicker) { | |
| 199 | + | warn('showSaveFilePicker() n\'est pas disponible dans ce navigateur.'); | |
| 200 | + | warn('Essayez avec un navigateur basé sur Chromium.'); | |
| 201 | + | saveButton.disabled = false; | |
| 202 | + | return; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | try { | |
| 206 | + | const handle = await window.showSaveFilePicker({ | |
| 207 | + | suggestedName: 'dumber-save-test.txt', | |
| 208 | + | types: [{ | |
| 209 | + | description: 'Text files', | |
| 210 | + | accept: { 'text/plain': ['.txt'] } | |
| 211 | + | }] | |
| 212 | + | }); | |
| 213 | + | ||
| 214 | + | const writable = await handle.createWritable(); | |
| 215 | + | const payload = 'Dumber save test — ' + new Date().toISOString() + '\n'; | |
| 216 | + | await writable.write(payload); | |
| 217 | + | await writable.close(); | |
| 218 | + | ||
| 219 | + | ok(`Fichier sauvegardé : "${handle.name}"`); | |
| 220 | + | } catch (err) { | |
| 221 | + | if (err?.name === 'AbortError' || err?.name === 'SecurityError') { | |
| 222 | + | warn('Sauvegarde annulée ou permission refusée.'); | |
| 223 | + | } else { | |
| 224 | + | error(`Échec de la sauvegarde : ${err?.message ?? err}`); | |
| 225 | + | } | |
| 226 | + | } finally { | |
| 227 | + | saveButton.disabled = false; | |
| 228 | + | } | |
| 229 | + | }); | |
| 230 | + | </script> | |
| 231 | + | ||
| 232 | + | </body> | |
| 233 | + | </html> | |
| @@ -0,0 +1,233 @@ | |||||
| 1 | + | <!DOCTYPE html> | |||
| 2 | + | <html lang="fr"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="UTF-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
| 6 | + | <title>Test des dialogues de fichiers — Dumber</title> | |||
| 7 | + | <style> | |||
| 8 | + | body { | |||
| 9 | + | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |||
| 10 | + | max-width: 800px; | |||
| 11 | + | margin: 2rem auto; | |||
| 12 | + | padding: 0 1rem; | |||
| 13 | + | background: #fafafa; | |||
| 14 | + | color: #222; | |||
| 15 | + | line-height: 1.5; | |||
| 16 | + | } | |||
| 17 | + | h1 { font-size: 1.4rem; margin-bottom: 0.25rem; } | |||
| 18 | + | h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; } | |||
| 19 | + | section { | |||
| 20 | + | background: #fff; | |||
| 21 | + | border: 1px solid #ddd; | |||
| 22 | + | border-radius: 6px; | |||
| 23 | + | padding: 1rem 1.25rem; | |||
| 24 | + | margin-bottom: 1rem; | |||
| 25 | + | } | |||
| 26 | + | label { display: block; margin-bottom: 0.5rem; font-weight: 600; } | |||
| 27 | + | input[type="file"] { display: block; margin-bottom: 0.75rem; } | |||
| 28 | + | button { | |||
| 29 | + | font-size: 0.95rem; | |||
| 30 | + | padding: 0.4rem 1rem; | |||
| 31 | + | border: 1px solid #888; | |||
| 32 | + | border-radius: 4px; | |||
| 33 | + | background: #eee; | |||
| 34 | + | cursor: pointer; | |||
| 35 | + | } | |||
| 36 | + | button:active { background: #ddd; } | |||
| 37 | + | #log { | |||
| 38 | + | background: #1e1e1e; | |||
| 39 | + | color: #d4d4d4; | |||
| 40 | + | font-family: "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, monospace; | |||
| 41 | + | font-size: 0.85rem; | |||
| 42 | + | padding: 0.75rem 1rem; | |||
| 43 | + | border-radius: 6px; | |||
| 44 | + | max-height: 20em; | |||
| 45 | + | overflow-y: auto; | |||
| 46 | + | white-space: pre-wrap; | |||
| 47 | + | overflow-wrap: anywhere; | |||
| 48 | + | } | |||
| 49 | + | #log .info { color: #6bc; } | |||
| 50 | + | #log .ok { color: #6c6; } | |||
| 51 | + | #log .warn { color: #da5; } | |||
| 52 | + | #log .error { color: #e55; } | |||
| 53 | + | .instructions { | |||
| 54 | + | background: #f0f6ff; | |||
| 55 | + | border: 1px solid #b8d4fe; | |||
| 56 | + | font-size: 0.9rem; | |||
| 57 | + | padding: 0.75rem 1rem; | |||
| 58 | + | border-radius: 6px; | |||
| 59 | + | margin-bottom: 1.5rem; | |||
| 60 | + | } | |||
| 61 | + | .instructions code { | |||
| 62 | + | background: #e3edf7; | |||
| 63 | + | padding: 0.1em 0.4em; | |||
| 64 | + | border-radius: 3px; | |||
| 65 | + | font-size: 0.85rem; | |||
| 66 | + | } | |||
| 67 | + | hr { border: none; border-top: 1px solid #ddd; margin: 1.5rem 0; } | |||
| 68 | + | </style> | |||
| 69 | + | </head> | |||
| 70 | + | <body> | |||
| 71 | + | ||||
| 72 | + | <h1>🧪 Test des dialogues de fichiers</h1> | |||
| 73 | + | <p>Page HTML simple pour tester les sélecteurs de fichiers et l'emplacement de sauvegarde dans Dumber.</p> | |||
| 74 | + | ||||
| 75 | + | <div class="instructions"> | |||
| 76 | + | <strong>Ouvrir cette page dans Dumber :</strong><br> | |||
| 77 | + | <code>ENV=dev DUMBER_LOG_LEVEL=debug ./dist/dumber browse /home/brice/dev/projects/dumber/assets/manual-tests/file-dialogs.html</code> | |||
| 78 | + | <p style="margin:0.6rem 0 0; color:#555;"> | |||
| 79 | + | Note : le choix de dossier via <code>webkitdirectory</code> et la boîte de dialogue de sauvegarde via <code>showSaveFilePicker()</code> | |||
| 80 | + | dépendent du moteur navigateur embarqué. | |||
| 81 | + | </p> | |||
| 82 | + | </div> | |||
| 83 | + | ||||
| 84 | + | <!-- ───── Upload section ───── --> | |||
| 85 | + | <section> | |||
| 86 | + | <h2>📤 Upload / Ouverture</h2> | |||
| 87 | + | ||||
| 88 | + | <label for="single-file">Un seul fichier</label> | |||
| 89 | + | <input type="file" id="single-file" onclick="this.value = null"> | |||
| 90 | + | ||||
| 91 | + | <label for="multi-file">Plusieurs fichiers</label> | |||
| 92 | + | <input type="file" id="multi-file" multiple onclick="this.value = null"> | |||
| 93 | + | ||||
| 94 | + | <label for="dir-picker">Dossier (webkitdirectory)</label> | |||
| 95 | + | <input type="file" id="dir-picker" webkitdirectory multiple onclick="this.value = null"> | |||
| 96 | + | </section> | |||
| 97 | + | ||||
| 98 | + | <!-- ───── Save section ───── --> | |||
| 99 | + | <section> | |||
| 100 | + | <h2>💾 Emplacement de sauvegarde</h2> | |||
| 101 | + | <p style="margin:0 0 0.75rem; font-size:0.9rem; color:#555;"> | |||
| 102 | + | Utilise <code>window.showSaveFilePicker()</code> — disponible dans les navigateurs basés sur Chromium. | |||
| 103 | + | </p> | |||
| 104 | + | <button type="button" id="save-btn">Choisir un emplacement et écrire le fichier</button> | |||
| 105 | + | </section> | |||
| 106 | + | ||||
| 107 | + | <!-- ───── Status log ───── --> | |||
| 108 | + | <section> | |||
| 109 | + | <div style="display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:0.75rem;"> | |||
| 110 | + | <h2 style="margin:0;">📋 Journal des événements</h2> | |||
| 111 | + | <button type="button" id="clear-log-btn">Vider le journal</button> | |||
| 112 | + | </div> | |||
| 113 | + | <div id="log">[journal prêt]</div> | |||
| 114 | + | </section> | |||
| 115 | + | ||||
| 116 | + | <script> | |||
| 117 | + | const logger = (() => { | |||
| 118 | + | const el = document.getElementById('log'); | |||
| 119 | + | return (level, msg) => { | |||
| 120 | + | const line = document.createElement('div'); | |||
| 121 | + | line.className = level; | |||
| 122 | + | line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; | |||
| 123 | + | el.appendChild(line); | |||
| 124 | + | el.scrollTop = el.scrollHeight; | |||
| 125 | + | }; | |||
| 126 | + | })(); | |||
| 127 | + | ||||
| 128 | + | function info(msg) { logger('info', msg); } | |||
| 129 | + | function ok(msg) { logger('ok', msg); } | |||
| 130 | + | function warn(msg) { logger('warn', msg); } | |||
| 131 | + | function error(msg) { logger('error', msg); } | |||
| 132 | + | ||||
| 133 | + | document.getElementById('clear-log-btn').addEventListener('click', function () { | |||
| 134 | + | document.getElementById('log').textContent = '[journal vidé]'; | |||
| 135 | + | }); | |||
| 136 | + | ||||
| 137 | + | if (!('webkitdirectory' in document.createElement('input'))) { | |||
| 138 | + | warn('Le support de webkitdirectory ne semble pas disponible dans ce navigateur.'); | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | if (!window.showSaveFilePicker) { | |||
| 142 | + | warn('showSaveFilePicker() ne semble pas disponible dans ce navigateur.'); | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // ── Single file ── | |||
| 146 | + | document.getElementById('single-file').addEventListener('change', function (e) { | |||
| 147 | + | const file = this.files[0]; | |||
| 148 | + | if (!file) { info('Single file: selection cleared'); return; } | |||
| 149 | + | ok(`Single file: "${file.name}" (${(file.size / 1024).toFixed(1)} KB, type: ${file.type || 'unknown'})`); | |||
| 150 | + | }); | |||
| 151 | + | ||||
| 152 | + | // ── Multiple files ── | |||
| 153 | + | document.getElementById('multi-file').addEventListener('change', function () { | |||
| 154 | + | const files = this.files; | |||
| 155 | + | if (!files.length) { info('Multiple files: selection cleared'); return; } | |||
| 156 | + | ok(`Multiple files: ${files.length} selected`); | |||
| 157 | + | for (let i = 0; i < files.length; i++) { | |||
| 158 | + | const f = files[i]; | |||
| 159 | + | ok(` [${i}] "${f.name}" (${(f.size / 1024).toFixed(1)} KB)`); | |||
| 160 | + | } | |||
| 161 | + | }); | |||
| 162 | + | ||||
| 163 | + | // ── Directory ── | |||
| 164 | + | document.getElementById('dir-picker').addEventListener('change', function () { | |||
| 165 | + | const files = this.files; | |||
| 166 | + | if (!files.length) { info('Folder: selection cleared'); return; } | |||
| 167 | + | ok(`Folder: ${files.length} entries selected`); | |||
| 168 | + | ||||
| 169 | + | // Collect folder names | |||
| 170 | + | const dirs = new Set(); | |||
| 171 | + | for (let i = 0; i < files.length; i++) { | |||
| 172 | + | const path = files[i].webkitRelativePath; | |||
| 173 | + | const parts = path.split('/'); | |||
| 174 | + | if (parts.length > 1) dirs.add(parts[0]); | |||
| 175 | + | } | |||
| 176 | + | for (const d of dirs) { | |||
| 177 | + | ok(` folder: "${d}"`); | |||
| 178 | + | } | |||
| 179 | + | // Show up to 5 filenames | |||
| 180 | + | const maxShow = 5; | |||
| 181 | + | for (let i = 0; i < Math.min(files.length, maxShow); i++) { | |||
| 182 | + | const f = files[i]; | |||
| 183 | + | ok(` file: "${f.webkitRelativePath}" (${(f.size / 1024).toFixed(1)} KB)`); | |||
| 184 | + | } | |||
| 185 | + | if (files.length > maxShow) { | |||
| 186 | + | ok(` … and ${files.length - maxShow} more entries`); | |||
| 187 | + | } | |||
| 188 | + | }); | |||
| 189 | + | ||||
| 190 | + | // ── Save file picker ── | |||
| 191 | + | const saveButton = document.getElementById('save-btn'); | |||
| 192 | + | saveButton.addEventListener('click', async function () { | |||
| 193 | + | if (saveButton.disabled) return; | |||
| 194 | + | ||||
| 195 | + | saveButton.disabled = true; | |||
| 196 | + | info('Ouverture de la boîte de dialogue de sauvegarde…'); | |||
| 197 | + | ||||
| 198 | + | if (!window.showSaveFilePicker) { | |||
| 199 | + | warn('showSaveFilePicker() n\'est pas disponible dans ce navigateur.'); | |||
| 200 | + | warn('Essayez avec un navigateur basé sur Chromium.'); | |||
| 201 | + | saveButton.disabled = false; | |||
| 202 | + | return; | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | try { | |||
| 206 | + | const handle = await window.showSaveFilePicker({ | |||
| 207 | + | suggestedName: 'dumber-save-test.txt', | |||
| 208 | + | types: [{ | |||
| 209 | + | description: 'Text files', | |||
| 210 | + | accept: { 'text/plain': ['.txt'] } | |||
| 211 | + | }] | |||
| 212 | + | }); | |||
| 213 | + | ||||
| 214 | + | const writable = await handle.createWritable(); | |||
| 215 | + | const payload = 'Dumber save test — ' + new Date().toISOString() + '\n'; | |||
| 216 | + | await writable.write(payload); | |||
| 217 | + | await writable.close(); | |||
| 218 | + | ||||
| 219 | + | ok(`Fichier sauvegardé : "${handle.name}"`); | |||
| 220 | + | } catch (err) { | |||
| 221 | + | if (err?.name === 'AbortError' || err?.name === 'SecurityError') { | |||
| 222 | + | warn('Sauvegarde annulée ou permission refusée.'); | |||
| 223 | + | } else { | |||
| 224 | + | error(`Échec de la sauvegarde : ${err?.message ?? err}`); | |||
| 225 | + | } | |||
| 226 | + | } finally { | |||
| 227 | + | saveButton.disabled = false; | |||
| 228 | + | } | |||
| 229 | + | }); | |||
| 230 | + | </script> | |||
| 231 | + | ||||
| 232 | + | </body> | |||
| 233 | + | </html> | |||
M go.mod +2 -0
| @@ -80,3 +80,5 @@ require ( | |||
| 80 | 80 | ) | |
| 81 | 81 | ||
| 82 | 82 | tool github.com/a-h/templ/cmd/templ | |
| 83 | + | ||
| 84 | + | replace github.com/bnema/purego-cef => ../purego-cef | |
| @@ -80,3 +80,5 @@ require ( | |||||
| 80 | ) | 80 | ) | ||
| 81 | 81 | ||||
| 82 | tool github.com/a-h/templ/cmd/templ | 82 | tool github.com/a-h/templ/cmd/templ | ||
| 83 | + | ||||
| 84 | + | replace github.com/bnema/purego-cef => ../purego-cef | |||
A internal/infrastructure/cef/file_dialog.go +530 -0
| @@ -0,0 +1,530 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io/fs" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strings" | |
| 10 | + | "unsafe" | |
| 11 | + | ||
| 12 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | + | "github.com/bnema/puregotk/v4/gio" | |
| 14 | + | "github.com/bnema/puregotk/v4/gobject" | |
| 15 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 16 | + | "github.com/rs/zerolog" | |
| 17 | + | ||
| 18 | + | "github.com/bnema/dumber/internal/logging" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type cefFileDialogRequest struct { | |
| 22 | + | Mode purecef.FileDialogMode | |
| 23 | + | Title string | |
| 24 | + | DefaultFilePath string | |
| 25 | + | AcceptFilters []string | |
| 26 | + | AcceptExtensions []string | |
| 27 | + | AcceptDescriptions []string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | |
| 31 | + | ||
| 32 | + | const maxFolderUploadExperimentFiles = 5000 | |
| 33 | + | ||
| 34 | + | var ( | |
| 35 | + | decodeCEFStringList = purecef.StringListToSlice | |
| 36 | + | newCEFStringList = purecef.NewStringList | |
| 37 | + | freeCEFStringList = purecef.FreeStringList | |
| 38 | + | continueCEFFileDialog = continueCEFFileDialogWithLogs | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | func (h *handlerSet) OnFileDialog( | |
| 42 | + | _ purecef.Browser, | |
| 43 | + | mode purecef.FileDialogMode, | |
| 44 | + | title string, | |
| 45 | + | defaultFilePath string, | |
| 46 | + | acceptFilters purecef.StringList, | |
| 47 | + | acceptExtensions purecef.StringList, | |
| 48 | + | acceptDescriptions purecef.StringList, | |
| 49 | + | callback purecef.FileDialogCallback, | |
| 50 | + | ) int32 { | |
| 51 | + | if h == nil || h.wv == nil || callback == nil { | |
| 52 | + | return 0 | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | presenter := h.fileDialogPresenter | |
| 56 | + | if presenter == nil { | |
| 57 | + | presenter = presentGTKFileDialog | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | req := cefFileDialogRequest{ | |
| 61 | + | Mode: mode, | |
| 62 | + | Title: title, | |
| 63 | + | DefaultFilePath: defaultFilePath, | |
| 64 | + | AcceptFilters: decodeCEFStringList(acceptFilters), | |
| 65 | + | AcceptExtensions: decodeCEFStringList(acceptExtensions), | |
| 66 | + | AcceptDescriptions: decodeCEFStringList(acceptDescriptions), | |
| 67 | + | } | |
| 68 | + | logCEFFileDialog(h.wv).Debug(). | |
| 69 | + | Int32("mode", mode). | |
| 70 | + | Str("title", title). | |
| 71 | + | Str("default_file_path", defaultFilePath). | |
| 72 | + | Int("accept_filter_count", len(req.AcceptFilters)). | |
| 73 | + | Int("accept_extension_count", len(req.AcceptExtensions)). | |
| 74 | + | Msg("cef: file dialog requested") | |
| 75 | + | ||
| 76 | + | presenter(h.wv, req, callback) | |
| 77 | + | return 1 | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purecef.FileDialogCallback) { | |
| 81 | + | if callback == nil || wv == nil { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | wv.runOnGTK(func() { | |
| 86 | + | logCEFFileDialog(wv).Debug(). | |
| 87 | + | Int32("mode", req.Mode). | |
| 88 | + | Str("title", req.Title). | |
| 89 | + | Str("default_file_path", req.DefaultFilePath). | |
| 90 | + | Msg("cef: presenting GTK file dialog") | |
| 91 | + | dialog, ok := newNativeFileDialog(wv, req) | |
| 92 | + | if !ok || dialog == nil { | |
| 93 | + | dispatchFileDialogResult(wv, callback, nil) | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | onResponse := func(_ gtk.NativeDialog, responseID int) { | |
| 98 | + | defer func() { | |
| 99 | + | logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: releasing GTK file dialog") | |
| 100 | + | dialog.Unref() | |
| 101 | + | logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: released GTK file dialog") | |
| 102 | + | }() | |
| 103 | + | ||
| 104 | + | normalizedResponseID := normalizeGTKResponseID(responseID) | |
| 105 | + | logCEFFileDialog(wv).Debug(). | |
| 106 | + | Int("response_id_raw", responseID). | |
| 107 | + | Int("response_id_normalized", normalizedResponseID). | |
| 108 | + | Int32("mode", req.Mode). | |
| 109 | + | Msg("cef: GTK file dialog response received") | |
| 110 | + | if normalizedResponseID != int(gtk.ResponseAcceptValue) { | |
| 111 | + | logCEFFileDialog(wv).Debug().Msg("cef: file dialog cancelled") | |
| 112 | + | dispatchFileDialogResult(wv, callback, nil) | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | paths := selectedFileDialogPaths(dialog, req.Mode) | |
| 117 | + | logCEFFileDialog(wv).Debug(). | |
| 118 | + | Int("path_count", len(paths)). | |
| 119 | + | Strs("paths", paths). | |
| 120 | + | Msg("cef: file dialog selection captured") | |
| 121 | + | paths = folderUploadExperimentPaths(wv, req.Mode, paths) | |
| 122 | + | dispatchFileDialogResult(wv, callback, paths) | |
| 123 | + | } | |
| 124 | + | dialog.ConnectResponse(&onResponse) | |
| 125 | + | dialog.Show() | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) { | |
| 130 | + | action, ok := fileChooserActionForMode(req.Mode) | |
| 131 | + | if !ok { | |
| 132 | + | logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode") | |
| 133 | + | return nil, false | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | var titlePtr *string | |
| 137 | + | title := strings.TrimSpace(req.Title) | |
| 138 | + | if title != "" { | |
| 139 | + | titlePtr = &title | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil) | |
| 143 | + | if dialog == nil { | |
| 144 | + | logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog") | |
| 145 | + | return nil, false | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | dialog.SetModal(true) | |
| 149 | + | if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple { | |
| 150 | + | dialog.SetSelectMultiple(true) | |
| 151 | + | } | |
| 152 | + | applyDefaultDialogPath(dialog, req) | |
| 153 | + | applyDialogFilters(dialog, req) | |
| 154 | + | return dialog, true | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) { | |
| 158 | + | switch mode { | |
| 159 | + | case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple: | |
| 160 | + | return gtk.FileChooserActionOpenValue, true | |
| 161 | + | case purecef.FileDialogModeFileDialogOpenFolder: | |
| 162 | + | return gtk.FileChooserActionSelectFolderValue, true | |
| 163 | + | case purecef.FileDialogModeFileDialogSave: | |
| 164 | + | return gtk.FileChooserActionSaveValue, true | |
| 165 | + | default: | |
| 166 | + | return 0, false | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func fileDialogParentWindow(wv *WebView) *gtk.Window { | |
| 171 | + | if wv == nil || wv.viewBridge == nil { | |
| 172 | + | return nil | |
| 173 | + | } | |
| 174 | + | widget := wv.viewBridge.Widget() | |
| 175 | + | if widget == nil { | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | ancestor := widget.GetAncestor(gtk.WindowGLibType()) | |
| 179 | + | if ancestor == nil { | |
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | // puregotk's GetAncestor wrapper adds a reference before returning. | |
| 183 | + | defer ancestor.Unref() | |
| 184 | + | return gtk.WindowNewFromInternalPtr(ancestor.GoPointer()) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) { | |
| 188 | + | if dialog == nil { | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | path := strings.TrimSpace(req.DefaultFilePath) | |
| 192 | + | if path == "" { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | if req.Mode == purecef.FileDialogModeFileDialogSave { | |
| 197 | + | if dir := filepath.Dir(path); dir != "." && dir != "" { | |
| 198 | + | if folder := gio.FileNewForPath(dir); folder != nil { | |
| 199 | + | _, _ = dialog.SetCurrentFolder(folder) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if name := filepath.Base(path); name != "." && name != string(filepath.Separator) { | |
| 203 | + | dialog.SetCurrentName(name) | |
| 204 | + | } | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | if info, err := os.Stat(path); err == nil && info.IsDir() { | |
| 209 | + | if folder := gio.FileNewForPath(path); folder != nil { | |
| 210 | + | _, _ = dialog.SetCurrentFolder(folder) | |
| 211 | + | } | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | if dir := filepath.Dir(path); dir != "." && dir != "" { | |
| 216 | + | if folder := gio.FileNewForPath(dir); folder != nil { | |
| 217 | + | _, _ = dialog.SetCurrentFolder(folder) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | if req.Mode != purecef.FileDialogModeFileDialogOpenFolder { | |
| 221 | + | if file := gio.FileNewForPath(path); file != nil { | |
| 222 | + | _, _ = dialog.SetFile(file) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) { | |
| 228 | + | if dialog == nil { | |
| 229 | + | return | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | appliedAny := false | |
| 233 | + | for i, spec := range req.AcceptFilters { | |
| 234 | + | filter := gtk.NewFileFilter() | |
| 235 | + | if filter == nil { | |
| 236 | + | continue | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | if name := dialogFilterName(req, i, spec); name != "" { | |
| 240 | + | filter.SetName(&name) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | added := false | |
| 244 | + | for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) { | |
| 245 | + | if addDialogFilterToken(filter, token) { | |
| 246 | + | added = true | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | if !added { | |
| 250 | + | continue | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | dialog.AddFilter(filter) | |
| 254 | + | if !appliedAny { | |
| 255 | + | dialog.SetFilter(filter) | |
| 256 | + | appliedAny = true | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string { | |
| 262 | + | if index < len(req.AcceptDescriptions) { | |
| 263 | + | if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" { | |
| 264 | + | return name | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | return strings.TrimSpace(fallback) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func dialogFilterExtensions(req cefFileDialogRequest, index int) string { | |
| 271 | + | if index >= len(req.AcceptExtensions) { | |
| 272 | + | return "" | |
| 273 | + | } | |
| 274 | + | return req.AcceptExtensions[index] | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func dialogFilterTokens(spec, extensions string) []string { | |
| 278 | + | tokens := make([]string, 0, 4) | |
| 279 | + | if spec = strings.TrimSpace(spec); spec != "" { | |
| 280 | + | tokens = append(tokens, spec) | |
| 281 | + | } | |
| 282 | + | for _, part := range strings.Split(extensions, ";") { | |
| 283 | + | part = strings.TrimSpace(part) | |
| 284 | + | if part != "" { | |
| 285 | + | tokens = append(tokens, part) | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | return tokens | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func addDialogFilterToken(filter *gtk.FileFilter, token string) bool { | |
| 292 | + | if filter == nil { | |
| 293 | + | return false | |
| 294 | + | } | |
| 295 | + | token = strings.TrimSpace(token) | |
| 296 | + | if token == "" { | |
| 297 | + | return false | |
| 298 | + | } | |
| 299 | + | if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") { | |
| 300 | + | filter.AddMimeType(token) | |
| 301 | + | return true | |
| 302 | + | } | |
| 303 | + | if strings.HasPrefix(token, ".") { | |
| 304 | + | filter.AddPattern("*" + token) | |
| 305 | + | return true | |
| 306 | + | } | |
| 307 | + | if strings.ContainsRune(token, '*') { | |
| 308 | + | filter.AddPattern(token) | |
| 309 | + | return true | |
| 310 | + | } | |
| 311 | + | return false | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string { | |
| 315 | + | if dialog == nil { | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | if mode == purecef.FileDialogModeFileDialogOpenMultiple { | |
| 319 | + | model := dialog.GetFiles() | |
| 320 | + | if model == nil { | |
| 321 | + | return nil | |
| 322 | + | } | |
| 323 | + | defer unrefGObjectPointer(model.GoPointer()) | |
| 324 | + | paths := make([]string, 0, model.GetNItems()) | |
| 325 | + | for i := uint(0); i < model.GetNItems(); i++ { | |
| 326 | + | obj := model.GetObject(i) | |
| 327 | + | if obj == nil { | |
| 328 | + | continue | |
| 329 | + | } | |
| 330 | + | file := &gio.FileBase{Ptr: obj.GoPointer()} | |
| 331 | + | if path := gioFileDialogPath(file); path != "" { | |
| 332 | + | paths = append(paths, path) | |
| 333 | + | } | |
| 334 | + | obj.Unref() | |
| 335 | + | } | |
| 336 | + | return paths | |
| 337 | + | } | |
| 338 | + | file := dialog.GetFile() | |
| 339 | + | defer unrefGObjectPointer(file.GoPointer()) | |
| 340 | + | if path := gioFileDialogPath(file); path != "" { | |
| 341 | + | return []string{path} | |
| 342 | + | } | |
| 343 | + | return nil | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func folderUploadExperimentPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string { | |
| 347 | + | if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 { | |
| 348 | + | return paths | |
| 349 | + | } | |
| 350 | + | folder := strings.TrimSpace(paths[0]) | |
| 351 | + | if folder == "" { | |
| 352 | + | return paths | |
| 353 | + | } | |
| 354 | + | info, err := os.Stat(folder) | |
| 355 | + | if err != nil || !info.IsDir() { | |
| 356 | + | logCEFFileDialog(wv).Warn().Err(err).Str("path", folder).Msg("cef: folder mode experiment skipped; selected path is not a directory") | |
| 357 | + | return paths | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | files := make([]string, 0, 64) | |
| 361 | + | truncated := false | |
| 362 | + | walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error { | |
| 363 | + | if err != nil { | |
| 364 | + | logCEFFileDialog(wv).Debug().Err(err).Str("path", path).Msg("cef: folder mode experiment skipped unreadable entry") | |
| 365 | + | return nil | |
| 366 | + | } | |
| 367 | + | if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { | |
| 368 | + | return nil | |
| 369 | + | } | |
| 370 | + | info, err := entry.Info() | |
| 371 | + | if err != nil || !info.Mode().IsRegular() { | |
| 372 | + | return nil | |
| 373 | + | } | |
| 374 | + | files = append(files, path) | |
| 375 | + | if len(files) >= maxFolderUploadExperimentFiles { | |
| 376 | + | truncated = true | |
| 377 | + | return filepath.SkipAll | |
| 378 | + | } | |
| 379 | + | return nil | |
| 380 | + | }) | |
| 381 | + | if walkErr != nil { | |
| 382 | + | logCEFFileDialog(wv).Warn().Err(walkErr).Str("folder", folder).Msg("cef: folder mode experiment failed to enumerate selected folder") | |
| 383 | + | return paths | |
| 384 | + | } | |
| 385 | + | if len(files) == 0 { | |
| 386 | + | logCEFFileDialog(wv).Warn().Str("folder", folder).Msg("cef: folder mode experiment found no descendant files; using selected directory path") | |
| 387 | + | return paths | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | logCEFFileDialog(wv).Warn(). | |
| 391 | + | Str("folder", folder). | |
| 392 | + | Int("file_count", len(files)). | |
| 393 | + | Bool("truncated", truncated). | |
| 394 | + | Msg("cef: folder mode experiment passing descendant file paths instead of selected directory") | |
| 395 | + | return files | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func gioFileDialogPath(file *gio.FileBase) string { | |
| 399 | + | if file == nil { | |
| 400 | + | return "" | |
| 401 | + | } | |
| 402 | + | return strings.TrimSpace(file.GetPath()) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func unrefGObjectPointer(ptr uintptr) { | |
| 406 | + | if ptr == 0 { | |
| 407 | + | return | |
| 408 | + | } | |
| 409 | + | obj := &gobject.Object{} | |
| 410 | + | obj.SetGoPointer(ptr) | |
| 411 | + | obj.Unref() | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func normalizeGTKResponseID(responseID int) int { | |
| 415 | + | return int(int32(uint32(responseID))) | |
| 416 | + | } | |
| 417 | + | ||
| 418 | + | func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) { | |
| 419 | + | if callback == nil { | |
| 420 | + | return | |
| 421 | + | } | |
| 422 | + | if wv == nil || wv.engine == nil { | |
| 423 | + | return | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 427 | + | logger := logCEFFileDialog(wv) | |
| 428 | + | logger.Debug(). | |
| 429 | + | Int("path_count", len(paths)). | |
| 430 | + | Strs("paths", paths). | |
| 431 | + | Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)). | |
| 432 | + | Msg("cef: file dialog continuation task executing") | |
| 433 | + | continueCEFFileDialog(logger, callback, paths...) | |
| 434 | + | logger.Debug(). | |
| 435 | + | Int("path_count", len(paths)). | |
| 436 | + | Strs("paths", paths). | |
| 437 | + | Msg("cef: file dialog continuation task finished") | |
| 438 | + | })) | |
| 439 | + | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |
| 440 | + | logCEFFileDialog(wv).Warn().Int32("post_result", result).Msg("cef: failed to post file dialog continuation to CEF UI thread") | |
| 441 | + | } else { | |
| 442 | + | logCEFFileDialog(wv).Debug().Int("path_count", len(paths)).Strs("paths", paths).Msg("cef: file dialog continuation task posted") | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) { | |
| 447 | + | logger = nonNilCEFFileDialogLogger(logger) | |
| 448 | + | callbackPtr := rawCEFPointer(callback) | |
| 449 | + | if callback == nil { | |
| 450 | + | logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil") | |
| 451 | + | return | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | if len(filePaths) == 0 { | |
| 455 | + | logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel") | |
| 456 | + | callback.Cancel() | |
| 457 | + | runtime.KeepAlive(callback) | |
| 458 | + | logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned") | |
| 459 | + | return | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | logger.Debug(). | |
| 463 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 464 | + | Int("path_count", len(filePaths)). | |
| 465 | + | Strs("paths", filePaths). | |
| 466 | + | Msg("cef: allocating file dialog CEF string list") | |
| 467 | + | list := newCEFStringList(filePaths...) | |
| 468 | + | if list == 0 { | |
| 469 | + | logger.Warn(). | |
| 470 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 471 | + | Int("path_count", len(filePaths)). | |
| 472 | + | Msg("cef: file dialog CEF string list allocation failed; canceling") | |
| 473 | + | callback.Cancel() | |
| 474 | + | runtime.KeepAlive(callback) | |
| 475 | + | logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure") | |
| 476 | + | return | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | listValue := uint64(list) | |
| 480 | + | logger.Debug(). | |
| 481 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 482 | + | Uint64("string_list", listValue). | |
| 483 | + | Int("path_count", len(filePaths)). | |
| 484 | + | Msg("cef: file dialog CEF string list allocated") | |
| 485 | + | logger.Debug(). | |
| 486 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 487 | + | Uint64("string_list", listValue). | |
| 488 | + | Int("path_count", len(filePaths)). | |
| 489 | + | Strs("paths", filePaths). | |
| 490 | + | Msg("cef: invoking file dialog callback continuation") | |
| 491 | + | callback.Cont(list) | |
| 492 | + | logger.Debug(). | |
| 493 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |
| 494 | + | Uint64("string_list", listValue). | |
| 495 | + | Int("path_count", len(filePaths)). | |
| 496 | + | Msg("cef: file dialog callback continuation returned") | |
| 497 | + | logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list") | |
| 498 | + | freeCEFStringList(list) | |
| 499 | + | logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list") | |
| 500 | + | runtime.KeepAlive(callback) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger { | |
| 504 | + | if logger != nil { | |
| 505 | + | return logger | |
| 506 | + | } | |
| 507 | + | return logging.FromContext(context.Background()) | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | type cefRawPointerHolder interface { | |
| 511 | + | RawPointer() unsafe.Pointer | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func rawCEFPointer(v any) uintptr { | |
| 515 | + | if v == nil { | |
| 516 | + | return 0 | |
| 517 | + | } | |
| 518 | + | holder, ok := v.(cefRawPointerHolder) | |
| 519 | + | if !ok { | |
| 520 | + | return 0 | |
| 521 | + | } | |
| 522 | + | return uintptr(holder.RawPointer()) | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | func logCEFFileDialog(wv *WebView) *zerolog.Logger { | |
| 526 | + | if wv == nil || wv.ctx == nil { | |
| 527 | + | return logging.FromContext(context.Background()) | |
| 528 | + | } | |
| 529 | + | return logging.FromContext(wv.ctx) | |
| 530 | + | } | |
| @@ -0,0 +1,530 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io/fs" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "runtime" | |||
| 9 | + | "strings" | |||
| 10 | + | "unsafe" | |||
| 11 | + | ||||
| 12 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 13 | + | "github.com/bnema/puregotk/v4/gio" | |||
| 14 | + | "github.com/bnema/puregotk/v4/gobject" | |||
| 15 | + | "github.com/bnema/puregotk/v4/gtk" | |||
| 16 | + | "github.com/rs/zerolog" | |||
| 17 | + | ||||
| 18 | + | "github.com/bnema/dumber/internal/logging" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type cefFileDialogRequest struct { | |||
| 22 | + | Mode purecef.FileDialogMode | |||
| 23 | + | Title string | |||
| 24 | + | DefaultFilePath string | |||
| 25 | + | AcceptFilters []string | |||
| 26 | + | AcceptExtensions []string | |||
| 27 | + | AcceptDescriptions []string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type fileDialogPresenter func(*WebView, cefFileDialogRequest, purecef.FileDialogCallback) | |||
| 31 | + | ||||
| 32 | + | const maxFolderUploadExperimentFiles = 5000 | |||
| 33 | + | ||||
| 34 | + | var ( | |||
| 35 | + | decodeCEFStringList = purecef.StringListToSlice | |||
| 36 | + | newCEFStringList = purecef.NewStringList | |||
| 37 | + | freeCEFStringList = purecef.FreeStringList | |||
| 38 | + | continueCEFFileDialog = continueCEFFileDialogWithLogs | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | func (h *handlerSet) OnFileDialog( | |||
| 42 | + | _ purecef.Browser, | |||
| 43 | + | mode purecef.FileDialogMode, | |||
| 44 | + | title string, | |||
| 45 | + | defaultFilePath string, | |||
| 46 | + | acceptFilters purecef.StringList, | |||
| 47 | + | acceptExtensions purecef.StringList, | |||
| 48 | + | acceptDescriptions purecef.StringList, | |||
| 49 | + | callback purecef.FileDialogCallback, | |||
| 50 | + | ) int32 { | |||
| 51 | + | if h == nil || h.wv == nil || callback == nil { | |||
| 52 | + | return 0 | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | presenter := h.fileDialogPresenter | |||
| 56 | + | if presenter == nil { | |||
| 57 | + | presenter = presentGTKFileDialog | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | req := cefFileDialogRequest{ | |||
| 61 | + | Mode: mode, | |||
| 62 | + | Title: title, | |||
| 63 | + | DefaultFilePath: defaultFilePath, | |||
| 64 | + | AcceptFilters: decodeCEFStringList(acceptFilters), | |||
| 65 | + | AcceptExtensions: decodeCEFStringList(acceptExtensions), | |||
| 66 | + | AcceptDescriptions: decodeCEFStringList(acceptDescriptions), | |||
| 67 | + | } | |||
| 68 | + | logCEFFileDialog(h.wv).Debug(). | |||
| 69 | + | Int32("mode", mode). | |||
| 70 | + | Str("title", title). | |||
| 71 | + | Str("default_file_path", defaultFilePath). | |||
| 72 | + | Int("accept_filter_count", len(req.AcceptFilters)). | |||
| 73 | + | Int("accept_extension_count", len(req.AcceptExtensions)). | |||
| 74 | + | Msg("cef: file dialog requested") | |||
| 75 | + | ||||
| 76 | + | presenter(h.wv, req, callback) | |||
| 77 | + | return 1 | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func presentGTKFileDialog(wv *WebView, req cefFileDialogRequest, callback purecef.FileDialogCallback) { | |||
| 81 | + | if callback == nil || wv == nil { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | wv.runOnGTK(func() { | |||
| 86 | + | logCEFFileDialog(wv).Debug(). | |||
| 87 | + | Int32("mode", req.Mode). | |||
| 88 | + | Str("title", req.Title). | |||
| 89 | + | Str("default_file_path", req.DefaultFilePath). | |||
| 90 | + | Msg("cef: presenting GTK file dialog") | |||
| 91 | + | dialog, ok := newNativeFileDialog(wv, req) | |||
| 92 | + | if !ok || dialog == nil { | |||
| 93 | + | dispatchFileDialogResult(wv, callback, nil) | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | onResponse := func(_ gtk.NativeDialog, responseID int) { | |||
| 98 | + | defer func() { | |||
| 99 | + | logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: releasing GTK file dialog") | |||
| 100 | + | dialog.Unref() | |||
| 101 | + | logCEFFileDialog(wv).Debug().Int32("mode", req.Mode).Msg("cef: released GTK file dialog") | |||
| 102 | + | }() | |||
| 103 | + | ||||
| 104 | + | normalizedResponseID := normalizeGTKResponseID(responseID) | |||
| 105 | + | logCEFFileDialog(wv).Debug(). | |||
| 106 | + | Int("response_id_raw", responseID). | |||
| 107 | + | Int("response_id_normalized", normalizedResponseID). | |||
| 108 | + | Int32("mode", req.Mode). | |||
| 109 | + | Msg("cef: GTK file dialog response received") | |||
| 110 | + | if normalizedResponseID != int(gtk.ResponseAcceptValue) { | |||
| 111 | + | logCEFFileDialog(wv).Debug().Msg("cef: file dialog cancelled") | |||
| 112 | + | dispatchFileDialogResult(wv, callback, nil) | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | paths := selectedFileDialogPaths(dialog, req.Mode) | |||
| 117 | + | logCEFFileDialog(wv).Debug(). | |||
| 118 | + | Int("path_count", len(paths)). | |||
| 119 | + | Strs("paths", paths). | |||
| 120 | + | Msg("cef: file dialog selection captured") | |||
| 121 | + | paths = folderUploadExperimentPaths(wv, req.Mode, paths) | |||
| 122 | + | dispatchFileDialogResult(wv, callback, paths) | |||
| 123 | + | } | |||
| 124 | + | dialog.ConnectResponse(&onResponse) | |||
| 125 | + | dialog.Show() | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func newNativeFileDialog(wv *WebView, req cefFileDialogRequest) (*gtk.FileChooserNative, bool) { | |||
| 130 | + | action, ok := fileChooserActionForMode(req.Mode) | |||
| 131 | + | if !ok { | |||
| 132 | + | logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: unsupported file dialog mode") | |||
| 133 | + | return nil, false | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | var titlePtr *string | |||
| 137 | + | title := strings.TrimSpace(req.Title) | |||
| 138 | + | if title != "" { | |||
| 139 | + | titlePtr = &title | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | dialog := gtk.NewFileChooserNative(titlePtr, fileDialogParentWindow(wv), action, nil, nil) | |||
| 143 | + | if dialog == nil { | |||
| 144 | + | logCEFFileDialog(wv).Warn().Int32("mode", req.Mode).Msg("cef: failed to create GTK file chooser dialog") | |||
| 145 | + | return nil, false | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | dialog.SetModal(true) | |||
| 149 | + | if req.Mode == purecef.FileDialogModeFileDialogOpenMultiple { | |||
| 150 | + | dialog.SetSelectMultiple(true) | |||
| 151 | + | } | |||
| 152 | + | applyDefaultDialogPath(dialog, req) | |||
| 153 | + | applyDialogFilters(dialog, req) | |||
| 154 | + | return dialog, true | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func fileChooserActionForMode(mode purecef.FileDialogMode) (gtk.FileChooserAction, bool) { | |||
| 158 | + | switch mode { | |||
| 159 | + | case purecef.FileDialogModeFileDialogOpen, purecef.FileDialogModeFileDialogOpenMultiple: | |||
| 160 | + | return gtk.FileChooserActionOpenValue, true | |||
| 161 | + | case purecef.FileDialogModeFileDialogOpenFolder: | |||
| 162 | + | return gtk.FileChooserActionSelectFolderValue, true | |||
| 163 | + | case purecef.FileDialogModeFileDialogSave: | |||
| 164 | + | return gtk.FileChooserActionSaveValue, true | |||
| 165 | + | default: | |||
| 166 | + | return 0, false | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func fileDialogParentWindow(wv *WebView) *gtk.Window { | |||
| 171 | + | if wv == nil || wv.viewBridge == nil { | |||
| 172 | + | return nil | |||
| 173 | + | } | |||
| 174 | + | widget := wv.viewBridge.Widget() | |||
| 175 | + | if widget == nil { | |||
| 176 | + | return nil | |||
| 177 | + | } | |||
| 178 | + | ancestor := widget.GetAncestor(gtk.WindowGLibType()) | |||
| 179 | + | if ancestor == nil { | |||
| 180 | + | return nil | |||
| 181 | + | } | |||
| 182 | + | // puregotk's GetAncestor wrapper adds a reference before returning. | |||
| 183 | + | defer ancestor.Unref() | |||
| 184 | + | return gtk.WindowNewFromInternalPtr(ancestor.GoPointer()) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func applyDefaultDialogPath(dialog *gtk.FileChooserNative, req cefFileDialogRequest) { | |||
| 188 | + | if dialog == nil { | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 191 | + | path := strings.TrimSpace(req.DefaultFilePath) | |||
| 192 | + | if path == "" { | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | if req.Mode == purecef.FileDialogModeFileDialogSave { | |||
| 197 | + | if dir := filepath.Dir(path); dir != "." && dir != "" { | |||
| 198 | + | if folder := gio.FileNewForPath(dir); folder != nil { | |||
| 199 | + | _, _ = dialog.SetCurrentFolder(folder) | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | if name := filepath.Base(path); name != "." && name != string(filepath.Separator) { | |||
| 203 | + | dialog.SetCurrentName(name) | |||
| 204 | + | } | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | if info, err := os.Stat(path); err == nil && info.IsDir() { | |||
| 209 | + | if folder := gio.FileNewForPath(path); folder != nil { | |||
| 210 | + | _, _ = dialog.SetCurrentFolder(folder) | |||
| 211 | + | } | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | if dir := filepath.Dir(path); dir != "." && dir != "" { | |||
| 216 | + | if folder := gio.FileNewForPath(dir); folder != nil { | |||
| 217 | + | _, _ = dialog.SetCurrentFolder(folder) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | if req.Mode != purecef.FileDialogModeFileDialogOpenFolder { | |||
| 221 | + | if file := gio.FileNewForPath(path); file != nil { | |||
| 222 | + | _, _ = dialog.SetFile(file) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func applyDialogFilters(dialog *gtk.FileChooserNative, req cefFileDialogRequest) { | |||
| 228 | + | if dialog == nil { | |||
| 229 | + | return | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | appliedAny := false | |||
| 233 | + | for i, spec := range req.AcceptFilters { | |||
| 234 | + | filter := gtk.NewFileFilter() | |||
| 235 | + | if filter == nil { | |||
| 236 | + | continue | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | if name := dialogFilterName(req, i, spec); name != "" { | |||
| 240 | + | filter.SetName(&name) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | added := false | |||
| 244 | + | for _, token := range dialogFilterTokens(spec, dialogFilterExtensions(req, i)) { | |||
| 245 | + | if addDialogFilterToken(filter, token) { | |||
| 246 | + | added = true | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | if !added { | |||
| 250 | + | continue | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | dialog.AddFilter(filter) | |||
| 254 | + | if !appliedAny { | |||
| 255 | + | dialog.SetFilter(filter) | |||
| 256 | + | appliedAny = true | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func dialogFilterName(req cefFileDialogRequest, index int, fallback string) string { | |||
| 262 | + | if index < len(req.AcceptDescriptions) { | |||
| 263 | + | if name := strings.TrimSpace(req.AcceptDescriptions[index]); name != "" { | |||
| 264 | + | return name | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | return strings.TrimSpace(fallback) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func dialogFilterExtensions(req cefFileDialogRequest, index int) string { | |||
| 271 | + | if index >= len(req.AcceptExtensions) { | |||
| 272 | + | return "" | |||
| 273 | + | } | |||
| 274 | + | return req.AcceptExtensions[index] | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func dialogFilterTokens(spec, extensions string) []string { | |||
| 278 | + | tokens := make([]string, 0, 4) | |||
| 279 | + | if spec = strings.TrimSpace(spec); spec != "" { | |||
| 280 | + | tokens = append(tokens, spec) | |||
| 281 | + | } | |||
| 282 | + | for _, part := range strings.Split(extensions, ";") { | |||
| 283 | + | part = strings.TrimSpace(part) | |||
| 284 | + | if part != "" { | |||
| 285 | + | tokens = append(tokens, part) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | return tokens | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func addDialogFilterToken(filter *gtk.FileFilter, token string) bool { | |||
| 292 | + | if filter == nil { | |||
| 293 | + | return false | |||
| 294 | + | } | |||
| 295 | + | token = strings.TrimSpace(token) | |||
| 296 | + | if token == "" { | |||
| 297 | + | return false | |||
| 298 | + | } | |||
| 299 | + | if strings.Contains(token, "/") && !strings.HasPrefix(token, ".") { | |||
| 300 | + | filter.AddMimeType(token) | |||
| 301 | + | return true | |||
| 302 | + | } | |||
| 303 | + | if strings.HasPrefix(token, ".") { | |||
| 304 | + | filter.AddPattern("*" + token) | |||
| 305 | + | return true | |||
| 306 | + | } | |||
| 307 | + | if strings.ContainsRune(token, '*') { | |||
| 308 | + | filter.AddPattern(token) | |||
| 309 | + | return true | |||
| 310 | + | } | |||
| 311 | + | return false | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func selectedFileDialogPaths(dialog *gtk.FileChooserNative, mode purecef.FileDialogMode) []string { | |||
| 315 | + | if dialog == nil { | |||
| 316 | + | return nil | |||
| 317 | + | } | |||
| 318 | + | if mode == purecef.FileDialogModeFileDialogOpenMultiple { | |||
| 319 | + | model := dialog.GetFiles() | |||
| 320 | + | if model == nil { | |||
| 321 | + | return nil | |||
| 322 | + | } | |||
| 323 | + | defer unrefGObjectPointer(model.GoPointer()) | |||
| 324 | + | paths := make([]string, 0, model.GetNItems()) | |||
| 325 | + | for i := uint(0); i < model.GetNItems(); i++ { | |||
| 326 | + | obj := model.GetObject(i) | |||
| 327 | + | if obj == nil { | |||
| 328 | + | continue | |||
| 329 | + | } | |||
| 330 | + | file := &gio.FileBase{Ptr: obj.GoPointer()} | |||
| 331 | + | if path := gioFileDialogPath(file); path != "" { | |||
| 332 | + | paths = append(paths, path) | |||
| 333 | + | } | |||
| 334 | + | obj.Unref() | |||
| 335 | + | } | |||
| 336 | + | return paths | |||
| 337 | + | } | |||
| 338 | + | file := dialog.GetFile() | |||
| 339 | + | defer unrefGObjectPointer(file.GoPointer()) | |||
| 340 | + | if path := gioFileDialogPath(file); path != "" { | |||
| 341 | + | return []string{path} | |||
| 342 | + | } | |||
| 343 | + | return nil | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func folderUploadExperimentPaths(wv *WebView, mode purecef.FileDialogMode, paths []string) []string { | |||
| 347 | + | if mode != purecef.FileDialogModeFileDialogOpenFolder || len(paths) != 1 { | |||
| 348 | + | return paths | |||
| 349 | + | } | |||
| 350 | + | folder := strings.TrimSpace(paths[0]) | |||
| 351 | + | if folder == "" { | |||
| 352 | + | return paths | |||
| 353 | + | } | |||
| 354 | + | info, err := os.Stat(folder) | |||
| 355 | + | if err != nil || !info.IsDir() { | |||
| 356 | + | logCEFFileDialog(wv).Warn().Err(err).Str("path", folder).Msg("cef: folder mode experiment skipped; selected path is not a directory") | |||
| 357 | + | return paths | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | files := make([]string, 0, 64) | |||
| 361 | + | truncated := false | |||
| 362 | + | walkErr := filepath.WalkDir(folder, func(path string, entry fs.DirEntry, err error) error { | |||
| 363 | + | if err != nil { | |||
| 364 | + | logCEFFileDialog(wv).Debug().Err(err).Str("path", path).Msg("cef: folder mode experiment skipped unreadable entry") | |||
| 365 | + | return nil | |||
| 366 | + | } | |||
| 367 | + | if path == folder || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { | |||
| 368 | + | return nil | |||
| 369 | + | } | |||
| 370 | + | info, err := entry.Info() | |||
| 371 | + | if err != nil || !info.Mode().IsRegular() { | |||
| 372 | + | return nil | |||
| 373 | + | } | |||
| 374 | + | files = append(files, path) | |||
| 375 | + | if len(files) >= maxFolderUploadExperimentFiles { | |||
| 376 | + | truncated = true | |||
| 377 | + | return filepath.SkipAll | |||
| 378 | + | } | |||
| 379 | + | return nil | |||
| 380 | + | }) | |||
| 381 | + | if walkErr != nil { | |||
| 382 | + | logCEFFileDialog(wv).Warn().Err(walkErr).Str("folder", folder).Msg("cef: folder mode experiment failed to enumerate selected folder") | |||
| 383 | + | return paths | |||
| 384 | + | } | |||
| 385 | + | if len(files) == 0 { | |||
| 386 | + | logCEFFileDialog(wv).Warn().Str("folder", folder).Msg("cef: folder mode experiment found no descendant files; using selected directory path") | |||
| 387 | + | return paths | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | logCEFFileDialog(wv).Warn(). | |||
| 391 | + | Str("folder", folder). | |||
| 392 | + | Int("file_count", len(files)). | |||
| 393 | + | Bool("truncated", truncated). | |||
| 394 | + | Msg("cef: folder mode experiment passing descendant file paths instead of selected directory") | |||
| 395 | + | return files | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func gioFileDialogPath(file *gio.FileBase) string { | |||
| 399 | + | if file == nil { | |||
| 400 | + | return "" | |||
| 401 | + | } | |||
| 402 | + | return strings.TrimSpace(file.GetPath()) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func unrefGObjectPointer(ptr uintptr) { | |||
| 406 | + | if ptr == 0 { | |||
| 407 | + | return | |||
| 408 | + | } | |||
| 409 | + | obj := &gobject.Object{} | |||
| 410 | + | obj.SetGoPointer(ptr) | |||
| 411 | + | obj.Unref() | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func normalizeGTKResponseID(responseID int) int { | |||
| 415 | + | return int(int32(uint32(responseID))) | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | func dispatchFileDialogResult(wv *WebView, callback purecef.FileDialogCallback, paths []string) { | |||
| 419 | + | if callback == nil { | |||
| 420 | + | return | |||
| 421 | + | } | |||
| 422 | + | if wv == nil || wv.engine == nil { | |||
| 423 | + | return | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | task := cefNewTask(cefTaskFunc(func() { | |||
| 427 | + | logger := logCEFFileDialog(wv) | |||
| 428 | + | logger.Debug(). | |||
| 429 | + | Int("path_count", len(paths)). | |||
| 430 | + | Strs("paths", paths). | |||
| 431 | + | Int32("currently_on_ui", purecef.CurrentlyOn(purecef.ThreadIDTidUi)). | |||
| 432 | + | Msg("cef: file dialog continuation task executing") | |||
| 433 | + | continueCEFFileDialog(logger, callback, paths...) | |||
| 434 | + | logger.Debug(). | |||
| 435 | + | Int("path_count", len(paths)). | |||
| 436 | + | Strs("paths", paths). | |||
| 437 | + | Msg("cef: file dialog continuation task finished") | |||
| 438 | + | })) | |||
| 439 | + | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |||
| 440 | + | logCEFFileDialog(wv).Warn().Int32("post_result", result).Msg("cef: failed to post file dialog continuation to CEF UI thread") | |||
| 441 | + | } else { | |||
| 442 | + | logCEFFileDialog(wv).Debug().Int("path_count", len(paths)).Strs("paths", paths).Msg("cef: file dialog continuation task posted") | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func continueCEFFileDialogWithLogs(logger *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) { | |||
| 447 | + | logger = nonNilCEFFileDialogLogger(logger) | |||
| 448 | + | callbackPtr := rawCEFPointer(callback) | |||
| 449 | + | if callback == nil { | |||
| 450 | + | logger.Debug().Msg("cef: file dialog callback continuation skipped; callback is nil") | |||
| 451 | + | return | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | if len(filePaths) == 0 { | |||
| 455 | + | logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: invoking file dialog callback cancel") | |||
| 456 | + | callback.Cancel() | |||
| 457 | + | runtime.KeepAlive(callback) | |||
| 458 | + | logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned") | |||
| 459 | + | return | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | logger.Debug(). | |||
| 463 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |||
| 464 | + | Int("path_count", len(filePaths)). | |||
| 465 | + | Strs("paths", filePaths). | |||
| 466 | + | Msg("cef: allocating file dialog CEF string list") | |||
| 467 | + | list := newCEFStringList(filePaths...) | |||
| 468 | + | if list == 0 { | |||
| 469 | + | logger.Warn(). | |||
| 470 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |||
| 471 | + | Int("path_count", len(filePaths)). | |||
| 472 | + | Msg("cef: file dialog CEF string list allocation failed; canceling") | |||
| 473 | + | callback.Cancel() | |||
| 474 | + | runtime.KeepAlive(callback) | |||
| 475 | + | logger.Debug().Uint64("callback_ptr", uint64(callbackPtr)).Msg("cef: file dialog callback cancel returned after allocation failure") | |||
| 476 | + | return | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | listValue := uint64(list) | |||
| 480 | + | logger.Debug(). | |||
| 481 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |||
| 482 | + | Uint64("string_list", listValue). | |||
| 483 | + | Int("path_count", len(filePaths)). | |||
| 484 | + | Msg("cef: file dialog CEF string list allocated") | |||
| 485 | + | logger.Debug(). | |||
| 486 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |||
| 487 | + | Uint64("string_list", listValue). | |||
| 488 | + | Int("path_count", len(filePaths)). | |||
| 489 | + | Strs("paths", filePaths). | |||
| 490 | + | Msg("cef: invoking file dialog callback continuation") | |||
| 491 | + | callback.Cont(list) | |||
| 492 | + | logger.Debug(). | |||
| 493 | + | Uint64("callback_ptr", uint64(callbackPtr)). | |||
| 494 | + | Uint64("string_list", listValue). | |||
| 495 | + | Int("path_count", len(filePaths)). | |||
| 496 | + | Msg("cef: file dialog callback continuation returned") | |||
| 497 | + | logger.Debug().Uint64("string_list", listValue).Msg("cef: freeing file dialog CEF string list") | |||
| 498 | + | freeCEFStringList(list) | |||
| 499 | + | logger.Debug().Uint64("string_list", listValue).Msg("cef: freed file dialog CEF string list") | |||
| 500 | + | runtime.KeepAlive(callback) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func nonNilCEFFileDialogLogger(logger *zerolog.Logger) *zerolog.Logger { | |||
| 504 | + | if logger != nil { | |||
| 505 | + | return logger | |||
| 506 | + | } | |||
| 507 | + | return logging.FromContext(context.Background()) | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | type cefRawPointerHolder interface { | |||
| 511 | + | RawPointer() unsafe.Pointer | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func rawCEFPointer(v any) uintptr { | |||
| 515 | + | if v == nil { | |||
| 516 | + | return 0 | |||
| 517 | + | } | |||
| 518 | + | holder, ok := v.(cefRawPointerHolder) | |||
| 519 | + | if !ok { | |||
| 520 | + | return 0 | |||
| 521 | + | } | |||
| 522 | + | return uintptr(holder.RawPointer()) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | func logCEFFileDialog(wv *WebView) *zerolog.Logger { | |||
| 526 | + | if wv == nil || wv.ctx == nil { | |||
| 527 | + | return logging.FromContext(context.Background()) | |||
| 528 | + | } | |||
| 529 | + | return logging.FromContext(wv.ctx) | |||
| 530 | + | } | |||
A internal/infrastructure/cef/file_dialog_test.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/rs/zerolog" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type stubFileDialogCallback struct { | |
| 15 | + | list purecef.StringList | |
| 16 | + | paths []string | |
| 17 | + | canceled bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) { | |
| 21 | + | s.list = filePaths | |
| 22 | + | s.paths = decodeCEFStringList(filePaths) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (s *stubFileDialogCallback) Cancel() { | |
| 26 | + | s.canceled = true | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestGetDialogHandlerEnabled(t *testing.T) { | |
| 30 | + | h := &handlerSet{} | |
| 31 | + | require.Same(t, h, h.GetDialogHandler()) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) { | |
| 35 | + | require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |
| 36 | + | require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |
| 37 | + | require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestNormalizeGTKResponseID(t *testing.T) { | |
| 41 | + | require.Equal(t, -3, normalizeGTKResponseID(4294967293)) | |
| 42 | + | require.Equal(t, -6, normalizeGTKResponseID(4294967290)) | |
| 43 | + | require.Equal(t, 0, normalizeGTKResponseID(0)) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) { | |
| 47 | + | prevDecode := decodeCEFStringList | |
| 48 | + | decodeCEFStringList = func(list purecef.StringList) []string { | |
| 49 | + | switch list { | |
| 50 | + | case purecef.StringList(11): | |
| 51 | + | return []string{"image/*", ".txt"} | |
| 52 | + | case purecef.StringList(12): | |
| 53 | + | return []string{".png;.jpg", ".txt"} | |
| 54 | + | case purecef.StringList(13): | |
| 55 | + | return []string{"Image Files", "Text Files"} | |
| 56 | + | default: | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | defer func() { decodeCEFStringList = prevDecode }() | |
| 61 | + | ||
| 62 | + | var gotReq cefFileDialogRequest | |
| 63 | + | var gotCalls int | |
| 64 | + | h := &handlerSet{ | |
| 65 | + | wv: &WebView{ctx: context.Background()}, | |
| 66 | + | fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) { | |
| 67 | + | gotReq = req | |
| 68 | + | gotCalls++ | |
| 69 | + | }, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{}) | |
| 73 | + | ||
| 74 | + | require.Equal(t, int32(1), handled) | |
| 75 | + | require.Equal(t, 1, gotCalls) | |
| 76 | + | require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode) | |
| 77 | + | require.Equal(t, "Pick files", gotReq.Title) | |
| 78 | + | require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath) | |
| 79 | + | require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters) | |
| 80 | + | require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions) | |
| 81 | + | require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestFolderUploadExperimentPathsExpandsDirectoryFiles(t *testing.T) { | |
| 85 | + | dir := t.TempDir() | |
| 86 | + | first := filepath.Join(dir, "first.txt") | |
| 87 | + | second := filepath.Join(dir, "nested", "second.txt") | |
| 88 | + | require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755)) | |
| 89 | + | require.NoError(t, os.WriteFile(first, []byte("first"), 0o644)) | |
| 90 | + | require.NoError(t, os.WriteFile(second, []byte("second"), 0o644)) | |
| 91 | + | ||
| 92 | + | got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |
| 93 | + | ||
| 94 | + | require.ElementsMatch(t, []string{first, second}, got) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestFolderUploadExperimentPathsKeepsEmptyDirectoryPath(t *testing.T) { | |
| 98 | + | dir := t.TempDir() | |
| 99 | + | ||
| 100 | + | got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |
| 101 | + | ||
| 102 | + | require.Equal(t, []string{dir}, got) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | |
| 106 | + | prevDecode := decodeCEFStringList | |
| 107 | + | decodeCEFStringList = func(purecef.StringList) []string { return nil } | |
| 108 | + | defer func() { decodeCEFStringList = prevDecode }() | |
| 109 | + | ||
| 110 | + | prevContinue := continueCEFFileDialog | |
| 111 | + | continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) { | |
| 112 | + | recorder, ok := callback.(*stubFileDialogCallback) | |
| 113 | + | require.True(t, ok) | |
| 114 | + | recorder.paths = append([]string(nil), filePaths...) | |
| 115 | + | } | |
| 116 | + | defer func() { continueCEFFileDialog = prevContinue }() | |
| 117 | + | ||
| 118 | + | callback := &stubFileDialogCallback{} | |
| 119 | + | h := &handlerSet{ | |
| 120 | + | wv: &WebView{ctx: context.Background()}, | |
| 121 | + | fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) { | |
| 122 | + | continueCEFFileDialog(nil, cb, "/tmp/report.txt") | |
| 123 | + | }, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback) | |
| 127 | + | ||
| 128 | + | require.Equal(t, int32(1), handled) | |
| 129 | + | require.False(t, callback.canceled) | |
| 130 | + | require.Equal(t, []string{"/tmp/report.txt"}, callback.paths) | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 10 | + | "github.com/rs/zerolog" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type stubFileDialogCallback struct { | |||
| 15 | + | list purecef.StringList | |||
| 16 | + | paths []string | |||
| 17 | + | canceled bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (s *stubFileDialogCallback) Cont(filePaths purecef.StringList) { | |||
| 21 | + | s.list = filePaths | |||
| 22 | + | s.paths = decodeCEFStringList(filePaths) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (s *stubFileDialogCallback) Cancel() { | |||
| 26 | + | s.canceled = true | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestGetDialogHandlerEnabled(t *testing.T) { | |||
| 30 | + | h := &handlerSet{} | |||
| 31 | + | require.Same(t, h, h.GetDialogHandler()) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestOnFileDialogRejectsNilWebViewOrCallback(t *testing.T) { | |||
| 35 | + | require.Zero(t, (*handlerSet)(nil).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |||
| 36 | + | require.Zero(t, (&handlerSet{}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, &stubFileDialogCallback{})) | |||
| 37 | + | require.Zero(t, (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnFileDialog(nil, purecef.FileDialogModeFileDialogOpen, "", "", 0, 0, 0, nil)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestNormalizeGTKResponseID(t *testing.T) { | |||
| 41 | + | require.Equal(t, -3, normalizeGTKResponseID(4294967293)) | |||
| 42 | + | require.Equal(t, -6, normalizeGTKResponseID(4294967290)) | |||
| 43 | + | require.Equal(t, 0, normalizeGTKResponseID(0)) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestOnFileDialogDelegatesDecodedRequestToPresenter(t *testing.T) { | |||
| 47 | + | prevDecode := decodeCEFStringList | |||
| 48 | + | decodeCEFStringList = func(list purecef.StringList) []string { | |||
| 49 | + | switch list { | |||
| 50 | + | case purecef.StringList(11): | |||
| 51 | + | return []string{"image/*", ".txt"} | |||
| 52 | + | case purecef.StringList(12): | |||
| 53 | + | return []string{".png;.jpg", ".txt"} | |||
| 54 | + | case purecef.StringList(13): | |||
| 55 | + | return []string{"Image Files", "Text Files"} | |||
| 56 | + | default: | |||
| 57 | + | return nil | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | defer func() { decodeCEFStringList = prevDecode }() | |||
| 61 | + | ||||
| 62 | + | var gotReq cefFileDialogRequest | |||
| 63 | + | var gotCalls int | |||
| 64 | + | h := &handlerSet{ | |||
| 65 | + | wv: &WebView{ctx: context.Background()}, | |||
| 66 | + | fileDialogPresenter: func(_ *WebView, req cefFileDialogRequest, _ purecef.FileDialogCallback) { | |||
| 67 | + | gotReq = req | |||
| 68 | + | gotCalls++ | |||
| 69 | + | }, | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogOpenMultiple, "Pick files", "/tmp/start.txt", purecef.StringList(11), purecef.StringList(12), purecef.StringList(13), &stubFileDialogCallback{}) | |||
| 73 | + | ||||
| 74 | + | require.Equal(t, int32(1), handled) | |||
| 75 | + | require.Equal(t, 1, gotCalls) | |||
| 76 | + | require.Equal(t, purecef.FileDialogModeFileDialogOpenMultiple, gotReq.Mode) | |||
| 77 | + | require.Equal(t, "Pick files", gotReq.Title) | |||
| 78 | + | require.Equal(t, "/tmp/start.txt", gotReq.DefaultFilePath) | |||
| 79 | + | require.Equal(t, []string{"image/*", ".txt"}, gotReq.AcceptFilters) | |||
| 80 | + | require.Equal(t, []string{".png;.jpg", ".txt"}, gotReq.AcceptExtensions) | |||
| 81 | + | require.Equal(t, []string{"Image Files", "Text Files"}, gotReq.AcceptDescriptions) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestFolderUploadExperimentPathsExpandsDirectoryFiles(t *testing.T) { | |||
| 85 | + | dir := t.TempDir() | |||
| 86 | + | first := filepath.Join(dir, "first.txt") | |||
| 87 | + | second := filepath.Join(dir, "nested", "second.txt") | |||
| 88 | + | require.NoError(t, os.MkdirAll(filepath.Dir(second), 0o755)) | |||
| 89 | + | require.NoError(t, os.WriteFile(first, []byte("first"), 0o644)) | |||
| 90 | + | require.NoError(t, os.WriteFile(second, []byte("second"), 0o644)) | |||
| 91 | + | ||||
| 92 | + | got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |||
| 93 | + | ||||
| 94 | + | require.ElementsMatch(t, []string{first, second}, got) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestFolderUploadExperimentPathsKeepsEmptyDirectoryPath(t *testing.T) { | |||
| 98 | + | dir := t.TempDir() | |||
| 99 | + | ||||
| 100 | + | got := folderUploadExperimentPaths(&WebView{ctx: context.Background()}, purecef.FileDialogModeFileDialogOpenFolder, []string{dir}) | |||
| 101 | + | ||||
| 102 | + | require.Equal(t, []string{dir}, got) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestOnFileDialogPresenterCanContinueCallback(t *testing.T) { | |||
| 106 | + | prevDecode := decodeCEFStringList | |||
| 107 | + | decodeCEFStringList = func(purecef.StringList) []string { return nil } | |||
| 108 | + | defer func() { decodeCEFStringList = prevDecode }() | |||
| 109 | + | ||||
| 110 | + | prevContinue := continueCEFFileDialog | |||
| 111 | + | continueCEFFileDialog = func(_ *zerolog.Logger, callback purecef.FileDialogCallback, filePaths ...string) { | |||
| 112 | + | recorder, ok := callback.(*stubFileDialogCallback) | |||
| 113 | + | require.True(t, ok) | |||
| 114 | + | recorder.paths = append([]string(nil), filePaths...) | |||
| 115 | + | } | |||
| 116 | + | defer func() { continueCEFFileDialog = prevContinue }() | |||
| 117 | + | ||||
| 118 | + | callback := &stubFileDialogCallback{} | |||
| 119 | + | h := &handlerSet{ | |||
| 120 | + | wv: &WebView{ctx: context.Background()}, | |||
| 121 | + | fileDialogPresenter: func(_ *WebView, _ cefFileDialogRequest, cb purecef.FileDialogCallback) { | |||
| 122 | + | continueCEFFileDialog(nil, cb, "/tmp/report.txt") | |||
| 123 | + | }, | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | handled := h.OnFileDialog(nil, purecef.FileDialogModeFileDialogSave, "", "", 0, 0, 0, callback) | |||
| 127 | + | ||||
| 128 | + | require.Equal(t, int32(1), handled) | |||
| 129 | + | require.False(t, callback.canceled) | |||
| 130 | + | require.Equal(t, []string{"/tmp/report.txt"}, callback.paths) | |||
| 131 | + | } | |||
M internal/infrastructure/cef/webview_handlers.go +6 -4
| @@ -23,9 +23,10 @@ const ( | |||
| 23 | 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 24 | 24 | // methods can return the same receiver, avoiding extra allocations. | |
| 25 | 25 | type handlerSet struct { | |
| 26 | - | wv *WebView | |
| 27 | - | renderHandlerOnce sync.Once | |
| 28 | - | renderHandler purecef.RenderHandler | |
| 26 | + | wv *WebView | |
| 27 | + | fileDialogPresenter fileDialogPresenter | |
| 28 | + | renderHandlerOnce sync.Once | |
| 29 | + | renderHandler purecef.RenderHandler | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // Compile-time interface checks. | |
| @@ -37,6 +38,7 @@ var ( | |||
| 37 | 38 | _ purecef.RequestHandler = (*handlerSet)(nil) | |
| 38 | 39 | _ purecef.AudioHandler = (*handlerSet)(nil) | |
| 39 | 40 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | |
| 41 | + | _ purecef.DialogHandler = (*handlerSet)(nil) | |
| 40 | 42 | _ purecef.DownloadHandler = (*handlerSet)(nil) | |
| 41 | 43 | _ purecef.FindHandler = (*handlerSet)(nil) | |
| 42 | 44 | ) | |
| @@ -54,7 +56,7 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||
| 54 | 56 | } | |
| 55 | 57 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | |
| 56 | 58 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | |
| 57 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 59 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h } | |
| 58 | 60 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 59 | 61 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | |
| 60 | 62 | if h.downloadHandler() != nil { | |
| @@ -23,9 +23,10 @@ const ( | |||||
| 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | ||
| 24 | // methods can return the same receiver, avoiding extra allocations. | 24 | // methods can return the same receiver, avoiding extra allocations. | ||
| 25 | type handlerSet struct { | 25 | type handlerSet struct { | ||
| 26 | - | wv *WebView | 26 | + | wv *WebView |
| 27 | - | renderHandlerOnce sync.Once | 27 | + | fileDialogPresenter fileDialogPresenter |
| 28 | - | renderHandler purecef.RenderHandler | 28 | + | renderHandlerOnce sync.Once |
| 29 | + | renderHandler purecef.RenderHandler | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // Compile-time interface checks. | 32 | // Compile-time interface checks. | ||
| @@ -37,6 +38,7 @@ var ( | |||||
| 37 | _ purecef.RequestHandler = (*handlerSet)(nil) | 38 | _ purecef.RequestHandler = (*handlerSet)(nil) | ||
| 38 | _ purecef.AudioHandler = (*handlerSet)(nil) | 39 | _ purecef.AudioHandler = (*handlerSet)(nil) | ||
| 39 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | 40 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | ||
| 41 | + | _ purecef.DialogHandler = (*handlerSet)(nil) | |||
| 40 | _ purecef.DownloadHandler = (*handlerSet)(nil) | 42 | _ purecef.DownloadHandler = (*handlerSet)(nil) | ||
| 41 | _ purecef.FindHandler = (*handlerSet)(nil) | 43 | _ purecef.FindHandler = (*handlerSet)(nil) | ||
| 42 | ) | 44 | ) | ||
| @@ -54,7 +56,7 @@ func (h *handlerSet) GetAudioHandler() purecef.AudioHandler { | |||||
| 54 | } | 56 | } | ||
| 55 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | 57 | func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return nil } | ||
| 56 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | 58 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | ||
| 57 | - | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | 59 | + | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return h } |
| 58 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | 60 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | ||
| 59 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | 61 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { | ||
| 60 | if h.downloadHandler() != nil { | 62 | if h.downloadHandler() != nil { | ||