2828from ok .util .config import Config , ConfigOption
2929from ok .util .handler import Handler , ExitEvent
3030from ok .util .logger import config_logger , Logger
31- from ok .util .process import check_mutex , get_first_gpu_free_memory_mib
31+ from ok .util .process import check_mutex , get_first_gpu_free_memory_mib , parse_arguments_to_map
3232from ok .util .file import get_path_relative_to_exe , install_path_isascii
3333from ok .util .window import windows_graphics_available
3434from ok .device .intercation import DoNothingInteraction , BaseInteraction , BrowserInteraction , PostMessageInteraction , \
@@ -238,6 +238,57 @@ def handle_sigint(signum, frame):
238238
239239 sys .exit (self .app .exec ())
240240
241+
242+ class HeadlessApp :
243+ """Small app facade for running tasks without creating any UI windows."""
244+
245+ def __init__ (self , config , exit_event = None ):
246+ og .exit_event = exit_event
247+ og .handler = Handler (exit_event , 'global' )
248+ self .config = config
249+ self .debug = config .get ('debug' , False )
250+ self .headless = True
251+ self .global_config = og .global_config
252+ self .icon = None
253+ self .exit_event = exit_event
254+ self .po_translation = None
255+ self .to_translate = None
256+
257+ from ok .gui .common .config import cfg
258+ self .locale = cfg .get (cfg .language ).value
259+ from ok .gui .StartController import StartController
260+ self .start_controller = StartController (self .config , exit_event )
261+
262+ og .app = self
263+ if my_app := self .config .get ('my_app' ):
264+ og .my_app = init_class_by_name (my_app [0 ], my_app [1 ], exit_event )
265+ logger .debug ('init headless app end' )
266+
267+ def tr (self , key ):
268+ if not key :
269+ return key
270+ if ok_tr := QCoreApplication .translate ("app" , key ):
271+ if ok_tr != key :
272+ return ok_tr
273+ if self .po_translation is None :
274+ locale_name = self .locale .name ()
275+ try :
276+ from ok .gui .i18n .GettextTranslator import get_translations
277+ self .po_translation = get_translations (locale_name )
278+ self .po_translation .install ()
279+ logger .info (f'headless translation installed for { locale_name } ' )
280+ except :
281+ logger .error (f'install headless translations error for { locale_name } ' )
282+ self .po_translation = "Failed"
283+ if self .po_translation != 'Failed' :
284+ return self .po_translation .gettext (key )
285+ return key
286+
287+ def quit (self ):
288+ if self .exit_event :
289+ self .exit_event .set ()
290+
291+
241292def get_my_id ():
242293 mac = uuid .getnode ()
243294 value_with_salt = 'mac123:' + str (mac )
@@ -294,8 +345,10 @@ def __init__(self, config):
294345 logger .info (
295346 f"pyappify app_version:{ pyappify .app_version } , app_profile:{ pyappify .app_profile } , pyappify_version:{ pyappify .pyappify_version } pyappify_upgradeable:{ pyappify .pyappify_upgradeable } , pyappify_executable:{ pyappify .pyappify_executable } " )
296347 config ['debug' ] = config .get ("debug" , False )
348+ self .args = parse_arguments_to_map ()
297349 self .task_executor = None
298350 self ._app = None
351+ self ._headless_app = None
299352 self .debug = config ['debug' ]
300353 self .global_config = GlobalConfig (config .get ('global_configs' ))
301354 windows_config = config .get ('windows' )
@@ -337,9 +390,21 @@ def app(self):
337390 self ._app = App (self .config , self .task_executor , self .exit_event )
338391 return self ._app
339392
393+ @property
394+ def headless_app (self ):
395+ if self ._headless_app is None :
396+ self ._headless_app = HeadlessApp (self .config , self .exit_event )
397+ return self ._headless_app
398+
399+ def should_init_task_manager_headless (self ):
400+ return not self .config .get ("use_gui" ) or self .args .get ('task' , 0 ) > 0
401+
340402 def start (self ):
341403 logger .info (f'OK start id:{ id (self )} pid:{ os .getpid ()} ' )
342404 try :
405+ if self .args .get ('task' , 0 ) > 0 :
406+ self .run_task (self .args .get ('task' ))
407+ return
343408 if self .config .get ("use_gui" ):
344409 if not self .init_error :
345410 self .app .show_main_window ()
@@ -374,9 +439,76 @@ def start(self):
374439 except Exception as e :
375440 logger .error ("start error" , e )
376441 self .exit_event .set ()
377- if self .app :
442+ if self ._app or self . _headless_app :
378443 self .quit ()
379444
445+ def run_task (self , task = 1 ):
446+ """
447+ Run a one-time task without showing the main UI.
448+
449+ Args:
450+ task: 1-based one-time task index, task name, task class, or task instance.
451+ """
452+ task = self .get_onetime_task (task )
453+ logger .info (f'run task without ui: { task .name } ' )
454+ started = self .headless_app .start_controller .do_start (task , exit_after = True )
455+ if not started :
456+ raise RuntimeError (f'Start task failed: { task .name } ' )
457+ self .wait_task (task )
458+ return True
459+
460+ def get_onetime_task (self , task ):
461+ if isinstance (task , int ):
462+ task_index = task - 1
463+ if task_index < 0 or task_index >= len (self .task_executor .onetime_tasks ):
464+ raise IndexError (
465+ f'Task index { task } is out of range. Available range is 1-{ len (self .task_executor .onetime_tasks )} ' )
466+ return self .task_executor .onetime_tasks [task_index ]
467+ if isinstance (task , str ):
468+ normalized_task = task .lower ()
469+ exact_matches = [
470+ candidate for candidate in self .task_executor .onetime_tasks
471+ if candidate .name .lower () == normalized_task or candidate .__class__ .__name__ .lower () == normalized_task
472+ ]
473+ if len (exact_matches ) == 1 :
474+ return exact_matches [0 ]
475+ if len (exact_matches ) > 1 :
476+ names = ', ' .join (candidate .__class__ .__name__ for candidate in exact_matches )
477+ raise ValueError (f'Multiple tasks matched "{ task } ": { names } ' )
478+
479+ partial_matches = [
480+ candidate for candidate in self .task_executor .onetime_tasks
481+ if normalized_task in candidate .name .lower ()
482+ or normalized_task in candidate .__class__ .__name__ .lower ()
483+ ]
484+ if len (partial_matches ) == 1 :
485+ return partial_matches [0 ]
486+ if len (partial_matches ) > 1 :
487+ names = ', ' .join (candidate .__class__ .__name__ for candidate in partial_matches )
488+ raise ValueError (f'Multiple tasks matched "{ task } ": { names } ' )
489+
490+ for candidate in self .task_executor .onetime_tasks :
491+ if candidate .name == task or candidate .__class__ .__name__ == task :
492+ return candidate
493+ raise ValueError (f'Task not found: { task } ' )
494+ if isinstance (task , type ):
495+ for candidate in self .task_executor .onetime_tasks :
496+ if isinstance (candidate , task ):
497+ return candidate
498+ if not issubclass (task , BaseTask ):
499+ raise ValueError (f'Task class must inherit BaseTask: { task } ' )
500+ if issubclass (task , TriggerTask ):
501+ raise ValueError (f'run_task only supports one-time BaseTask classes, got TriggerTask: { task } ' )
502+ task_instance = task (executor = self .task_executor , app = self .headless_app )
503+ task_instance .after_init (executor = self .task_executor , scene = self .task_executor .scene )
504+ task_instance .post_init ()
505+ self .task_executor .onetime_tasks .append (task_instance )
506+ logger .info (f'created headless task from class: { task_instance } ' )
507+ return task_instance
508+ if task in self .task_executor .onetime_tasks :
509+ return task
510+ raise ValueError (f'Unsupported one-time task selector: { task } ' )
511+
380512 def do_init (self ):
381513 logger .info (f"do_init, config: { self .config } " )
382514 self .init_device_manager ()
@@ -401,9 +533,12 @@ def do_init(self):
401533 ocr_target_height = ocr .get ('target_height' , 0 )
402534 if not isascii :
403535 logger .info (f'show_path_ascii_error' )
404- self .app .show_path_ascii_error (path )
405536 self .init_error = True
406- self .app .exec ()
537+ if self .should_init_task_manager_headless ():
538+ raise RuntimeError (f'Install dir { path } must be an English path, move to another path.' )
539+ else :
540+ self .app .show_path_ascii_error (path )
541+ self .app .exec ()
407542 return False
408543
409544 self .task_executor = TaskExecutor (self .device_manager , exit_event = self .exit_event ,
@@ -413,17 +548,29 @@ def do_init(self):
413548 global_config = self .global_config , ocr_target_height = ocr_target_height ,
414549 config = self .config )
415550 from ok .gui .tasks .TaskManger import TaskManager
416- og .task_manager = TaskManager (task_executor = self .task_executor , app = self .app ,
551+ task_app = self .headless_app if self .should_init_task_manager_headless () else self .app
552+ og .task_manager = TaskManager (task_executor = self .task_executor , app = task_app ,
417553 onetime_tasks = self .config .get ('onetime_tasks' , []),
418554 trigger_tasks = self .config .get ('trigger_tasks' , []),
419555 scene = self .config .get ('scene' ))
420556 og .executor = self .task_executor
421557 logger .info (f"do_init, end" )
422558 return True
423559
424- def wait_task (self ):
425- while not self .exit_event .is_set ():
426- time .sleep (1 )
560+ def wait_task (self , task = None ):
561+ try :
562+ while not self .exit_event .is_set ():
563+ if task is not None and not task .enabled and self .task_executor .current_task is not task :
564+ logger .info (f'task finished without ui: { task .name } ' )
565+ break
566+ time .sleep (1 )
567+ except KeyboardInterrupt :
568+ logger .info ("Keyboard interrupt received, exiting script." )
569+ finally :
570+ if task is not None :
571+ self .exit_event .set ()
572+ if self .task_executor .thread and self .task_executor .thread != threading .current_thread ():
573+ self .task_executor .thread .join (timeout = 10 )
427574
428575 def console_handler (self , event ):
429576 import win32con
@@ -450,6 +597,8 @@ def quit(self):
450597 self .exit_event .set ()
451598 if self ._app :
452599 self ._app .quit ()
600+ if self ._headless_app :
601+ self ._headless_app .quit ()
453602
454603 def init_device_manager (self ):
455604 if self .device_manager is None :
@@ -458,6 +607,23 @@ def init_device_manager(self):
458607 og .device_manager = self .device_manager
459608
460609
610+ def run_task (config , task = 1 , debug = False ):
611+ """
612+ Convenience entrypoint for scripts that only need to run one task.
613+
614+ Example:
615+ from ok import run_task
616+ from src.config import config
617+
618+ if __name__ == "__main__":
619+ run_task(config, task=1)
620+ """
621+ headless_config = dict (config )
622+ headless_config ["use_gui" ] = False
623+ headless_config ["debug" ] = debug
624+ return OK (headless_config ).run_task (task )
625+
626+
461627class BaseScene :
462628 def reset (self ):
463629 pass
0 commit comments