Files

164 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Installer frontends sharing settings and backend preparation."""
import curses
import importlib.util
import os
from pathlib import Path
import subprocess
def load(name):
spec = importlib.util.spec_from_file_location(name.replace('-', '_'), Path(__file__).with_name(name+'.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def desktop_environment():
environment = dict(os.environ, GSK_RENDERER='cairo')
if environment.get('DISPLAY') or environment.get('WAYLAND_DISPLAY'):
return environment
found = load('tray-session').discover(os.getuid())
if found:
environment.update(found[1])
return environment
return None
def choose_screen(screen, title, choices, selected=0):
selected = max(0, min(selected, len(choices)-1))
while True:
screen.erase()
height, width = screen.getmaxyx()
if height < 10 or width < 40: raise RuntimeError('Enlarge the terminal to at least 40 columns and 10 rows')
screen.addnstr(1, 2, title, width-4, curses.A_BOLD)
screen.addnstr(height-2, 2, '↑/↓ select · Enter edit/accept · Esc cancel', width-4)
visible = height-5
offset = max(0, min(selected-visible+1, len(choices)-visible))
for row, choice in enumerate(choices[offset:offset+visible], 3):
screen.addnstr(row, 2, choice, width-4, curses.A_REVERSE if row-3+offset == selected else 0)
screen.refresh()
key = screen.getch()
if key == curses.KEY_UP: selected = (selected-1) % len(choices)
elif key == curses.KEY_DOWN: selected = (selected+1) % len(choices)
elif key == curses.KEY_HOME: selected = 0
elif key == curses.KEY_END: selected = len(choices)-1
elif key in (10, 13, curses.KEY_ENTER): return selected
elif key == 27: raise RuntimeError('Setup canceled')
def entry(screen, title, initial='', secret=False):
value = initial
curses.curs_set(1)
try:
while True:
screen.erase()
height, width = screen.getmaxyx()
screen.addnstr(1, 2, title, width-4, curses.A_BOLD)
shown = '•'*len(value) if secret else value
screen.addnstr(3, 2, shown[-(width-5):], width-4)
screen.addnstr(height-2, 2, 'Enter accept · Esc cancel · Ctrl+U clear', width-4)
screen.move(3, min(2+len(value), width-3))
screen.refresh()
key = screen.get_wch()
if key in ('\n', '\r'): return value
if key == '\x1b': raise RuntimeError('Setup canceled')
if key == '\x15': value = ''
elif key in ('\x7f', '\b', curses.KEY_BACKSPACE): value = value[:-1]
elif isinstance(key, str) and key.isprintable(): value += key
finally:
curses.curs_set(0)
def tui_options(screen, backend, home, requested, language, key_file):
curses.curs_set(0)
values = backend.read_values(home)
mode = requested or 'groq'
if requested is None and values.get('VOICE_BACKEND') == 'local':
mode = 'local-balanced' if 'small' in values.get('VOICE_MODEL', '') else 'local-fast'
language = backend.normalize_language(language or values.get('VOICE_LANG', 'en'))
options = {}
specs = [spec for section in backend.SETTINGS.values() for spec in section]
specs.append(('VOICE_CLEANUP', 'Clean up transcript', 'bool', None, True))
specs.append(('GROQ_USAGE_NOTIFY', 'In-headset Groq notifications', 'bool', None, True))
specs.append(('VOICE_SLASH', 'Convert spoken slash to /', 'bool', None, False))
for spec in specs:
key, _, kind = spec[:3]
default = spec[4]
value = values.get(key, default)
options[key] = ('1' if str(value).lower() in ('1', 'true', 'yes', 'on') else '0') if kind == 'bool' else str(value)
api_key = ''
selected = 0
while True:
names = ['Transcription path: '+backend.MODES[mode], 'Language: '+('Automatic detection' if language == 'auto' else backend.LANGUAGES[language])]
cloud_keys = ('VOICE_CLEANUP', 'GROQ_USAGE_NOTIFY', 'VOICE_SLASH')
visible_specs = [spec for spec in specs if mode == 'groq' or spec[0] not in cloud_keys]
if mode == 'groq':
names.append('Groq API key: '+('Provided key file' if key_file else 'Saved/provided' if api_key or values.get('GROQ_API_KEY') else 'Not set'))
for spec in visible_specs:
value = options[spec[0]]
names.append(spec[1]+': '+(('On' if value == '1' else 'Off') if spec[2] == 'bool' else backend.option_label(value) if spec[2] == 'enum' else value))
names.append('Install and start Frame Voice')
selected = choose_screen(screen, 'Frame Voice installer', names, selected)
if selected == 0:
modes = list(backend.MODES)
mode = modes[choose_screen(screen, 'Transcription path', list(backend.MODES.values()), modes.index(mode))]
elif selected == 1:
codes = ['auto', 'en']+[code for code in backend.LANGUAGES if code != 'en']
labels = ['Automatic detection', 'English']+[backend.LANGUAGES[code] for code in codes[2:]]
language = codes[choose_screen(screen, 'Language', labels, codes.index(language))]
elif mode == 'groq' and selected == 2:
api_key = entry(screen, 'Groq API key (empty keeps the saved key)', api_key, secret=True)
elif selected == len(names)-1:
maximum = float(options['VOICE_MAX'])
if not 0.1 <= maximum <= 120: raise RuntimeError('Maximum recording seconds must be 0.1–120')
if mode != 'groq':
for key in cloud_keys: options.pop(key, None)
return mode, language, api_key, options
else:
spec = visible_specs[selected-(3 if mode == 'groq' else 2)]
key, label, kind = spec[:3]
if kind == 'bool': options[key] = '0' if options[key] == '1' else '1'
elif kind == 'enum':
choices = spec[3]
options[key] = choices[choose_screen(screen, label, [backend.option_label(value) for value in choices], choices.index(options[key]) if options[key] in choices else 0)]
else: options[key] = entry(screen, label, options[key])
def run_tui(backend, home, requested, language, key_file):
try:
terminal = open('/dev/tty', 'r+')
except OSError as error:
raise RuntimeError('The TUI needs a terminal. Agents should use --non-interactive --backend MODE --language CODE and --groq-key-file PATH for Groq.') from error
saved = [os.dup(fd) for fd in (0, 1)]
try:
for fd in (0, 1): os.dup2(terminal.fileno(), fd)
mode, language, key, options = curses.wrapper(tui_options, backend, home, requested, language, key_file)
finally:
for fd, original in zip((0, 1), saved):
os.dup2(original, fd)
os.close(original)
terminal.close()
prepared, _ = backend.prepare(home, mode, key_file=key_file, api_key=key, allow_prompt=False, language=language)
prepared.update(options)
backend.input_permissions(os.getuid())
backend.write_values(home/'.config/frame-voice/env', prepared)
def configure(backend, home, ui='auto', requested=None, language=None, key_file=None):
if ui == 'auto' and requested is not None:
backend.configure(home, requested, key_file, language=language)
return
environment = desktop_environment() if ui != 'tui' else None
if ui == 'gui' or ui == 'auto' and environment:
if not environment: raise RuntimeError('Open Desktop to use the GUI installer, or choose --tui')
command = ['python3', str(Path(__file__).with_name('config-gui.py')), '--setup']
if requested: command += ['--backend', requested]
if language: command += ['--language', language]
if key_file: command += ['--groq-key-file', str(key_file)]
result = subprocess.run(command, env=environment)
if result.returncode: raise RuntimeError('GUI setup canceled or failed')
backend.input_permissions(os.getuid(), gui=True)
else:
run_tui(backend, home, requested, language, key_file)