From 81e9b27ed99db2c3061d73cbad41f592eeea1abf Mon Sep 17 00:00:00 2001 From: Mamat79 Date: Mon, 10 Aug 2026 22:41:32 +0200 Subject: [PATCH] Add automatic Windows and controller startup --- CHANGELOG.md | 3 + README.md | 8 +- .../windows/SiLeMIO-Controller-Studio.iss | 11 + packaging/windows/uninstall.ps1 | 1 + scripts/generate_manuals.py | 12 + src/silemio_control_hub/desktop.py | 208 +++++++++++++++--- src/silemio_control_hub/desktop_settings.py | 5 + ...ler-Studio-for-LiveProfessor-Manual-EN.pdf | Bin 1067153 -> 1067660 bytes ...ler-Studio-for-LiveProfessor-Notice-FR.pdf | Bin 1068008 -> 1068554 bytes .../runtime/ec4_liveprofessor.py | 54 ++++- src/silemio_control_hub/windows_startup.py | 102 +++++++++ tests/test_bridge.py | 65 ++++++ tests/test_desktop.py | 6 + tests/test_desktop_settings.py | 4 + tests/test_windows_startup.py | 90 ++++++++ 15 files changed, 538 insertions(+), 31 deletions(-) create mode 100644 src/silemio_control_hub/windows_startup.py create mode 100644 tests/test_windows_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f275c..bff115b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## V.2026 (2026.4) — 2026-08-10 +- Ajoute deux options indépendantes : lancement de Controller Studio avec Windows dans la zone de notification et connexion automatique du contrôleur sélectionné au démarrage, avec plusieurs tentatives silencieuses si les ports ne sont pas encore prêts. +- Maintient sur l'EC4 un message localisé d'attente de LiveProfessor après la connexion MIDI, puis restaure automatiquement les paramètres dès le premier retour OSC. +- Corrige la fenêtre de réglages EC4 afin que tous les boutons restent entièrement visibles en français comme en anglais, même lorsque la largeur disponible est réduite. - Corrige la création d'une copie AutoMap lorsqu'aucun contrôleur Companion/OSC n'existe encore et que l'utilisateur choisit **Créer un nouveau contrôleur depuis le profil choisi**. - Valide la sélection personnalisée sur un projet réel de 41 instances : tous les plug-ins peuvent être mappés, ou un type/une instance peut être exclu avec les cases sans modifier le projet source. - Ajoute un véritable fabricant de contrôleurs dans la Banque : encodeurs absolus ou relatifs, faders, boutons, ordre physique, banques et pages. diff --git a/README.md b/README.md index dfb9fa4..159a5de 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Controller Studio transforme un contrôleur MIDI en surface de contrôle organis |---|---|---|---| | Pilote EC4 temps réel, banques, push, Shift, labels, valeurs et reconnexion | Profils prêts à exporter et éditeur de contrôleur | Vrais noms lus dans les plug-ins installés, récupération globale, priorités et cases individuelles | Choix des plug-ins et instances, UniBank ou FullBank, copie `.rack2` validée | -L’interface existe en français et en anglais, se réduit dans la zone de notification et place le journal temps réel dans une fenêtre séparée. +L’interface existe en français et en anglais, se réduit dans la zone de notification et place le journal temps réel dans une fenêtre séparée. Deux options indépendantes permettent de lancer Controller Studio avec Windows puis de connecter automatiquement le contrôleur sélectionné, sans devoir cliquer sur **Démarrer** après chaque redémarrage du serveur. ## Utilisation en trois étapes @@ -116,7 +116,7 @@ Cliquez sur **Créer un contrôleur…** pour partir d’un modèle de huit enco **Enregistrer dans ma banque** valide le profil et le rend disponible hors ligne. **Enregistrer + créer .ctrl2** produit aussitôt le fichier LiveProfessor. Le remplacement d’un profil personnel conserve une sauvegarde de la version précédente. La page Live donne aussi accès à **Configurer / apprentissage MIDI…** pour le contrôleur actif ; le setup/groupe visible avec l’EC4 reste une fonction spécifique à ce matériel. -La fenêtre **Réglages** de la page Live retrouve les paramètres avancés d’EC4 Bridge : cadence de l’Overlay, durée d’affichage, rafraîchissement Companion et des labels, délai de confirmation LiveProfessor et affichage persistant. Ces temporisations sont disponibles pour tous les contrôleurs compatibles ; les outils setup/groupe et SysEx restent affichés uniquement pour l’EC4. +La fenêtre **Réglages** de la page Live retrouve les paramètres avancés d’EC4 Bridge : cadence de l’Overlay, durée d’affichage, rafraîchissement Companion et des labels, délai de confirmation LiveProfessor et affichage persistant. Ces temporisations sont disponibles pour tous les contrôleurs compatibles ; les outils setup/groupe et SysEx restent affichés uniquement pour l’EC4. Lorsque l’EC4 est connecté avant LiveProfessor, son écran affiche une attente localisée puis revient automatiquement aux paramètres dès que LiveProfessor répond. Pour proposer un contrôleur à la bibliothèque commune : @@ -238,7 +238,7 @@ Controller Studio turns a MIDI controller into an organized control surface for |---|---|---|---| | Real-time EC4 driver, banks, push, Shift, labels, values and reconnection | Ready-to-export profiles and a controller editor | Real names read from installed plug-ins, batch retrieval, priorities and individual checkboxes | Plug-in and instance selection, UniBank or FullBank, validated `.rack2` copy | -The interface is available in French and English, minimizes to the notification area and keeps the real-time log in a separate window. +The interface is available in French and English, minimizes to the notification area and keeps the real-time log in a separate window. Two independent options can launch Controller Studio with Windows and then connect the selected controller automatically, so a server restart no longer requires clicking **Start**. ## Three-step workflow @@ -312,7 +312,7 @@ Click **Create a controller…** to start with eight encoders, then add, remove, **Save to my bank** validates the profile and makes it available offline. **Save + create .ctrl2** immediately produces the LiveProfessor file. Replacing a personal profile keeps a backup of the previous version. The Live page also provides **Configure / MIDI Learn…** for the active controller; the setup/group section shown with the EC4 remains hardware-specific. -The Live page **Settings** window restores EC4 Bridge’s advanced controls: Overlay update rate and duration, Companion and label refresh delays, LiveProfessor feedback timeout, and persistent display. These timings are available to every compatible controller; setup/group and SysEx tools remain EC4-only. +The Live page **Settings** window restores EC4 Bridge’s advanced controls: Overlay update rate and duration, Companion and label refresh delays, LiveProfessor feedback timeout, and persistent display. These timings are available to every compatible controller; setup/group and SysEx tools remain EC4-only. If the EC4 connects before LiveProfessor, its screen shows a localized waiting message and automatically restores parameter feedback as soon as LiveProfessor responds. To submit a controller to the shared library: diff --git a/packaging/windows/SiLeMIO-Controller-Studio.iss b/packaging/windows/SiLeMIO-Controller-Studio.iss index 77960fc..0275eaa 100644 --- a/packaging/windows/SiLeMIO-Controller-Studio.iss +++ b/packaging/windows/SiLeMIO-Controller-Studio.iss @@ -58,3 +58,14 @@ Name: "{autodesktop}\{#MyShortcutName}"; Filename: "{app}\{#MyAppExeName}"; Work [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent + +[Code] +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + RegDeleteValue( + HKCU, + 'Software\Microsoft\Windows\CurrentVersion\Run', + 'Controller Studio for LiveProfessor' + ); +end; diff --git a/packaging/windows/uninstall.ps1 b/packaging/windows/uninstall.ps1 index fd978d5..e1b7bd0 100644 --- a/packaging/windows/uninstall.ps1 +++ b/packaging/windows/uninstall.ps1 @@ -19,6 +19,7 @@ $shortcutPaths = @( foreach ($shortcut in $shortcutPaths) { Remove-Item -LiteralPath $shortcut -Force -ErrorAction SilentlyContinue } +Remove-ItemProperty -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'Controller Studio for LiveProfessor' -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\SiLeMIOControllerStudio' -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $installRoot 'Controller-Studio-for-LiveProfessor.exe') -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $installRoot 'SiLeMIO-Controller-Studio.exe') -Force -ErrorAction SilentlyContinue diff --git a/scripts/generate_manuals.py b/scripts/generate_manuals.py index 6612ae0..c6635ef 100644 --- a/scripts/generate_manuals.py +++ b/scripts/generate_manuals.py @@ -571,6 +571,10 @@ def english_content() -> dict: "p", "Le sélecteur de la page Live reste synchronisé avec la banque. Configurer / apprentissage MIDI ouvre l'éditeur pour le contrôleur actif. Lorsqu'un pilote temps réel est disponible, les boutons Démarrer, Arrêter, les banques et les réglages adaptés deviennent accessibles. Le setup et le groupe affichés avec l'EC4 sont propres à ce matériel. Sinon, le profil reste immédiatement utilisable pour l'export et AutoMap.", ), + ( + "p", + "L'option de connexion automatique réutilise toujours le contrôleur sélectionné. Avec l'EC4, le message de connexion suit la langue de l'application. Si LiveProfessor n'a pas encore démarré, l'écran reste sur Attente démarrage / LiveProfessor ou Waiting for / LiveProfessor, puis revient automatiquement aux paramètres dès le premier retour OSC.", + ), ("h2", "Exporter vers LiveProfessor"), ( "steps", @@ -666,6 +670,11 @@ def english_content() -> dict: "p", "Réglages retrouve la petite fenêtre avancée d'EC4 Bridge : cadence et durée de l'Overlay, rafraîchissement Companion et des labels, délai maximal du retour LiveProfessor et affichage persistant. Ces temporisations sont communes aux contrôleurs compatibles ; les commandes setup/groupe et SysEx restent propres à l'EC4.", ), + ("h2", "Démarrage automatique d'un serveur"), + ( + "p", + "Deux cases indépendantes sont disponibles sur la page Live et dans Options. Lancer Controller Studio avec Windows ouvre l'application directement dans la zone de notification. Démarrer automatiquement la connexion du contrôleur sélectionné lance ensuite le moteur sans clic manuel. Si Windows n'a pas encore rendu les ports MIDI disponibles, Controller Studio réessaie silencieusement plusieurs fois. Laissez aussi LiveProfessor configuré pour démarrer avec Windows si le serveur doit devenir autonome.", + ), ("h2", "Langue et zone de notification"), ( "p", @@ -746,6 +755,7 @@ def english_content() -> dict: ("p", "Edit / duplicate creates a personal copy of a built-in profile without changing the original. Import a profile installs validated JSON. Every personal replacement keeps a backup."), ("h2", "Active controller"), ("p", "The Live page selector stays synchronized with the bank. Configure / MIDI Learn opens the editor for the active controller. When a real-time driver is available, Start, Stop, bank, and matching settings become available. The setup and group shown with the EC4 are specific to that hardware. Otherwise, the profile remains ready for export and AutoMap."), + ("p", "Automatic connection always reuses the selected controller. On the EC4, the connection message follows the application language. If LiveProfessor has not started yet, the screen remains on Attente demarrage / LiveProfessor or Waiting for / LiveProfessor, then automatically restores the parameter display on the first OSC feedback."), ("h2", "Export to LiveProfessor"), ("steps", [("1", "Select the controller in the bank."), ("2", "Click Export controller .ctrl2."), ("3", "In LiveProfessor, open Hardware Controllers Setup, then Load from file.")]), ], @@ -787,6 +797,8 @@ def english_content() -> dict: ("p", "The Live page keeps only the active controller, essential actions, status, and last event. MIDI/OSC connections are under Settings, and the complete log opens in a separate window."), ("h2", "Responsiveness, feedback, and Overlay"), ("p", "Settings restores EC4 Bridge's compact advanced window: Overlay update rate and duration, Companion and label refresh delays, LiveProfessor feedback timeout, and persistent display. These timings are shared by compatible controllers; setup/group and SysEx commands remain EC4-only."), + ("h2", "Automatic server startup"), + ("p", "Two independent checkboxes are available on the Live page and under Options. Launch Controller Studio with Windows opens the application directly in the notification area. Connect the selected controller automatically then starts the engine without a manual click. If Windows has not exposed the MIDI ports yet, Controller Studio retries silently several times. Configure LiveProfessor to start with Windows as well when the server must become autonomous."), ("h2", "Language and notification area"), ("p", "Options > Language switches immediately between French and English. Minimizing to the notification area hides the window without stopping an active engine; Quit actually stops the application."), ("h2", "Manual, updates, and support"), diff --git a/src/silemio_control_hub/desktop.py b/src/silemio_control_hub/desktop.py index 8f0e433..6ac444d 100644 --- a/src/silemio_control_hub/desktop.py +++ b/src/silemio_control_hub/desktop.py @@ -8,6 +8,7 @@ import os import queue import re +import sys import threading import time import tkinter as tk @@ -98,11 +99,13 @@ from .transports.midi import input_names, output_names from .transports.osc import decode_message, encode_message from .windows_tray import TrayCommand, WindowsTray +from .windows_startup import set_start_with_windows, starts_with_windows from .workflow import prepare_liveprofessor_project PRODUCT_ICON_PATH = Path(__file__).resolve().parent / "assets" / "controller-studio.ico" DISPLAY_VERSION = f"V.{__version__}" +AUTO_START_RETRY_DELAYS_MS = (1200, 3000, 6000, 12000, 20000) UI_TEXT = { @@ -302,6 +305,19 @@ "minimize": "Réduire dans la zone de notification", "quit": "Quitter", "close_to_tray": "Réduire dans la zone de notification à la fermeture", + "start_with_windows": "Lancer Controller Studio avec Windows", + "auto_start_runtime": ( + "Démarrer automatiquement la connexion du contrôleur sélectionné" + ), + "startup_windows_enabled": ( + "Controller Studio démarrera avec Windows dans la zone de notification." + ), + "startup_windows_disabled": "Démarrage avec Windows désactivé.", + "startup_registration_error": "Démarrage avec Windows impossible : {error}", + "runtime_auto_start_attempt": "Connexion automatique à {controller}…", + "runtime_auto_start_retry": ( + "Connexion automatique impossible ; nouvel essai dans {seconds} s." + ), "language": "Langue", "french": "Français", "english": "English", @@ -823,6 +839,17 @@ "minimize": "Minimize to notification area", "quit": "Quit", "close_to_tray": "Minimize to notification area when closing", + "start_with_windows": "Launch Controller Studio with Windows", + "auto_start_runtime": "Connect the selected controller automatically on startup", + "startup_windows_enabled": ( + "Controller Studio will start with Windows in the notification area." + ), + "startup_windows_disabled": "Windows startup disabled.", + "startup_registration_error": "Could not configure Windows startup: {error}", + "runtime_auto_start_attempt": "Connecting automatically to {controller}…", + "runtime_auto_start_retry": ( + "Automatic connection failed; trying again in {seconds} s." + ), "language": "Language", "french": "Français", "english": "English", @@ -1210,12 +1237,19 @@ def live_runtime_supported(profile_id: str | None) -> bool: class ControlHubDesktop: - def __init__(self, root: tk.Tk) -> None: + def __init__(self, root: tk.Tk, *, start_minimized: bool = False) -> None: self.root = root self.settings_path = default_desktop_settings_path() self.settings = load_desktop_settings(self.settings_path) self.language_var = tk.StringVar(value=self.settings.language) self.close_to_tray_var = tk.BooleanVar(value=self.settings.close_to_tray) + self.start_with_windows_var = tk.BooleanVar(value=starts_with_windows()) + self.auto_start_runtime_var = tk.BooleanVar( + value=self.settings.auto_start_runtime + ) + self._start_minimized = bool(start_minimized) + self._auto_start_after_id = None + self._auto_start_retry_index = 0 self.runtime_config_path = default_config_path() self._configuration_load_error: str | None = None try: @@ -1333,6 +1367,7 @@ def __init__(self, root: tk.Tk) -> None: self._append_runtime_log( f"Configuration par defaut utilisee: {self._configuration_load_error}" ) + self.root.after_idle(self._apply_initial_startup_preferences) def _t(self, key: str, **values: object) -> str: return translated_text(self.language_var.get(), key, **values) @@ -1463,6 +1498,17 @@ def _build_menu(self) -> None: variable=self.close_to_tray_var, command=self._save_close_to_tray, ) + options_menu.add_checkbutton( + label=self._t("start_with_windows"), + variable=self.start_with_windows_var, + command=self._save_start_with_windows, + ) + options_menu.add_checkbutton( + label=self._t("auto_start_runtime"), + variable=self.auto_start_runtime_var, + command=self._save_auto_start_runtime, + ) + options_menu.add_separator() language_menu = tk.Menu(options_menu, tearoff=0) language_menu.add_radiobutton( label=self._t("french"), @@ -1558,6 +1604,23 @@ def _build_live_tab(self, parent: ttk.Frame) -> None: self.live_driver_note.grid( row=1, column=0, columnspan=3, sticky="ew", pady=(7, 0) ) + startup_options = ttk.Frame(controller) + startup_options.grid( + row=2, column=0, columnspan=3, sticky="ew", pady=(8, 0) + ) + ttk.Checkbutton( + startup_options, + text=self._t("start_with_windows"), + variable=self.start_with_windows_var, + command=self._save_start_with_windows, + ).pack(side="left") + self.auto_start_runtime_check = ttk.Checkbutton( + startup_options, + text=self._t("auto_start_runtime"), + variable=self.auto_start_runtime_var, + command=self._save_auto_start_runtime, + ) + self.auto_start_runtime_check.pack(side="left", padx=(18, 0)) actions = ttk.Frame(parent) actions.grid(row=3, column=0, sticky="ew", pady=(9, 5)) @@ -3025,7 +3088,7 @@ def _apply_live_profile_state(self) -> None: self.live_settings_window ): self.live_settings_window.geometry( - f"920x{560 if supported else 500}" + f"920x{640 if supported else 500}" ) if not supported and not (self.runtime and self.runtime.running): self.runtime_status.set( @@ -3991,19 +4054,23 @@ def _set_runtime_widget_states(self) -> None: self.previous_bank_button.configure(state=state) self.next_bank_button.configure(state=state) - def start_runtime(self) -> None: + def start_runtime(self, *, interactive: bool = True) -> bool: if self.runtime and self.runtime.running: - return + return True if not live_runtime_supported(self.selected_profile_id): - messagebox.showinfo( - self._t("live_title"), - self._t( - "runtime_driver_unavailable", - controller=self._active_controller_label(), - ), - parent=self.root, + details = self._t( + "runtime_driver_unavailable", + controller=self._active_controller_label(), ) - return + self.runtime_status.set(details) + self._append_runtime_log(details) + if interactive: + messagebox.showinfo( + self._t("live_title"), + details, + parent=self.root, + ) + return False self.runtime_status.set(self._t("runtime_starting")) try: config = self._runtime_config_from_form() @@ -4019,6 +4086,7 @@ def start_runtime(self) -> None: runtime.start() self._apply_runtime_snapshot(runtime.snapshot()) self.status.set(self._t("runtime_running")) + return True except Exception as exc: runtime = self.runtime self.runtime = None @@ -4030,7 +4098,9 @@ def start_runtime(self) -> None: self.runtime_status.set(str(exc)) self._append_runtime_log(f"Demarrage impossible: {exc}") self._set_runtime_widget_states() - messagebox.showerror(self._t("runtime_error"), str(exc), parent=self.root) + if interactive: + messagebox.showerror(self._t("runtime_error"), str(exc), parent=self.root) + return False def stop_runtime(self) -> None: runtime = self.runtime @@ -4308,7 +4378,7 @@ def show_live_settings(self) -> None: self.live_settings_window = window window.title(self._t("live_settings_title")) supported = live_runtime_supported(self.selected_profile_id) - window.geometry(f"920x{560 if supported else 500}") + window.geometry(f"920x{640 if supported else 500}") window.minsize(800, 470) window.transient(self.root) if PRODUCT_ICON_PATH.is_file(): @@ -4416,27 +4486,36 @@ def show_live_settings(self) -> None: frame, text=self._t("ec4_tools"), padding=10 ) self.live_settings_ec4_frame.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + self.live_settings_ec4_frame.columnconfigure(0, weight=1) + self.live_settings_ec4_frame.columnconfigure(1, weight=1) ttk.Checkbutton( self.live_settings_ec4_frame, text=self._t("display_enabled"), variable=self.display_enabled_var, - ).pack(side="left") - ttk.Button( - self.live_settings_ec4_frame, - text=self._t("import_legacy_config"), - command=self.import_legacy_runtime_config, - ).pack(side="left", padx=(8, 0)) - for label, command in ( + ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 7)) + ec4_actions = ( + (self._t("import_legacy_config"), self.import_legacy_runtime_config), (self._t("reconnect_ec4"), self.reconnect_ec4), (self._t("request_setup"), self.request_setup_state), (self._t("refresh_companion"), self.refresh_companion), (self._t("test_display"), self.test_ec4_display), - ): + ) + for index, (label, command) in enumerate(ec4_actions): button = ttk.Button( self.live_settings_ec4_frame, text=label, command=command ) - button.pack(side="left", padx=(8, 0)) - self.runtime_settings_action_buttons.append(button) + row = 1 + index // 2 + column = index % 2 + button.grid( + row=row, + column=column, + columnspan=2 if index == len(ec4_actions) - 1 else 1, + sticky="ew", + padx=(0 if column == 0 else 5, 5 if column == 0 else 0), + pady=3, + ) + if index > 0: + self.runtime_settings_action_buttons.append(button) footer = ttk.Frame(frame) footer.grid(row=4, column=0, sticky="ew", pady=(14, 0)) @@ -4572,6 +4651,72 @@ def _save_close_to_tray(self) -> None: ) self._save_settings() + def _save_start_with_windows(self) -> None: + enabled = bool(self.start_with_windows_var.get()) + try: + set_start_with_windows(enabled) + except OSError as exc: + self.start_with_windows_var.set(not enabled) + details = self._t("startup_registration_error", error=exc) + self.status.set(details) + messagebox.showerror(self._t("menu_options"), details, parent=self.root) + return + self.status.set( + self._t("startup_windows_enabled" if enabled else "startup_windows_disabled") + ) + + def _save_auto_start_runtime(self) -> None: + enabled = bool(self.auto_start_runtime_var.get()) + self.settings = replace(self.settings, auto_start_runtime=enabled) + self._save_settings() + if not enabled and self._auto_start_after_id is not None: + try: + self.root.after_cancel(self._auto_start_after_id) + except Exception: + pass + self._auto_start_after_id = None + + def _apply_initial_startup_preferences(self) -> None: + if self._start_minimized: + self.minimize_to_tray() + if not self.settings.auto_start_runtime: + return + self._auto_start_retry_index = 0 + self._schedule_automatic_runtime_start(AUTO_START_RETRY_DELAYS_MS[0]) + + def _schedule_automatic_runtime_start(self, delay_ms: int) -> None: + if self._closing or not self.settings.auto_start_runtime: + return + self._auto_start_after_id = self.root.after( + max(0, int(delay_ms)), self._attempt_automatic_runtime_start + ) + + def _attempt_automatic_runtime_start(self) -> None: + self._auto_start_after_id = None + if self._closing or not self.settings.auto_start_runtime: + return + controller = self._active_controller_label() + self.status.set(self._t("runtime_auto_start_attempt", controller=controller)) + self._append_runtime_log( + self._t("runtime_auto_start_attempt", controller=controller) + ) + if not live_runtime_supported(self.selected_profile_id): + self.start_runtime(interactive=False) + return + if self.start_runtime(interactive=False): + return + self._auto_start_retry_index += 1 + if self._auto_start_retry_index >= len(AUTO_START_RETRY_DELAYS_MS): + return + delay = AUTO_START_RETRY_DELAYS_MS[self._auto_start_retry_index] + details = self._t( + "runtime_auto_start_retry", + seconds=max(1, round(delay / 1000)), + ) + self.status.set(details) + self._append_runtime_log(details) + self._schedule_automatic_runtime_start(delay) + def change_language(self) -> None: selected = self.selected_profile_id self.settings = replace(self.settings, language=self.language_var.get()) @@ -4635,6 +4780,12 @@ def quit(self) -> None: if self._closing: return self._closing = True + if self._auto_start_after_id is not None: + try: + self.root.after_cancel(self._auto_start_after_id) + except Exception: + pass + self._auto_start_after_id = None if self._runtime_poll_after_id is not None: try: self.root.after_cancel(self._runtime_poll_after_id) @@ -4847,9 +4998,14 @@ def show_about(self) -> None: ) -def main() -> int: +def start_minimized_requested(arguments: list[str] | None = None) -> bool: + values = sys.argv[1:] if arguments is None else arguments + return "--minimized" in values + + +def main(arguments: list[str] | None = None) -> int: root = tk.Tk() - ControlHubDesktop(root) + ControlHubDesktop(root, start_minimized=start_minimized_requested(arguments)) root.mainloop() return 0 diff --git a/src/silemio_control_hub/desktop_settings.py b/src/silemio_control_hub/desktop_settings.py index 3d9dda5..13f00a2 100644 --- a/src/silemio_control_hub/desktop_settings.py +++ b/src/silemio_control_hub/desktop_settings.py @@ -23,6 +23,7 @@ class DesktopSettings: language: str = "fr" close_to_tray: bool = True active_controller_id: str | None = None + auto_start_runtime: bool = False def default_desktop_settings_path() -> Path: @@ -58,16 +59,20 @@ def load_desktop_settings(path: Path | None = None) -> DesktopSettings: language = raw.get("language", "fr") close_to_tray = raw.get("close_to_tray", True) active_controller_id = raw.get("active_controller_id") + auto_start_runtime = raw.get("auto_start_runtime", False) if language not in SUPPORTED_LANGUAGES: language = "fr" if not isinstance(close_to_tray, bool): close_to_tray = True if not isinstance(active_controller_id, str) or not active_controller_id.strip(): active_controller_id = None + if not isinstance(auto_start_runtime, bool): + auto_start_runtime = False return DesktopSettings( language=language, close_to_tray=close_to_tray, active_controller_id=active_controller_id, + auto_start_runtime=auto_start_runtime, ) diff --git a/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Manual-EN.pdf b/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Manual-EN.pdf index 89ba2a91065be471c495f3cd761fb6f21553031d..2d6cd4c3e7ef3b1b669446de8c5da874837dc86b 100644 GIT binary patch delta 4085 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 delta 3564 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 Z-2Zd=xyO&+q;Kg*jUYZiu&4At{tBX33?u*m diff --git a/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Notice-FR.pdf b/src/silemio_control_hub/manuals/Controller-Studio-for-LiveProfessor-Notice-FR.pdf index 3cdd279beff1bad8a511a745e9e136fc84d98b74..69c69e935f3347be2872dacec013e009de747f43 100644 GIT binary patch delta 4421 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 Z8J_$9JbwAz_-Fc~fuTPjs6QS2_%EbhX&L|k diff --git a/src/silemio_control_hub/runtime/ec4_liveprofessor.py b/src/silemio_control_hub/runtime/ec4_liveprofessor.py index f91ea53..ee6c606 100644 --- a/src/silemio_control_hub/runtime/ec4_liveprofessor.py +++ b/src/silemio_control_hub/runtime/ec4_liveprofessor.py @@ -134,6 +134,8 @@ def __init__( self._viewset_count: int | None = None self._viewset_indices: set[int] = set() self._startup_banner_shown: bool = False + self._liveprofessor_feedback_received: bool = False + self._waiting_for_liveprofessor_displayed: bool = False @property def bank_count(self) -> int: @@ -175,6 +177,8 @@ def start(self) -> None: return self._stop.clear() self._running = True + self._liveprofessor_feedback_received = False + self._waiting_for_liveprofessor_displayed = False try: self._osc_server.start() self._log( @@ -253,6 +257,7 @@ def _reconnect_loop(self) -> None: # A reconnect is a new user-visible connection event. Keep the # one-shot guard inside one connection, but show the banner again. self._startup_banner_shown = False + self._waiting_for_liveprofessor_displayed = False self.show_startup_banner() if self.config.setup_request_on_connect: self._midi.send_sysex(SETUP_REQUEST) @@ -1037,6 +1042,13 @@ def _refresh_main_display(self) -> None: return if self._shift_held: return + if ( + self.config.mode == "companion" + and self._waiting_for_liveprofessor_displayed + and not self._liveprofessor_feedback_received + ): + self._show_waiting_for_liveprofessor() + return start = self.active_bank * self.config.bank_size labels = self.short_names[start : start + self.config.bank_size] labels = [ @@ -1167,10 +1179,38 @@ def show_startup_banner(self) -> None: except MidiBackendError as exc: self._log(str(exc), logging.WARNING) return - self._overlay_timer = threading.Timer(2.0, lambda: self._hide_overlay(force=True)) + self._overlay_timer = threading.Timer(2.0, self._finish_startup_banner) self._overlay_timer.daemon = True self._overlay_timer.start() + def _finish_startup_banner(self) -> None: + self._overlay_timer = None + if not self._midi.is_open: + return + if self.config.mode != "companion" or self._liveprofessor_feedback_received: + self._hide_overlay(force=True) + return + self._show_waiting_for_liveprofessor() + + def _show_waiting_for_liveprofessor(self) -> None: + if not self.config.display_enabled or not self._midi.is_open: + return + if self.config.ui_language == "en": + lines = ["Waiting for", "LiveProfessor", "By Mamat", "-----[]---"] + else: + lines = ["Attente demarrage", "LiveProfessor", "By Mamat", "-----[]---"] + try: + self._midi.send_sysex( + total_display_message( + lines, + alignments=["center", "center", "right", "right"], + ) + ) + except MidiBackendError as exc: + self._log(str(exc), logging.WARNING) + return + self._waiting_for_liveprofessor_displayed = True + def _show_overlay( self, lines: list[str], @@ -1201,6 +1241,13 @@ def _hide_overlay(self, force: bool = False) -> None: if self._shift_held: self._send_shift_shortcuts() return + if ( + self.config.mode == "companion" + and self._waiting_for_liveprofessor_displayed + and not self._liveprofessor_feedback_received + ): + self._show_waiting_for_liveprofessor() + return try: if self.config.persistent_parameter_display: start = self.active_bank * self.config.bank_size @@ -1238,6 +1285,11 @@ def _button_number(value: Any) -> int | None: def _on_osc(self, address: str, args: list[Any]) -> None: try: LOGGER.debug("OSC <- %s %r", address, args) + first_liveprofessor_feedback = not self._liveprofessor_feedback_received + self._liveprofessor_feedback_received = True + if first_liveprofessor_feedback and self._waiting_for_liveprofessor_displayed: + self._waiting_for_liveprofessor_displayed = False + self._hide_overlay(force=True) if self.config.mode == "companion": self._on_companion_feedback(address, args) else: diff --git a/src/silemio_control_hub/windows_startup.py b/src/silemio_control_hub/windows_startup.py new file mode 100644 index 0000000..bc6c461 --- /dev/null +++ b/src/silemio_control_hub/windows_startup.py @@ -0,0 +1,102 @@ +"""Per-user Windows startup registration for the desktop application.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + +from .identity import FULL_PRODUCT_NAME + +try: # pragma: no cover - imported only on Windows in production + import winreg as _winreg +except ImportError: # pragma: no cover - exercised by Linux CI through the guard + _winreg = None + + +RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run" +RUN_VALUE_NAME = FULL_PRODUCT_NAME + + +def startup_command( + executable: str | Path | None = None, + *, + frozen: bool | None = None, +) -> str: + """Return the quoted command registered for a quiet notification-area launch.""" + + target = Path(executable or sys.executable).expanduser().resolve() + packaged = bool(getattr(sys, "frozen", False)) if frozen is None else frozen + parts = [str(target)] + if not packaged: + parts.extend(("-m", "silemio_control_hub.desktop")) + parts.append("--minimized") + return subprocess.list2cmdline(parts) + + +def _registry_module(): + if _winreg is None: + raise OSError("Windows startup registration is only available on Windows") + return _winreg + + +def read_startup_command() -> str | None: + """Read the current per-user startup command without changing the registry.""" + + registry = _registry_module() + try: + with registry.OpenKey( + registry.HKEY_CURRENT_USER, + RUN_KEY_PATH, + 0, + registry.KEY_QUERY_VALUE, + ) as key: + value, value_type = registry.QueryValueEx(key, RUN_VALUE_NAME) + except FileNotFoundError: + return None + if value_type != registry.REG_SZ or not isinstance(value, str) or not value.strip(): + return None + return value + + +def starts_with_windows(command: str | None = None) -> bool: + """Return whether the current application command is registered at sign-in.""" + + expected = command or startup_command() + try: + registered = read_startup_command() + except OSError: + return False + return registered == expected + + +def set_start_with_windows(enabled: bool, command: str | None = None) -> None: + """Create or remove the per-user startup entry.""" + + registry = _registry_module() + if enabled: + with registry.CreateKeyEx( + registry.HKEY_CURRENT_USER, + RUN_KEY_PATH, + 0, + registry.KEY_SET_VALUE, + ) as key: + registry.SetValueEx( + key, + RUN_VALUE_NAME, + 0, + registry.REG_SZ, + command or startup_command(), + ) + return + + try: + with registry.OpenKey( + registry.HKEY_CURRENT_USER, + RUN_KEY_PATH, + 0, + registry.KEY_SET_VALUE, + ) as key: + registry.DeleteValue(key, RUN_VALUE_NAME) + except FileNotFoundError: + return diff --git a/tests/test_bridge.py b/tests/test_bridge.py index dddebff..784065e 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -519,6 +519,71 @@ def test_startup_banner_shown_on_connect(self): bridge.show_startup_banner() self.assertEqual(len(bridge._midi.sysex), banner_count) + def test_startup_banner_uses_english_when_the_application_is_english(self): + bridge = self.make_bridge(display_enabled=True, restrict_to_target=False, ui_language="en") + bridge._midi.sysex.clear() + + bridge.show_startup_banner() + + self.assertEqual( + bridge._midi.sysex[-1], + total_display_message( + ["Connection OK", "SiLeMI/O CtrlStudio", "By Mamat", "-----[]---"], + alignments=["center", "center", "right", "right"], + ), + ) + + def test_ec4_waits_in_french_until_liveprofessor_answers(self): + bridge = self.make_bridge(display_enabled=True, restrict_to_target=False, ui_language="fr") + bridge._midi.sysex.clear() + + bridge.show_startup_banner() + bridge._overlay_timer.cancel() + bridge._finish_startup_banner() + + self.assertTrue(bridge._waiting_for_liveprofessor_displayed) + self.assertEqual( + bridge._midi.sysex[-1], + total_display_message( + ["Attente demarrage", "LiveProfessor", "By Mamat", "-----[]---"], + alignments=["center", "center", "right", "right"], + ), + ) + + bridge._refresh_main_display() + + self.assertEqual( + bridge._midi.sysex[-1], + total_display_message( + ["Attente demarrage", "LiveProfessor", "By Mamat", "-----[]---"], + alignments=["center", "center", "right", "right"], + ), + ) + + def test_ec4_waits_in_english_until_liveprofessor_answers(self): + bridge = self.make_bridge(display_enabled=True, restrict_to_target=False, ui_language="en") + bridge._midi.sysex.clear() + + bridge.show_startup_banner() + bridge._overlay_timer.cancel() + bridge._finish_startup_banner() + + self.assertEqual( + bridge._midi.sysex[-1], + total_display_message( + ["Waiting for", "LiveProfessor", "By Mamat", "-----[]---"], + alignments=["center", "center", "right", "right"], + ), + ) + + bridge._on_osc("/Companion/Rotary1", [0.5]) + + self.assertFalse(bridge._waiting_for_liveprofessor_displayed) + self.assertEqual( + bridge._midi.sysex[-1], + parameter_grid_message(bridge.short_names[:16]), + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_desktop.py b/tests/test_desktop.py index 588f131..64f44a3 100644 --- a/tests/test_desktop.py +++ b/tests/test_desktop.py @@ -7,6 +7,7 @@ _safe_filename, controller_table_rows, live_runtime_supported, + start_minimized_requested, translated_text, ) from silemio_control_hub.registry import ControllerRegistry @@ -41,6 +42,11 @@ def test_live_runtime_is_enabled_only_for_profiles_with_a_native_driver(): assert not live_runtime_supported(None) +def test_windows_startup_argument_requests_a_notification_area_launch(): + assert start_minimized_requested(["--minimized"]) + assert not start_minimized_requested([]) + + def test_destroyed_widgets_are_not_reused_after_language_rebuild(): class ExistingWidget: def winfo_exists(self): diff --git a/tests/test_desktop_settings.py b/tests/test_desktop_settings.py index b51e979..48e2cf1 100644 --- a/tests/test_desktop_settings.py +++ b/tests/test_desktop_settings.py @@ -18,6 +18,7 @@ def test_desktop_settings_round_trip_is_atomic(tmp_path): language="en", close_to_tray=False, active_controller_id="behringer.x-touch-compact.layer-a", + auto_start_runtime=True, ), path, ) @@ -27,6 +28,7 @@ def test_desktop_settings_round_trip_is_atomic(tmp_path): language="en", close_to_tray=False, active_controller_id="behringer.x-touch-compact.layer-a", + auto_start_runtime=True, ) assert not list(tmp_path.glob("*.tmp")) @@ -95,9 +97,11 @@ def test_empty_active_controller_is_ignored(tmp_path): "language": "fr", "close_to_tray": True, "active_controller_id": "", + "auto_start_runtime": "yes", } ), encoding="utf-8", ) assert load_desktop_settings(path).active_controller_id is None + assert load_desktop_settings(path).auto_start_runtime is False diff --git a/tests/test_windows_startup.py b/tests/test_windows_startup.py new file mode 100644 index 0000000..fd775a4 --- /dev/null +++ b/tests/test_windows_startup.py @@ -0,0 +1,90 @@ +from pathlib import Path + +import silemio_control_hub.windows_startup as windows_startup + + +class _Key: + def __init__(self, registry): + self.registry = registry + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _Registry: + HKEY_CURRENT_USER = object() + KEY_QUERY_VALUE = 1 + KEY_SET_VALUE = 2 + REG_SZ = 3 + + def __init__(self): + self.values = {} + + def OpenKey(self, _root, path, _reserved, _access): + if path not in self.values: + raise FileNotFoundError(path) + return _Key(self) + + def CreateKeyEx(self, _root, path, _reserved, _access): + self.values.setdefault(path, {}) + return _Key(self) + + def QueryValueEx(self, _key, name): + try: + return self.values[windows_startup.RUN_KEY_PATH][name] + except KeyError as exc: + raise FileNotFoundError(name) from exc + + def SetValueEx(self, _key, name, _reserved, value_type, value): + self.values[windows_startup.RUN_KEY_PATH][name] = (value, value_type) + + def DeleteValue(self, _key, name): + try: + del self.values[windows_startup.RUN_KEY_PATH][name] + except KeyError as exc: + raise FileNotFoundError(name) from exc + + +def test_packaged_startup_command_is_quoted_and_minimized(tmp_path): + executable = tmp_path / "Controller Studio" / "Controller Studio.exe" + + command = windows_startup.startup_command(executable, frozen=True) + + assert command == f'"{executable.resolve()}" --minimized' + + +def test_development_startup_command_invokes_desktop_module(tmp_path): + python = tmp_path / "Python Runtime" / "python.exe" + + command = windows_startup.startup_command(python, frozen=False) + + assert command == ( + f'"{python.resolve()}" -m silemio_control_hub.desktop --minimized' + ) + + +def test_startup_registration_round_trip(monkeypatch): + registry = _Registry() + monkeypatch.setattr(windows_startup, "_winreg", registry) + command = r'"C:\Apps\Controller Studio.exe" --minimized' + + assert windows_startup.starts_with_windows(command) is False + + windows_startup.set_start_with_windows(True, command) + + assert windows_startup.read_startup_command() == command + assert windows_startup.starts_with_windows(command) is True + + windows_startup.set_start_with_windows(False, command) + + assert windows_startup.read_startup_command() is None + + +def test_disabling_absent_startup_registration_is_idempotent(monkeypatch): + registry = _Registry() + monkeypatch.setattr(windows_startup, "_winreg", registry) + + windows_startup.set_start_with_windows(False)