1616 * Notes:
1717 * - WM_CLASS matching is performed case-insensitively by doing a substring match
1818 * (the rule's wmClass lowercased must appear anywhere inside the window's WM_CLASS).
19- * - `firstOnly` prevents the rule from being applied to more than one window instance. The runtime tracks
20- * the first-applied MetaWindow reference and frees the lock when that window is removed.
19+ * - `firstOnly: true` - Only the first window instance is placed on the assigned workspace (initial placement only).
20+ * - `firstOnly: false` - All window instances are placed on the assigned workspace with continuous enforcement.
21+ * The window will be automatically moved back if it's moved to a different workspace.
2122 * - `maximized` takes precedence over all geometry settings - if true, the window is fully maximized.
2223 * - `maximizeVertically` maximizes height only; requires `width` to be set, and `height` is ignored.
2324 */
@@ -27,7 +28,6 @@ const Settings = imports.ui.settings;
2728const SignalManager = imports . misc . signalManager ;
2829const Meta = imports . gi . Meta ;
2930const GLib = imports . gi . GLib ;
30- const Gio = imports . gi . Gio ;
3131
3232let extensionInstance = null ;
3333
@@ -48,6 +48,7 @@ class AutoMoveWindows {
4848 this . _settings = null ;
4949 this . _signals = null ;
5050 this . _firstAppliedMap = new Map ( ) ;
51+ this . _managedWindows = new Map ( ) ;
5152 }
5253
5354
@@ -63,14 +64,116 @@ class AutoMoveWindows {
6364 this . _signals . connect ( global . screen , 'window-added' , this . _onWindowAdded , this ) ;
6465 this . _signals . connect ( global . screen , 'window-removed' , this . _onWindowRemoved , this ) ;
6566
66- // Clear the firstOnly map whenever settings change to allow rules to be re-evaluated
67+ // Capture this context for settings change handler
68+ const self = this ;
69+
70+ // Clear tracking maps whenever settings change to allow rules to be re-evaluated
6771 this . _settings . connect ( 'changed::app-rules' , ( ) => {
68- this . _firstAppliedMap . clear ( ) ;
72+ try {
73+ self . _firstAppliedMap . clear ( ) ;
74+ self . _managedWindows . clear ( ) ;
75+ // Re-process all existing windows with new rules
76+ self . _processExistingWindows ( ) ;
77+ } catch ( e ) {
78+ global . logError ( '[auto-move-windows] Error in settings change handler: ' + e ) ;
79+ }
6980 } ) ;
7081
71- // Optionally connect to existing windows to track removals (not strictly required)
72- // and ensure map is clean if extension is enabled after windows exist.
73- let windows = global . display . list_windows ( 0 ) ;
82+ // Process existing windows to enable continuous enforcement for already-open windows
83+ this . _processExistingWindows ( ) ;
84+ }
85+
86+
87+ /**
88+ * Process all existing windows to set up continuous workspace enforcement.
89+ *
90+ * This is called when the extension is enabled or when settings change, to ensure
91+ * that windows matching rules with firstOnly=false are tracked for continuous enforcement.
92+ */
93+ _processExistingWindows ( ) {
94+ try {
95+ if ( ! this . _settings || ! this . _signals ) {
96+ return ;
97+ }
98+
99+ const windows = global . display . list_windows ( 0 ) ;
100+ const rules = ( this . _settings . getValue ( 'app-rules' ) || [ ] ) . map ( r => {
101+ if ( r && r . wmClass ) {
102+ let copy = Object . assign ( { } , r ) ;
103+ copy . _wmClassLower = String ( r . wmClass ) . trim ( ) . toLowerCase ( ) ;
104+ return copy ;
105+ }
106+ return r ;
107+ } ) ;
108+
109+ for ( let metaWindow of windows ) {
110+ if ( ! metaWindow )
111+ continue ;
112+
113+ const wmClass = metaWindow . get_wm_class && metaWindow . get_wm_class ( ) ;
114+ if ( ! wmClass )
115+ continue ;
116+
117+ const type = metaWindow . get_window_type ( ) ;
118+ if ( type === Meta . WindowType . DESKTOP || type === Meta . WindowType . DOCK || type === Meta . WindowType . SPLASHSCREEN )
119+ continue ;
120+
121+ const wmLower = String ( wmClass ) . toLowerCase ( ) ;
122+ const titleLower = String ( metaWindow . get_title && metaWindow . get_title ( ) || '' ) . toLowerCase ( ) ;
123+
124+ // Find matching rule
125+ const rule = rules . find ( r => {
126+ if ( ! r || ! r . _wmClassLower )
127+ return false ;
128+ const field = ( r . matchField || 'wmClass' ) ;
129+ switch ( field ) {
130+ case 'title' :
131+ return titleLower . indexOf ( r . _wmClassLower ) !== - 1 ;
132+ case 'wmClass' :
133+ default :
134+ return wmLower . indexOf ( r . _wmClassLower ) !== - 1 ;
135+ }
136+ } ) ;
137+
138+ if ( rule ) {
139+ // If firstOnly is true, register this window so subsequent instances are ignored
140+ if ( rule . firstOnly ) {
141+ const ruleKey = rule . _wmClassLower ;
142+ if ( ! this . _firstAppliedMap . has ( ruleKey ) ) {
143+ this . _firstAppliedMap . set ( ruleKey , metaWindow ) ;
144+ }
145+ }
146+ }
147+
148+ if ( rule && ! rule . firstOnly && Number . isInteger ( rule . workspace ) && rule . workspace >= 0 ) {
149+ // Move window to correct workspace if it's not already there
150+ const currentWorkspace = metaWindow . get_workspace ( ) ;
151+ const currentIndex = currentWorkspace ? currentWorkspace . index ( ) : - 1 ;
152+
153+ if ( currentIndex !== rule . workspace ) {
154+ if ( metaWindow . change_workspace_by_index ) {
155+ metaWindow . change_workspace_by_index ( rule . workspace , false ) ;
156+ } else if ( metaWindow . change_workspace ) {
157+ const ws = global . workspace_manager . get_workspace_by_index ( rule . workspace ) ;
158+ if ( ws )
159+ metaWindow . change_workspace ( ws ) ;
160+ }
161+ }
162+
163+ // Enable continuous enforcement for this window
164+ this . _managedWindows . set ( metaWindow , {
165+ workspace : rule . workspace ,
166+ wmClass : rule . wmClass
167+ } ) ;
168+
169+ // Connect to workspace-changed signal
170+ this . _signals . connect ( metaWindow , 'workspace-changed' ,
171+ this . _onWindowWorkspaceChanged . bind ( this , metaWindow ) ) ;
172+ }
173+ }
174+ } catch ( e ) {
175+ global . logError ( '[auto-move-windows] Error processing existing windows: ' + e ) ;
176+ }
74177 }
75178
76179
@@ -81,14 +184,21 @@ class AutoMoveWindows {
81184 * is being disabled or reloaded.
82185 */
83186 disable ( ) {
187+ // Clear maps first to prevent handlers from accessing them during cleanup
188+ if ( this . _firstAppliedMap ) {
189+ this . _firstAppliedMap . clear ( ) ;
190+ }
191+ if ( this . _managedWindows ) {
192+ this . _managedWindows . clear ( ) ;
193+ }
194+
84195 if ( this . _signals ) {
85196 this . _signals . disconnectAllSignals ( ) ;
86197 this . _signals = null ;
87198 }
88199 if ( this . _settings ) {
89200 this . _settings = null ;
90201 }
91- this . _firstAppliedMap . clear ( ) ;
92202 }
93203
94204
@@ -126,7 +236,6 @@ class AutoMoveWindows {
126236 const wmLower = String ( wmClass ) . toLowerCase ( ) ;
127237 const titleLower = String ( metaWindow . get_title && metaWindow . get_title ( ) || '' ) . toLowerCase ( ) ;
128238
129- try { global . log ( '[auto-move-windows] Window WM_CLASS: ' + String ( wmClass ) + ' (normalized: ' + wmLower + ')' ) ; } catch ( e ) { }
130239 const rule = rules . find ( r => {
131240 if ( ! r || ! r . _wmClassLower )
132241 return false ;
@@ -148,12 +257,15 @@ class AutoMoveWindows {
148257 return ;
149258 }
150259
260+ // Capture this context for use in timeout callback
261+ const self = this ;
262+
151263 // Wait a short time so the window has been fully mapped and its frame exists
152264 GLib . timeout_add ( GLib . PRIORITY_DEFAULT , 50 , ( ) => {
153265 // Register window in firstOnly map after processing starts
154266 if ( rule . firstOnly ) {
155267 const ruleKey = rule . _wmClassLower ;
156- this . _firstAppliedMap . set ( ruleKey , metaWindow ) ;
268+ self . _firstAppliedMap . set ( ruleKey , metaWindow ) ;
157269 }
158270
159271 if ( Number . isInteger ( rule . workspace ) && rule . workspace >= 0 ) {
@@ -164,6 +276,18 @@ class AutoMoveWindows {
164276 if ( ws )
165277 metaWindow . change_workspace ( ws ) ;
166278 }
279+
280+ // If firstOnly is false, enable continuous workspace enforcement
281+ if ( ! rule . firstOnly ) {
282+ self . _managedWindows . set ( metaWindow , {
283+ workspace : rule . workspace ,
284+ wmClass : rule . wmClass
285+ } ) ;
286+
287+ // Connect to workspace-changed signal for this window
288+ self . _signals . connect ( metaWindow , 'workspace-changed' ,
289+ self . _onWindowWorkspaceChanged . bind ( self , metaWindow ) ) ;
290+ }
167291 }
168292
169293 if ( rule . maximized === true ) {
@@ -172,7 +296,7 @@ class AutoMoveWindows {
172296 } catch ( e ) {
173297 global . logError ( e ) ;
174298 }
175- } else if ( rule . maximizeVertically === true && Number . isFinite ( rule . width ) ) {
299+ } else if ( rule . maximizeVertically === true && Number . isFinite ( rule . width ) && rule . width > 0 ) {
176300 try {
177301 const x = Number . isFinite ( rule . x ) ? rule . x : 0 ;
178302 const width = Math . max ( 1 , rule . width ) ;
@@ -190,7 +314,7 @@ class AutoMoveWindows {
190314 global . logError ( e ) ;
191315 }
192316 } else {
193- const hasGeom = Number . isFinite ( rule . width ) && Number . isFinite ( rule . height ) ;
317+ const hasGeom = Number . isFinite ( rule . width ) && rule . width > 0 && Number . isFinite ( rule . height ) && rule . height > 0 ;
194318 if ( hasGeom ) {
195319 const x = Number . isFinite ( rule . x ) ? rule . x : 0 ;
196320 const y = Number . isFinite ( rule . y ) ? rule . y : 0 ;
@@ -210,11 +334,55 @@ class AutoMoveWindows {
210334 }
211335
212336
337+ /**
338+ * Handler for the `workspace-changed` signal on managed windows.
339+ *
340+ * When a window with continuous enforcement changes workspace, this moves it back
341+ * to its assigned workspace.
342+ *
343+ * @param {Meta.Window } metaWindow - the MetaWindow that changed workspace
344+ */
345+ _onWindowWorkspaceChanged ( metaWindow ) {
346+ try {
347+ if ( ! metaWindow )
348+ return ;
349+
350+ // Check if map still exists (extension might be disabling)
351+ if ( ! this . _managedWindows )
352+ return ;
353+
354+ if ( ! this . _managedWindows . has ( metaWindow ) )
355+ return ;
356+
357+ const windowInfo = this . _managedWindows . get ( metaWindow ) ;
358+ const currentWorkspace = metaWindow . get_workspace ( ) ;
359+ const targetWorkspace = global . workspace_manager . get_workspace_by_index ( windowInfo . workspace ) ;
360+
361+ // If window moved to wrong workspace, move it back
362+ if ( currentWorkspace && targetWorkspace && currentWorkspace !== targetWorkspace ) {
363+ try {
364+ global . log ( '[auto-move-windows] Enforcing workspace ' + windowInfo . workspace +
365+ ' for ' + windowInfo . wmClass ) ;
366+ if ( metaWindow . change_workspace_by_index ) {
367+ metaWindow . change_workspace_by_index ( windowInfo . workspace , false ) ;
368+ } else if ( metaWindow . change_workspace ) {
369+ metaWindow . change_workspace ( targetWorkspace ) ;
370+ }
371+ } catch ( e ) {
372+ global . logError ( '[auto-move-windows] Error enforcing workspace: ' + e ) ;
373+ }
374+ }
375+ } catch ( e ) {
376+ global . logError ( '[auto-move-windows] Error in workspace-changed handler: ' + e ) ;
377+ }
378+ }
379+
380+
213381 /**
214382 * Handler for the `window-removed` signal.
215383 *
216384 * Frees any `firstOnly` locks that referenced the removed window so future windows
217- * matching the same rule may be handled.
385+ * matching the same rule may be handled. Also removes continuous workspace enforcement.
218386 *
219387 * @param {object } screen - the Screen object that emitted the signal
220388 * @param {Meta.Window } metaWindow - the removed MetaWindow
@@ -225,13 +393,23 @@ class AutoMoveWindows {
225393 if ( ! metaWindow )
226394 return ;
227395
396+ // Check if maps still exist (extension might be disabling)
397+ if ( ! this . _firstAppliedMap || ! this . _managedWindows )
398+ return ;
399+
400+ // Remove from firstOnly tracking
228401 for ( let [ key , tracked ] of this . _firstAppliedMap ) {
229402 if ( tracked === metaWindow ) {
230403 this . _firstAppliedMap . delete ( key ) ;
231404 }
232405 }
406+
407+ // Remove from continuous enforcement tracking
408+ if ( this . _managedWindows . has ( metaWindow ) ) {
409+ this . _managedWindows . delete ( metaWindow ) ;
410+ }
233411 } catch ( e ) {
234- global . logError ( e ) ;
412+ global . logError ( '[auto-move-windows] Error in window-removed handler: ' + e ) ;
235413 }
236414 }
237415}
@@ -246,7 +424,6 @@ function init(metadata) {
246424function enable ( ) {
247425 try {
248426 extensionInstance . enable ( ) ;
249- return { create_sample_settings_file } ;
250427 } catch ( e ) {
251428 global . logError ( e ) ;
252429 disable ( ) ;
@@ -265,51 +442,3 @@ function disable() {
265442 extensionInstance = null ;
266443 }
267444}
268-
269-
270- /**
271- * Create a sample settings JSON file in the user's home directory.
272- *
273- * The file is written as ~/auto-move-windows-sample-settings.json and contains a
274- * minimal JSON object with a single entry in the `app-rules` array. After creating the file,
275- * it is automatically opened in the user's preferred text/code editor.
276- */
277- function create_sample_settings_file ( ) {
278- const home = GLib . get_home_dir ( ) ;
279- const target = GLib . build_filenamev ( [ home , 'auto-move-windows-sample-settings.json' ] ) ;
280- const boilerplate = {
281- "app-rules" : [
282- {
283- "wmClass" : "Firefox" ,
284- "matchField" : "title" ,
285- "workspace" : 2 ,
286- "x" : 640 ,
287- "y" : 0 ,
288- "width" : 640 ,
289- "maximizeVertically" : true ,
290- "firstOnly" : true
291- }
292- ]
293- } ;
294- const jsonString = JSON . stringify ( boilerplate , null , 2 ) ;
295- GLib . file_set_contents ( target , jsonString ) ;
296- try { Main . notify ( 'auto-move-windows' , 'Created ' + target ) ; } catch ( e ) { }
297-
298- // Open the file in the user's preferred text editor
299- try {
300- const file = Gio . File . new_for_path ( target ) ;
301- // Use the default text editor instead of the default for JSON files (which is often a browser)
302- const textEditor = Gio . AppInfo . get_default_for_type ( 'text/plain' , false ) ;
303- if ( textEditor ) {
304- textEditor . launch ( [ file ] , null ) ;
305- } else {
306- // Fallback to URI launcher if no text editor is set
307- const uri = file . get_uri ( ) ;
308- Gio . AppInfo . launch_default_for_uri ( uri , null ) ;
309- }
310- } catch ( e ) {
311- global . logError ( '[auto-move-windows] Failed to open file: ' + e ) ;
312- }
313-
314- return true ;
315- }
0 commit comments