1212import shutil
1313import sys
1414import tempfile
15+ import time
1516import traceback
1617
1718if sys .platform == 'win32' :
3940from psycopg .errors import UndefinedTable
4041from typing import TYPE_CHECKING , Any , Iterable
4142from typing_extensions import override
42- from watchdog .events import FileSystemEventHandler , FileSystemEvent , FileSystemMovedEvent
43+ from watchdog .events import (FileSystemEventHandler , DirCreatedEvent , FileCreatedEvent , DirDeletedEvent ,
44+ FileDeletedEvent , DirMovedEvent , FileMovedEvent )
4345from watchdog .observers import Observer , ObserverType
4446
4547# ruamel YAML support
5961
6062
6163class MissionFileSystemEventHandler (FileSystemEventHandler ):
62- def __init__ (self , server : Server , loop : asyncio .AbstractEventLoop ):
64+ DEBOUNCE_WINDOW = 2.0 # seconds
65+ TEMP_SUFFIXES = ('.tmp' , '.part' , '.swp' , '~' )
66+ IGNORE_PREFIX = '.' # hidden files
67+
68+ def __init__ (self , server , loop ):
69+ super ().__init__ ()
6370 self .server = server
6471 self .log = server .log
6572 self .loop = loop
6673 self .deleted : dict [str , int ] = {}
74+ self ._seen : dict [str , float ] = {}
6775
68- @override
69- def on_created (self , event : FileSystemEvent ):
70- path : str = os .path .normpath (event .src_path )
71- # ignore non-mission files and such that are in the .dcssb folder
72- if not (path .endswith ('.miz' ) or path .endswith ('.sav' )) or '.dcssb' in path :
76+ def _is_debounced (self , path ):
77+ now = time .monotonic ()
78+ last = self ._seen .get (path , 0 )
79+ if now - last < self .DEBOUNCE_WINDOW :
80+ return True
81+ self ._seen [path ] = now
82+ return False
83+
84+ def _is_valid_mission_file (self , path ):
85+ name = os .path .basename (path )
86+ if name .startswith (self .IGNORE_PREFIX ):
87+ return False
88+ if any (name .endswith (suf ) for suf in self .TEMP_SUFFIXES ):
89+ return False
90+ return path .endswith ('.miz' ) or path .endswith ('.sav' )
91+
92+ def _wait_for_stable_file (self , path , timeout = 5.0 ):
93+ start = time .monotonic ()
94+ while time .monotonic () - start < timeout :
95+ try :
96+ size1 = os .path .getsize (path )
97+ except FileNotFoundError :
98+ return False
99+ time .sleep (0.5 )
100+ try :
101+ size2 = os .path .getsize (path )
102+ except FileNotFoundError :
103+ return False
104+ if size1 == size2 :
105+ return True
106+ return False
107+
108+ # ----------------------------------------------------------------------
109+ # Core handlers
110+ # ----------------------------------------------------------------------
111+ def on_created (self , event : DirCreatedEvent | FileCreatedEvent ) -> None :
112+ path = os .path .normpath (event .src_path )
113+ if not self ._is_valid_mission_file (path ):
114+ return
115+ if self ._is_debounced (path ):
73116 return
117+
118+ if not self ._wait_for_stable_file (path ):
119+ self .log .warning (f"File { path } never stabilized – skipping." )
120+ return
121+
74122 if path in self .deleted :
75- asyncio . run_coroutine_threadsafe ( self . server . addMission ( path , idx = self .deleted [ path ]), self . loop )
76- del self .deleted [ path ]
123+ idx = self .deleted . pop ( path )
124+ asyncio . run_coroutine_threadsafe ( self .server . addMission ( path , idx = idx ), self . loop )
77125 else :
78126 asyncio .run_coroutine_threadsafe (self .server .addMission (path ), self .loop )
79- self .log .info (f"=> New mission { os .path .basename (path )[:- 4 ]} added to server { self .server .name } ." )
80127
81- @override
82- def on_moved (self , event : FileSystemMovedEvent ):
83- self .on_deleted (event )
84- self .on_created (FileSystemEvent (event .dest_path ))
128+ self .log .info (f"=> New mission { os .path .basename (path )[:- 4 ]} added to server { self .server .name } ." )
85129
86- @override
87- def on_deleted (self , event : FileSystemEvent ):
88- path : str = os .path .normpath (event .src_path )
89- # ignore non-mission files
130+ def on_deleted (self , event : DirDeletedEvent | FileDeletedEvent ) -> None :
131+ path = os .path .normpath (event .src_path )
90132 if not path .endswith ('.miz' ):
91133 return
92134 missions = self .server .settings ['missionList' ]
@@ -97,13 +139,15 @@ def on_deleted(self, event: FileSystemEvent):
97139 if path in missions :
98140 idx = missions .index (path ) + 1
99141 asyncio .run_coroutine_threadsafe (self .server .deleteMission (idx ), self .loop )
100- # cache the index of the line to re-add the file at the correct position afterward
101- # if a cloud drive did a delete/add instead of a modification
102142 self .deleted [path ] = idx
103143 self .log .info (f"=> Mission { os .path .basename (path )[:- 4 ]} deleted from server { self .server .name } ." )
104144 else :
105145 self .log .debug (f"Mission file { path } got deleted from disk." )
106146
147+ def on_moved (self , event : DirMovedEvent | FileMovedEvent ) -> None :
148+ self .on_deleted (event )
149+ self .on_created (event )
150+
107151
108152@dataclass
109153@DataObjectFactory .register ()
@@ -156,7 +200,7 @@ def settings(self) -> dict:
156200 # if someone managed to destroy the mission list, fix it...
157201 if 'missionList' not in self ._settings :
158202 self ._settings ['missionList' ] = []
159- self ._settings ['listStartIndex' ] = 0
203+ self ._settings ['listStartIndex' ] = self . settings [ 'current' ] = 0
160204 elif isinstance (self ._settings ['missionList' ], dict ):
161205 self ._settings ['missionList' ] = list (self ._settings ['missionList' ].values ())
162206 return self ._settings
@@ -232,7 +276,7 @@ def _make_missions_unique(self):
232276 new_start = self ._settings ['missionList' ].index (current_mission )
233277 except ValueError :
234278 new_start = 0
235- self ._settings ['listStartIndex' ] = new_start + 1
279+ self ._settings ['listStartIndex' ] = self . _settings [ 'current' ] = new_start + 1
236280
237281 async def _load_mission_list (self ):
238282 try :
@@ -257,8 +301,7 @@ def set_status(self, status: Status | str):
257301 if self ._status == Status .UNREGISTERED and status == Status .SHUTDOWN :
258302 if self .locals .get ('autoscan' , False ):
259303 self ._init_mission_list ()
260- else :
261- self ._make_missions_unique ()
304+ self ._make_missions_unique ()
262305 elif self ._status in [Status .UNREGISTERED , Status .LOADING ] and new_status in [Status .RUNNING , Status .PAUSED ]:
263306 # only check the mission list if we started that server
264307 if self ._status == Status .LOADING :
@@ -551,7 +594,7 @@ def do_startup(self):
551594 idx = missions .index (start_mission ) + 1
552595 except ValueError :
553596 idx = 1
554- self .settings ['listStartIndex' ] = idx
597+ self .settings ['listStartIndex' ] = self . settings [ 'current' ] = idx
555598 self .log .warning ('Removed non-existent missions from serverSettings.lua' )
556599 self .log .debug (r'Launching DCS server with: "{}" --server --norender -w {}' .format (path , self .instance .name ))
557600 try :
@@ -1059,8 +1102,7 @@ async def loadMission(self, mission: int | str, modify_mission: bool | None = Tr
10591102 if self .status == Status .STOPPED :
10601103 try :
10611104 idx = mission_list .index (filename ) + 1
1062- self .settings ['listStartIndex' ] = idx
1063- self .settings ['current' ] = idx
1105+ self .settings ['listStartIndex' ] = self .settings ['current' ] = idx
10641106 return await self .start ()
10651107 except ValueError :
10661108 return False
0 commit comments