1 | /******************************************************************** |
2 | KWin - the KDE window manager |
3 | This file is part of the KDE project. |
4 | |
5 | Copyright (C) 1999, 2000 Matthias Ettrich <ettrich@kde.org> |
6 | Copyright (C) 2003 Lubos Lunak <l.lunak@kde.org> |
7 | |
8 | This program is free software; you can redistribute it and/or modify |
9 | it under the terms of the GNU General Public License as published by |
10 | the Free Software Foundation; either version 2 of the License, or |
11 | (at your option) any later version. |
12 | |
13 | This program is distributed in the hope that it will be useful, |
14 | but WITHOUT ANY WARRANTY; without even the implied warranty of |
15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
16 | GNU General Public License for more details. |
17 | |
18 | You should have received a copy of the GNU General Public License |
19 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
20 | *********************************************************************/ |
21 | |
22 | #include "sm.h" |
23 | |
24 | #include <unistd.h> |
25 | #include <stdlib.h> |
26 | #include <pwd.h> |
27 | #include <fixx11h.h> |
28 | #include <kconfig.h> |
29 | #include <kglobal.h> |
30 | |
31 | #include "workspace.h" |
32 | #include "client.h" |
33 | #include <QSocketNotifier> |
34 | #include <QSessionManager> |
35 | #include <kdebug.h> |
36 | |
37 | namespace KWin |
38 | { |
39 | |
40 | bool SessionManager::saveState(QSessionManager& sm) |
41 | { |
42 | // If the session manager is ksmserver, save stacking |
43 | // order, active window, active desktop etc. in phase 1, |
44 | // as ksmserver assures no interaction will be done |
45 | // before the WM finishes phase 1. Saving in phase 2 is |
46 | // too late, as possible user interaction may change some things. |
47 | // Phase2 is still needed though (ICCCM 5.2) |
48 | char* sm_vendor = SmcVendor(static_cast< SmcConn >(sm.handle())); |
49 | bool ksmserver = qstrcmp(sm_vendor, "KDE" ) == 0; |
50 | free(sm_vendor); |
51 | if (!sm.isPhase2()) { |
52 | Workspace::self()->sessionSaveStarted(); |
53 | if (ksmserver) // save stacking order etc. before "save file?" etc. dialogs change it |
54 | Workspace::self()->storeSession(kapp->sessionConfig(), SMSavePhase0); |
55 | sm.release(); // Qt doesn't automatically release in this case (bug?) |
56 | sm.requestPhase2(); |
57 | return true; |
58 | } |
59 | Workspace::self()->storeSession(kapp->sessionConfig(), ksmserver ? SMSavePhase2 : SMSavePhase2Full); |
60 | kapp->sessionConfig()->sync(); |
61 | return true; |
62 | } |
63 | |
64 | // I bet this is broken, just like everywhere else in KDE |
65 | bool SessionManager::commitData(QSessionManager& sm) |
66 | { |
67 | if (!sm.isPhase2()) |
68 | Workspace::self()->sessionSaveStarted(); |
69 | return true; |
70 | } |
71 | |
72 | // Workspace |
73 | |
74 | /*! |
75 | Stores the current session in the config file |
76 | |
77 | \sa loadSessionInfo() |
78 | */ |
79 | void Workspace::storeSession(KConfig* config, SMSavePhase phase) |
80 | { |
81 | KConfigGroup cg(config, "Session" ); |
82 | int count = 0; |
83 | int active_client = -1; |
84 | |
85 | for (ClientList::Iterator it = clients.begin(); it != clients.end(); ++it) { |
86 | Client* c = (*it); |
87 | QByteArray sessionId = c->sessionId(); |
88 | QByteArray wmCommand = c->wmCommand(); |
89 | if (sessionId.isEmpty()) |
90 | // remember also applications that are not XSMP capable |
91 | // and use the obsolete WM_COMMAND / WM_SAVE_YOURSELF |
92 | if (wmCommand.isEmpty()) |
93 | continue; |
94 | count++; |
95 | if (c->isActive()) |
96 | active_client = count; |
97 | if (phase == SMSavePhase2 || phase == SMSavePhase2Full) |
98 | storeClient(cg, count, c); |
99 | } |
100 | if (phase == SMSavePhase0) { |
101 | // it would be much simpler to save these values to the config file, |
102 | // but both Qt and KDE treat phase1 and phase2 separately, |
103 | // which results in different sessionkey and different config file :( |
104 | session_active_client = active_client; |
105 | session_desktop = VirtualDesktopManager::self()->current(); |
106 | } else if (phase == SMSavePhase2) { |
107 | cg.writeEntry("count" , count); |
108 | cg.writeEntry("active" , session_active_client); |
109 | cg.writeEntry("desktop" , session_desktop); |
110 | } else { // SMSavePhase2Full |
111 | cg.writeEntry("count" , count); |
112 | cg.writeEntry("active" , session_active_client); |
113 | cg.writeEntry("desktop" , VirtualDesktopManager::self()->current()); |
114 | } |
115 | } |
116 | |
117 | void Workspace::storeClient(KConfigGroup &cg, int num, Client *c) |
118 | { |
119 | c->setSessionInteract(false); //make sure we get the real values |
120 | QString n = QString::number(num); |
121 | cg.writeEntry(QString("sessionId" ) + n, c->sessionId().constData()); |
122 | cg.writeEntry(QString("windowRole" ) + n, c->windowRole().constData()); |
123 | cg.writeEntry(QString("wmCommand" ) + n, c->wmCommand().constData()); |
124 | cg.writeEntry(QString("resourceName" ) + n, c->resourceName().constData()); |
125 | cg.writeEntry(QString("resourceClass" ) + n, c->resourceClass().constData()); |
126 | cg.writeEntry(QString("geometry" ) + n, QRect(c->calculateGravitation(true), c->clientSize())); // FRAME |
127 | cg.writeEntry(QString("restore" ) + n, c->geometryRestore()); |
128 | cg.writeEntry(QString("fsrestore" ) + n, c->geometryFSRestore()); |
129 | cg.writeEntry(QString("maximize" ) + n, (int) c->maximizeMode()); |
130 | cg.writeEntry(QString("fullscreen" ) + n, (int) c->fullScreenMode()); |
131 | cg.writeEntry(QString("desktop" ) + n, c->desktop()); |
132 | // the config entry is called "iconified" for back. comp. reasons |
133 | // (kconf_update script for updating session files would be too complicated) |
134 | cg.writeEntry(QString("iconified" ) + n, c->isMinimized()); |
135 | cg.writeEntry(QString("opacity" ) + n, c->opacity()); |
136 | // the config entry is called "sticky" for back. comp. reasons |
137 | cg.writeEntry(QString("sticky" ) + n, c->isOnAllDesktops()); |
138 | cg.writeEntry(QString("shaded" ) + n, c->isShade()); |
139 | // the config entry is called "staysOnTop" for back. comp. reasons |
140 | cg.writeEntry(QString("staysOnTop" ) + n, c->keepAbove()); |
141 | cg.writeEntry(QString("keepBelow" ) + n, c->keepBelow()); |
142 | cg.writeEntry(QString("skipTaskbar" ) + n, c->skipTaskbar(true)); |
143 | cg.writeEntry(QString("skipPager" ) + n, c->skipPager()); |
144 | cg.writeEntry(QString("skipSwitcher" ) + n, c->skipSwitcher()); |
145 | // not really just set by user, but name kept for back. comp. reasons |
146 | cg.writeEntry(QString("userNoBorder" ) + n, c->noBorder()); |
147 | cg.writeEntry(QString("windowType" ) + n, windowTypeToTxt(c->windowType())); |
148 | cg.writeEntry(QString("shortcut" ) + n, c->shortcut().toString()); |
149 | cg.writeEntry(QString("stackingOrder" ) + n, unconstrained_stacking_order.indexOf(c)); |
150 | // KConfig doesn't support long so we need to live with less precision on 64-bit systems |
151 | cg.writeEntry(QString("tabGroup" ) + n, static_cast<int>(reinterpret_cast<long>(c->tabGroup()))); |
152 | cg.writeEntry(QString("activities" ) + n, c->activities()); |
153 | } |
154 | |
155 | void Workspace::storeSubSession(const QString &name, QSet<QByteArray> sessionIds) |
156 | { |
157 | //TODO clear it first |
158 | KConfigGroup cg(KGlobal::config(), QString("SubSession: " ) + name); |
159 | int count = 0; |
160 | int active_client = -1; |
161 | for (ClientList::Iterator it = clients.begin(); it != clients.end(); ++it) { |
162 | Client* c = (*it); |
163 | QByteArray sessionId = c->sessionId(); |
164 | QByteArray wmCommand = c->wmCommand(); |
165 | if (sessionId.isEmpty()) |
166 | // remember also applications that are not XSMP capable |
167 | // and use the obsolete WM_COMMAND / WM_SAVE_YOURSELF |
168 | if (wmCommand.isEmpty()) |
169 | continue; |
170 | if (!sessionIds.contains(sessionId)) |
171 | continue; |
172 | |
173 | kDebug() << "storing" << sessionId; |
174 | count++; |
175 | if (c->isActive()) |
176 | active_client = count; |
177 | storeClient(cg, count, c); |
178 | } |
179 | cg.writeEntry("count" , count); |
180 | cg.writeEntry("active" , active_client); |
181 | //cg.writeEntry( "desktop", currentDesktop()); |
182 | } |
183 | |
184 | /*! |
185 | Loads the session information from the config file. |
186 | |
187 | \sa storeSession() |
188 | */ |
189 | void Workspace::loadSessionInfo() |
190 | { |
191 | session.clear(); |
192 | KConfigGroup cg(kapp->sessionConfig(), "Session" ); |
193 | |
194 | addSessionInfo(cg); |
195 | } |
196 | |
197 | void Workspace::addSessionInfo(KConfigGroup &cg) |
198 | { |
199 | int count = cg.readEntry("count" , 0); |
200 | int active_client = cg.readEntry("active" , 0); |
201 | for (int i = 1; i <= count; i++) { |
202 | QString n = QString::number(i); |
203 | SessionInfo* info = new SessionInfo; |
204 | session.append(info); |
205 | info->sessionId = cg.readEntry(QString("sessionId" ) + n, QString()).toLatin1(); |
206 | info->windowRole = cg.readEntry(QString("windowRole" ) + n, QString()).toLatin1(); |
207 | info->wmCommand = cg.readEntry(QString("wmCommand" ) + n, QString()).toLatin1(); |
208 | info->resourceName = cg.readEntry(QString("resourceName" ) + n, QString()).toLatin1(); |
209 | info->resourceClass = cg.readEntry(QString("resourceClass" ) + n, QString()).toLower().toLatin1(); |
210 | info->geometry = cg.readEntry(QString("geometry" ) + n, QRect()); |
211 | info->restore = cg.readEntry(QString("restore" ) + n, QRect()); |
212 | info->fsrestore = cg.readEntry(QString("fsrestore" ) + n, QRect()); |
213 | info->maximized = cg.readEntry(QString("maximize" ) + n, 0); |
214 | info->fullscreen = cg.readEntry(QString("fullscreen" ) + n, 0); |
215 | info->desktop = cg.readEntry(QString("desktop" ) + n, 0); |
216 | info->minimized = cg.readEntry(QString("iconified" ) + n, false); |
217 | info->opacity = cg.readEntry(QString("opacity" ) + n, 1.0); |
218 | info->onAllDesktops = cg.readEntry(QString("sticky" ) + n, false); |
219 | info->shaded = cg.readEntry(QString("shaded" ) + n, false); |
220 | info->keepAbove = cg.readEntry(QString("staysOnTop" ) + n, false); |
221 | info->keepBelow = cg.readEntry(QString("keepBelow" ) + n, false); |
222 | info->skipTaskbar = cg.readEntry(QString("skipTaskbar" ) + n, false); |
223 | info->skipPager = cg.readEntry(QString("skipPager" ) + n, false); |
224 | info->skipSwitcher = cg.readEntry(QString("skipSwitcher" ) + n, false); |
225 | info->noBorder = cg.readEntry(QString("userNoBorder" ) + n, false); |
226 | info->windowType = txtToWindowType(cg.readEntry(QString("windowType" ) + n, QString()).toLatin1()); |
227 | info->shortcut = cg.readEntry(QString("shortcut" ) + n, QString()); |
228 | info->active = (active_client == i); |
229 | info->stackingOrder = cg.readEntry(QString("stackingOrder" ) + n, -1); |
230 | info->tabGroup = cg.readEntry(QString("tabGroup" ) + n, 0); |
231 | info->tabGroupClient = NULL; |
232 | info->activities = cg.readEntry(QString("activities" ) + n, QStringList()); |
233 | } |
234 | } |
235 | |
236 | void Workspace::loadSubSessionInfo(const QString &name) |
237 | { |
238 | KConfigGroup cg(KGlobal::config(), QString("SubSession: " ) + name); |
239 | addSessionInfo(cg); |
240 | } |
241 | |
242 | /*! |
243 | Returns a SessionInfo for client \a c. The returned session |
244 | info is removed from the storage. It's up to the caller to delete it. |
245 | |
246 | This function is called when a new window is mapped and must be managed. |
247 | We try to find a matching entry in the session. |
248 | |
249 | May return 0 if there's no session info for the client. |
250 | */ |
251 | SessionInfo* Workspace::takeSessionInfo(Client* c) |
252 | { |
253 | SessionInfo *realInfo = 0; |
254 | QByteArray sessionId = c->sessionId(); |
255 | QByteArray windowRole = c->windowRole(); |
256 | QByteArray wmCommand = c->wmCommand(); |
257 | QByteArray resourceName = c->resourceName(); |
258 | QByteArray resourceClass = c->resourceClass(); |
259 | |
260 | // First search ``session'' |
261 | if (! sessionId.isEmpty()) { |
262 | // look for a real session managed client (algorithm suggested by ICCCM) |
263 | foreach (SessionInfo * info, session) { |
264 | if (realInfo) |
265 | break; |
266 | if (info->sessionId == sessionId && sessionInfoWindowTypeMatch(c, info)) { |
267 | if (! windowRole.isEmpty()) { |
268 | if (info->windowRole == windowRole) { |
269 | realInfo = info; |
270 | session.removeAll(info); |
271 | } |
272 | } else { |
273 | if (info->windowRole.isEmpty() |
274 | && info->resourceName == resourceName |
275 | && info->resourceClass == resourceClass) { |
276 | realInfo = info; |
277 | session.removeAll(info); |
278 | } |
279 | } |
280 | } |
281 | } |
282 | } else { |
283 | // look for a sessioninfo with matching features. |
284 | foreach (SessionInfo * info, session) { |
285 | if (realInfo) |
286 | break; |
287 | if (info->resourceName == resourceName |
288 | && info->resourceClass == resourceClass |
289 | && sessionInfoWindowTypeMatch(c, info)) { |
290 | if (wmCommand.isEmpty() || info->wmCommand == wmCommand) { |
291 | realInfo = info; |
292 | session.removeAll(info); |
293 | } |
294 | } |
295 | } |
296 | } |
297 | |
298 | // Set tabGroupClient for other clients in the same group |
299 | if (realInfo && realInfo->tabGroup) { |
300 | foreach (SessionInfo * info, session) { |
301 | if (!info->tabGroupClient && info->tabGroup == realInfo->tabGroup) |
302 | info->tabGroupClient = c; |
303 | } |
304 | } |
305 | |
306 | return realInfo; |
307 | } |
308 | |
309 | bool Workspace::sessionInfoWindowTypeMatch(Client* c, SessionInfo* info) |
310 | { |
311 | if (info->windowType == -2) { |
312 | // undefined (not really part of NET::WindowType) |
313 | return !c->isSpecialWindow(); |
314 | } |
315 | return info->windowType == c->windowType(); |
316 | } |
317 | |
318 | static const char* const window_type_names[] = { |
319 | "Unknown" , "Normal" , "Desktop" , "Dock" , "Toolbar" , "Menu" , "Dialog" , |
320 | "Override" , "TopMenu" , "Utility" , "Splash" |
321 | }; |
322 | // change also the two functions below when adding new entries |
323 | |
324 | const char* Workspace::windowTypeToTxt(NET::WindowType type) |
325 | { |
326 | if (type >= NET::Unknown && type <= NET::Splash) |
327 | return window_type_names[ type + 1 ]; // +1 (unknown==-1) |
328 | if (type == -2) // undefined (not really part of NET::WindowType) |
329 | return "Undefined" ; |
330 | kFatal(1212) << "Unknown Window Type" ; |
331 | return NULL; |
332 | } |
333 | |
334 | NET::WindowType Workspace::txtToWindowType(const char* txt) |
335 | { |
336 | for (int i = NET::Unknown; |
337 | i <= NET::Splash; |
338 | ++i) |
339 | if (qstrcmp(txt, window_type_names[ i + 1 ]) == 0) // +1 |
340 | return static_cast< NET::WindowType >(i); |
341 | return static_cast< NET::WindowType >(-2); // undefined |
342 | } |
343 | |
344 | |
345 | |
346 | |
347 | // KWin's focus stealing prevention causes problems with user interaction |
348 | // during session save, as it prevents possible dialogs from getting focus. |
349 | // Therefore it's temporarily disabled during session saving. Start of |
350 | // session saving can be detected in SessionManager::saveState() above, |
351 | // but Qt doesn't have API for saying when session saved finished (either |
352 | // successfully, or was canceled). Therefore, create another connection |
353 | // to session manager, that will provide this information. |
354 | // Similarly the remember feature of window-specific settings should be disabled |
355 | // during KDE shutdown when windows may move e.g. because of Kicker going away |
356 | // (struts changing). When session saving starts, it can be cancelled, in which |
357 | // case the shutdown_cancelled callback is invoked, or it's a checkpoint that |
358 | // is immediatelly followed by save_complete, or finally it's a shutdown that |
359 | // is immediatelly followed by die callback. So getting save_yourself with shutdown |
360 | // set disables window-specific settings remembering, getting shutdown_cancelled |
361 | // re-enables, otherwise KWin will go away after die. |
362 | static void save_yourself(SmcConn conn_P, SmPointer ptr, int, Bool shutdown, int, Bool) |
363 | { |
364 | SessionSaveDoneHelper* session = reinterpret_cast< SessionSaveDoneHelper* >(ptr); |
365 | if (conn_P != session->connection()) |
366 | return; |
367 | if (shutdown) |
368 | RuleBook::self()->setUpdatesDisabled(true); |
369 | SmcSaveYourselfDone(conn_P, True); |
370 | } |
371 | |
372 | static void die(SmcConn conn_P, SmPointer ptr) |
373 | { |
374 | SessionSaveDoneHelper* session = reinterpret_cast< SessionSaveDoneHelper* >(ptr); |
375 | if (conn_P != session->connection()) |
376 | return; |
377 | // session->saveDone(); we will quit anyway |
378 | session->close(); |
379 | } |
380 | |
381 | static void save_complete(SmcConn conn_P, SmPointer ptr) |
382 | { |
383 | SessionSaveDoneHelper* session = reinterpret_cast< SessionSaveDoneHelper* >(ptr); |
384 | if (conn_P != session->connection()) |
385 | return; |
386 | session->saveDone(); |
387 | } |
388 | |
389 | static void shutdown_cancelled(SmcConn conn_P, SmPointer ptr) |
390 | { |
391 | SessionSaveDoneHelper* session = reinterpret_cast< SessionSaveDoneHelper* >(ptr); |
392 | if (conn_P != session->connection()) |
393 | return; |
394 | RuleBook::self()->setUpdatesDisabled(false); // re-enable |
395 | // no need to differentiate between successful finish and cancel |
396 | session->saveDone(); |
397 | } |
398 | |
399 | void SessionSaveDoneHelper::saveDone() |
400 | { |
401 | Workspace::self()->sessionSaveDone(); |
402 | } |
403 | |
404 | SessionSaveDoneHelper::SessionSaveDoneHelper() |
405 | { |
406 | SmcCallbacks calls; |
407 | calls.save_yourself.callback = save_yourself; |
408 | calls.save_yourself.client_data = reinterpret_cast< SmPointer >(this); |
409 | calls.die.callback = die; |
410 | calls.die.client_data = reinterpret_cast< SmPointer >(this); |
411 | calls.save_complete.callback = save_complete; |
412 | calls.save_complete.client_data = reinterpret_cast< SmPointer >(this); |
413 | calls.shutdown_cancelled.callback = shutdown_cancelled; |
414 | calls.shutdown_cancelled.client_data = reinterpret_cast< SmPointer >(this); |
415 | char* id = NULL; |
416 | char err[ 11 ]; |
417 | conn = SmcOpenConnection(NULL, 0, 1, 0, |
418 | SmcSaveYourselfProcMask | SmcDieProcMask | SmcSaveCompleteProcMask |
419 | | SmcShutdownCancelledProcMask, &calls, NULL, &id, 10, err); |
420 | if (id != NULL) |
421 | free(id); |
422 | if (conn == NULL) |
423 | return; // no SM |
424 | // set the required properties, mostly dummy values |
425 | SmPropValue propvalue[ 5 ]; |
426 | SmProp props[ 5 ]; |
427 | propvalue[ 0 ].length = sizeof(unsigned char); |
428 | unsigned char value0 = SmRestartNever; // so that this extra SM connection doesn't interfere |
429 | propvalue[ 0 ].value = &value0; |
430 | props[ 0 ].name = const_cast< char* >(SmRestartStyleHint); |
431 | props[ 0 ].type = const_cast< char* >(SmCARD8); |
432 | props[ 0 ].num_vals = 1; |
433 | props[ 0 ].vals = &propvalue[ 0 ]; |
434 | struct passwd* entry = getpwuid(geteuid()); |
435 | propvalue[ 1 ].length = entry != NULL ? strlen(entry->pw_name) : 0; |
436 | propvalue[ 1 ].value = (SmPointer)(entry != NULL ? entry->pw_name : "" ); |
437 | props[ 1 ].name = const_cast< char* >(SmUserID); |
438 | props[ 1 ].type = const_cast< char* >(SmARRAY8); |
439 | props[ 1 ].num_vals = 1; |
440 | props[ 1 ].vals = &propvalue[ 1 ]; |
441 | propvalue[ 2 ].length = 0; |
442 | propvalue[ 2 ].value = (SmPointer)("" ); |
443 | props[ 2 ].name = const_cast< char* >(SmRestartCommand); |
444 | props[ 2 ].type = const_cast< char* >(SmLISTofARRAY8); |
445 | props[ 2 ].num_vals = 1; |
446 | props[ 2 ].vals = &propvalue[ 2 ]; |
447 | propvalue[ 3 ].length = strlen("kwinsmhelper" ); |
448 | propvalue[ 3 ].value = (SmPointer)"kwinsmhelper" ; |
449 | props[ 3 ].name = const_cast< char* >(SmProgram); |
450 | props[ 3 ].type = const_cast< char* >(SmARRAY8); |
451 | props[ 3 ].num_vals = 1; |
452 | props[ 3 ].vals = &propvalue[ 3 ]; |
453 | propvalue[ 4 ].length = 0; |
454 | propvalue[ 4 ].value = (SmPointer)("" ); |
455 | props[ 4 ].name = const_cast< char* >(SmCloneCommand); |
456 | props[ 4 ].type = const_cast< char* >(SmLISTofARRAY8); |
457 | props[ 4 ].num_vals = 1; |
458 | props[ 4 ].vals = &propvalue[ 4 ]; |
459 | SmProp* p[ 5 ] = { &props[ 0 ], &props[ 1 ], &props[ 2 ], &props[ 3 ], &props[ 4 ] }; |
460 | SmcSetProperties(conn, 5, p); |
461 | notifier = new QSocketNotifier(IceConnectionNumber(SmcGetIceConnection(conn)), |
462 | QSocketNotifier::Read, this); |
463 | connect(notifier, SIGNAL(activated(int)), SLOT(processData())); |
464 | } |
465 | |
466 | SessionSaveDoneHelper::~SessionSaveDoneHelper() |
467 | { |
468 | close(); |
469 | } |
470 | |
471 | void SessionSaveDoneHelper::close() |
472 | { |
473 | if (conn != NULL) { |
474 | delete notifier; |
475 | SmcCloseConnection(conn, 0, NULL); |
476 | } |
477 | conn = NULL; |
478 | } |
479 | |
480 | void SessionSaveDoneHelper::processData() |
481 | { |
482 | if (conn != NULL) |
483 | IceProcessMessages(SmcGetIceConnection(conn), 0, 0); |
484 | } |
485 | |
486 | void Workspace::sessionSaveDone() |
487 | { |
488 | session_saving = false; |
489 | //remove sessionInteract flag from all clients |
490 | foreach (Client * c, clients) { |
491 | c->setSessionInteract(false); |
492 | } |
493 | } |
494 | |
495 | } // namespace |
496 | |
497 | #include "sm.moc" |
498 | |