Skip to content

Commit 7143cf7

Browse files
committed
ui: wire env editor, native dialogs, import endpoint; move build deps to requirements.conf; add .gitignore
1 parent 3abeab2 commit 7143cf7

File tree

5 files changed

+248
-14
lines changed

5 files changed

+248
-14
lines changed

.gitignore

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*.pyo
5+
*.pyd
6+
7+
# Virtual environments
8+
.venv/
9+
venv/
10+
11+
# Environment files
12+
.env.local
13+
14+
# macOS
15+
.DS_Store
16+
17+
# UI build artifacts
18+
/ui/build/
19+
/ui/dist/
20+
/ui/*.spec
21+
22+
# Generated C wrappers
23+
*.x.c
24+
25+
# Editor directories
26+
.vscode/
127
# Local configuration
228
.env.local
329
src/packages.conf

ui/app.py

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ def api_env():
144144
set_key(str(ENV_LOCAL), k, str(v))
145145
return jsonify({'status':'ok'})
146146

147+
148+
@app.route('/api/env/exists')
149+
def api_env_exists():
150+
try:
151+
return jsonify({'exists': ENV_LOCAL.exists()})
152+
except Exception as e:
153+
return jsonify({'error': str(e)}), 500
154+
155+
156+
@app.route('/api/env/init', methods=['POST'])
157+
def api_env_init():
158+
try:
159+
# If an example file exists, copy it as a starting point
160+
if ENV_EXAMPLE.exists():
161+
ENV_LOCAL.write_text(ENV_EXAMPLE.read_text())
162+
else:
163+
# create an empty .env.local
164+
ENV_LOCAL.write_text('')
165+
return jsonify({'status': 'ok'})
166+
except Exception as e:
167+
return jsonify({'error': str(e)}), 500
168+
147169
@app.route('/api/packages')
148170
def api_packages():
149171
pkgs=read_packages()
@@ -278,6 +300,20 @@ def api_action_install():
278300
except Exception as e:
279301
return jsonify({'error': str(e)}), 500
280302

303+
304+
@app.route('/api/action/import-installed', methods=['POST'])
305+
def api_action_import_installed():
306+
# Run bash src/import_installed.sh
307+
script = BASE_DIR / 'src' / 'import_installed.sh'
308+
if not script.exists():
309+
return jsonify({'error':'import_installed.sh script not found'}), 500
310+
try:
311+
cmd = ['bash', str(script)]
312+
res = subprocess.run(cmd, capture_output=True, text=True)
313+
return jsonify({'stdout': res.stdout, 'stderr': res.stderr, 'code': res.returncode})
314+
except Exception as e:
315+
return jsonify({'error': str(e)}), 500
316+
281317
@app.route('/api/action/wifi-export', methods=['POST'])
282318
def api_action_wifi_export():
283319
data = request.json or {}
@@ -337,9 +373,45 @@ def start_server(port):
337373
t = threading.Thread(target=start_server, args=(port,), daemon=True)
338374
t.start()
339375

340-
# Determine if we are running in a built executable or dev mode
341-
# If dev mode, maybe don't open webview if FLASK_DEBUG is on?
342-
# But user wants standalone.
343-
344-
window = webview.create_window('ok_computer', f'http://localhost:{port}')
376+
# Provide a small JS API to open native file/folder dialogs when running
377+
# inside the pywebview window. Fall back to prompt in browsers.
378+
class FileDialogApi:
379+
def __init__(self):
380+
# Window will be set after creation
381+
self.window = None
382+
383+
def open_file(self, title="Select file"):
384+
try:
385+
# Use pywebview native dialog if available
386+
if self.window:
387+
res = webview.create_file_dialog(self.window, webview.OPEN_DIALOG)
388+
# pywebview returns a list for multiple selections
389+
if isinstance(res, (list, tuple)):
390+
return res[0] if res else ""
391+
return res or ""
392+
except Exception:
393+
pass
394+
return ""
395+
396+
def open_dir(self, title="Select folder"):
397+
try:
398+
if self.window:
399+
res = webview.create_file_dialog(self.window, webview.FOLDER_DIALOG)
400+
if isinstance(res, (list, tuple)):
401+
return res[0] if res else ""
402+
return res or ""
403+
except Exception:
404+
pass
405+
return ""
406+
407+
api = FileDialogApi()
408+
409+
# Create webview window and expose the API to JS via `window.pywebview.api`
410+
try:
411+
window = webview.create_window('ok_computer', f'http://localhost:{port}', js_api=api)
412+
api.window = window
413+
except TypeError:
414+
# Older pywebview versions or contexts where js_api isn't supported
415+
window = webview.create_window('ok_computer', f'http://localhost:{port}')
416+
345417
webview.start()

ui/build.sh

100644100755
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ cd "$HERE"
1111
python3 -m venv .venv
1212
. .venv/bin/activate
1313
pip install -U pip
14-
pip install -r requirements.txt
14+
# Install runtime and build requirements (build-only deps in requirements.conf)
15+
pip install -r requirements.txt -r requirements.conf
1516

1617
# Determine add-data separator (':' on Unix, ';' on Windows)
1718
SEP=:
@@ -21,7 +22,7 @@ case "$(uname -s)" in
2122
esac
2223

2324
# Run PyInstaller including templates and static folders
24-
python -m PyInstaller --name "$NAME" --onefile \
25+
python -m PyInstaller --name "$NAME" --onefile --noconfirm --clean \
2526
--noconsole \
2627
--add-data "templates${SEP}templates" \
2728
--add-data "static${SEP}static" \

ui/requirements.conf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Build-only requirements for the UI
2+
pyinstaller

ui/static/js/app.js

Lines changed: 140 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,137 @@ async function loadEnv() {
2626
data.forEach(item => {
2727
const div = document.createElement('div');
2828
div.className = 'env-item';
29-
29+
30+
const key = item.key;
31+
const val = item.value == null ? '' : item.value;
32+
const desc = item.desc || '';
33+
34+
// Infer type from key name and value
35+
function inferType(key, value) {
36+
const k = key.toUpperCase();
37+
const v = (value || '').toString().toLowerCase();
38+
if (/(_DIR|_PATH|_FILE|VAULT|CONFIG|DB|KEY_FILE)/i.test(k)) {
39+
if (/_FILE$/i.test(k) || /_KEY_FILE$/i.test(k)) return {type: 'path', mode: 'file'};
40+
if (/_DIR$/i.test(k) || /_DIR\b/i.test(k)) return {type: 'path', mode: 'dir'};
41+
return {type: 'path', mode: 'any'};
42+
}
43+
if (v === 'true' || v === 'false' || v === '0' || v === '1') return {type: 'boolean'};
44+
if (k.match(/(HOUR|MINUTE|PORT|COUNT|NUM|SIZE|SECONDS|DAYS)/)) return {type: 'number'};
45+
if (/^\d+$/.test(v)) return {type: 'number'};
46+
return {type: 'string'};
47+
}
48+
49+
const info = inferType(key, val);
50+
51+
// Build inner HTML per type
52+
let inputHtml = '';
53+
if (info.type === 'boolean') {
54+
const checked = (val === 'true' || val === '1') ? 'checked' : '';
55+
inputHtml = `<input type="checkbox" data-key="${key}" ${checked}>`;
56+
} else if (info.type === 'number') {
57+
let attrs = '';
58+
if (key.toUpperCase().includes('HOUR')) attrs = 'min="0" max="23"';
59+
if (key.toUpperCase().includes('MINUTE')) attrs = 'min="0" max="59"';
60+
inputHtml = `<input type="number" data-key="${key}" value="${val}" ${attrs}>`;
61+
} else if (info.type === 'path') {
62+
// show text input + browse button (browse uses prompt as fallback)
63+
inputHtml = `<div style="display:flex;gap:8px;align-items:center;"><input type="text" data-key="${key}" value="${val}" style="flex:1"><button type="button" class="secondary" data-browse-for="${key}">Parcourir</button></div>`;
64+
} else {
65+
inputHtml = `<input type="text" data-key="${key}" value="${val}">`;
66+
}
67+
3068
div.innerHTML = `
31-
<label class="env-label">${item.key}</label>
32-
<div class="env-desc">${item.desc || ''}</div>
33-
<input type="text" data-key="${item.key}" value="${item.value || ''}">
69+
<label class="env-label">${key}</label>
70+
<div class="env-desc">${desc}</div>
71+
${inputHtml}
3472
`;
3573
container.appendChild(div);
3674
});
75+
76+
// Attach browse handlers. Prefer native pywebview dialogs when available,
77+
// otherwise fall back to a simple `prompt`.
78+
container.querySelectorAll('button[data-browse-for]').forEach(btn => {
79+
btn.addEventListener('click', async (e) => {
80+
const key = btn.dataset.browseFor;
81+
const input = container.querySelector(`input[data-key="${key}"]`);
82+
if (!input) return;
83+
84+
const isFile = /_FILE$|KEY_FILE/i.test(key);
85+
const isDir = /_DIR$|VAULT|CONFIG|SYNC_DIR/i.test(key);
86+
87+
// If running inside pywebview, use the exposed API
88+
if (window.pywebview && window.pywebview.api) {
89+
try {
90+
let path = '';
91+
if (isDir) {
92+
path = await window.pywebview.api.open_dir(`Select folder for ${key}`);
93+
} else {
94+
path = await window.pywebview.api.open_file(`Select file for ${key}`);
95+
}
96+
if (path) input.value = path;
97+
return;
98+
} catch (err) {
99+
// fall through to prompt fallback
100+
}
101+
}
102+
103+
// Browser or fallback
104+
const current = input.value || '';
105+
const chosen = prompt('Entrez le chemin pour ' + key, current);
106+
if (chosen !== null) input.value = chosen;
107+
});
108+
});
109+
110+
// If .env.local does not exist, show init button
111+
try {
112+
const existsRes = await fetch('/api/env/exists');
113+
const existsData = await existsRes.json();
114+
if (!existsData.exists) {
115+
const initDiv = document.createElement('div');
116+
initDiv.style.marginTop = '10px';
117+
initDiv.innerHTML = `<button id="btn-init-env" class="secondary">Initialiser .env.local</button> <span style="color:#888; font-size:12px; margin-left:8px;">Crée .env.local à partir de .env.example</span>`;
118+
container.prepend(initDiv);
119+
document.getElementById('btn-init-env').addEventListener('click', async () => {
120+
if (!confirm('Créer .env.local à partir de .env.example ?')) return;
121+
const btn = document.getElementById('btn-init-env');
122+
btn.disabled = true;
123+
btn.innerText = 'Initialisation...';
124+
try {
125+
const r = await fetch('/api/env/init', {method: 'POST'});
126+
const j = await r.json();
127+
if (j.error) alert('Erreur: ' + j.error);
128+
else {
129+
alert('.env.local créé');
130+
// reload form
131+
loadEnv();
132+
}
133+
} catch (e) {
134+
alert('Request failed: ' + e);
135+
} finally {
136+
btn.disabled = false;
137+
btn.innerText = 'Initialiser .env.local';
138+
}
139+
});
140+
}
141+
} catch (e) {
142+
// ignore existence check failures
143+
}
37144
}
38145

39146
async function saveEnv() {
40147
const inputs = document.querySelectorAll('#env-form input');
41148
const data = {};
42-
inputs.forEach(i => data[i.dataset.key] = i.value);
149+
inputs.forEach(i => {
150+
const key = i.dataset.key;
151+
if (!key) return;
152+
if (i.type === 'checkbox') {
153+
data[key] = i.checked ? 'true' : 'false';
154+
} else if (i.type === 'number') {
155+
data[key] = i.value.toString();
156+
} else {
157+
data[key] = i.value;
158+
}
159+
});
43160

44161
await fetch('/api/env', {
45162
method: 'POST',
@@ -130,8 +247,24 @@ async function runUpdate() {
130247
}
131248

132249
async function importInstalled() {
133-
// Placeholder: currently calls update.sh? No, we need import_installed.sh
134-
alert("This feature triggers 'src/import_installed.sh' (not fully wired in API yet, assuming bash access)");
250+
const btn = document.querySelector('button[onclick="importInstalled()"]');
251+
const originalText = btn ? btn.innerText : '';
252+
if(btn) { btn.disabled = true; btn.innerText = t('running'); }
253+
254+
try {
255+
const res = await fetch('/api/action/import-installed', { method: 'POST' });
256+
const data = await res.json();
257+
console.log(data);
258+
if(data.error) {
259+
alert('Error: ' + data.error);
260+
} else {
261+
alert('Exit Code: ' + data.code + '\n\nSTDOUT:\n' + (data.stdout || '').slice(0,2000) + '\n\nSTDERR:\n' + (data.stderr || '').slice(0,2000));
262+
}
263+
} catch(e) {
264+
alert('Request failed: ' + e);
265+
} finally {
266+
if(btn) { btn.disabled = false; btn.innerText = originalText; }
267+
}
135268
}
136269

137270
// SEARCH

0 commit comments

Comments
 (0)