1 /* === This file is part of Calamares - <https://calamares.io> ===
2  *
3  *   SPDX-FileCopyrightText: 2019-2020 Adriaan de Groot <groot@kde.org>
4  *   SPDX-FileCopyrightText: 2020 Camilo Higuita <milo.h@aol.com> *
5  *   SPDX-License-Identifier: GPL-3.0-or-later
6  *
7  *   Calamares is Free Software: see the License-Identifier above.
8  *
9  */
10 
11 #include "Config.h"
12 
13 #include "SetKeyboardLayoutJob.h"
14 #include "keyboardwidget/keyboardpreview.h"
15 
16 #include "GlobalStorage.h"
17 #include "JobQueue.h"
18 #include "utils/Logger.h"
19 #include "utils/RAII.h"
20 #include "utils/Retranslator.h"
21 #include "utils/String.h"
22 #include "utils/Variant.h"
23 
24 #include <QApplication>
25 #include <QProcess>
26 #include <QTimer>
27 
28 /* Returns stringlist with suitable setxkbmap command-line arguments
29  * to set the given @p model.
30  */
31 static inline QStringList
xkbmap_model_args(const QString & model)32 xkbmap_model_args( const QString& model )
33 {
34     QStringList r { "-model", model };
35     return r;
36 }
37 
38 
39 /* Returns stringlist with suitable setxkbmap command-line arguments
40  * to set the given @p layout and @p variant.
41  */
42 static inline QStringList
xkbmap_layout_args(const QString & layout,const QString & variant)43 xkbmap_layout_args( const QString& layout, const QString& variant )
44 {
45     QStringList r { "-layout", layout };
46     if ( !variant.isEmpty() )
47     {
48         r << "-variant" << variant;
49     }
50     return r;
51 }
52 
53 static inline QStringList
xkbmap_layout_args(const QStringList & layouts,const QStringList & variants,const QString & switchOption="grp:alt_shift_toggle")54 xkbmap_layout_args( const QStringList& layouts,
55                     const QStringList& variants,
56                     const QString& switchOption = "grp:alt_shift_toggle" )
57 {
58     if ( layouts.size() != variants.size() )
59     {
60         cError() << "Number of layouts and variants must be equal (empty string should be used if there is no "
61                     "corresponding variant)";
62         return QStringList();
63     }
64 
65     QStringList r { "-layout", layouts.join( "," ) };
66 
67     if ( !variants.isEmpty() )
68     {
69         r << "-variant" << variants.join( "," );
70     }
71 
72     if ( !switchOption.isEmpty() )
73     {
74         r << "-option" << switchOption;
75     }
76 
77     return r;
78 }
79 
80 /* Returns group-switch setxkbd option if set
81  * or an empty string otherwise
82  */
83 static inline QString
xkbmap_query_grp_option()84 xkbmap_query_grp_option()
85 {
86     QProcess setxkbmapQuery;
87     setxkbmapQuery.start( "setxkbmap", { "-query" } );
88     setxkbmapQuery.waitForFinished();
89 
90     QString outputLine;
91 
92     do
93     {
94         outputLine = setxkbmapQuery.readLine();
95     } while ( setxkbmapQuery.canReadLine() && !outputLine.startsWith( "options:" ) );
96 
97     if ( !outputLine.startsWith( "options:" ) )
98     {
99         return QString();
100     }
101 
102     int index = outputLine.indexOf( "grp:" );
103 
104     if ( index == -1 )
105     {
106         return QString();
107     }
108 
109     //it's either in the end of line or before the other option so \s or ,
110     int lastIndex = outputLine.indexOf( QRegExp( "[\\s,]" ), index );
111 
112     return outputLine.mid( index, lastIndex - index );
113 }
114 
115 AdditionalLayoutInfo
getAdditionalLayoutInfo(const QString & layout)116 Config::getAdditionalLayoutInfo( const QString& layout )
117 {
118     QFile layoutTable( ":/non-ascii-layouts" );
119 
120     if ( !layoutTable.open( QIODevice::ReadOnly | QIODevice::Text ) )
121     {
122         cError() << "Non-ASCII layout table could not be opened";
123         return AdditionalLayoutInfo();
124     }
125 
126     QString tableLine;
127 
128     do
129     {
130         tableLine = layoutTable.readLine();
131     } while ( layoutTable.canReadLine() && !tableLine.startsWith( layout ) );
132 
133     if ( !tableLine.startsWith( layout ) )
134     {
135         return AdditionalLayoutInfo();
136     }
137 
138     QStringList tableEntries = tableLine.split( " ", SplitSkipEmptyParts );
139 
140     AdditionalLayoutInfo r;
141 
142     r.additionalLayout = tableEntries[ 1 ];
143     r.additionalVariant = tableEntries[ 2 ] == "-" ? "" : tableEntries[ 2 ];
144 
145     r.vconsoleKeymap = tableEntries[ 3 ];
146 
147     return r;
148 }
149 
Config(QObject * parent)150 Config::Config( QObject* parent )
151     : QObject( parent )
152     , m_keyboardModelsModel( new KeyboardModelsModel( this ) )
153     , m_keyboardLayoutsModel( new KeyboardLayoutModel( this ) )
154     , m_keyboardVariantsModel( new KeyboardVariantsModel( this ) )
155 {
156     m_setxkbmapTimer.setSingleShot( true );
157 
158     // Connect signals and slots
159     connect( m_keyboardModelsModel, &KeyboardModelsModel::currentIndexChanged, [&]( int index ) {
160         // Set Xorg keyboard model
161         m_selectedModel = m_keyboardModelsModel->key( index );
162         QProcess::execute( "setxkbmap", xkbmap_model_args( m_selectedModel ) );
163         emit prettyStatusChanged();
164     } );
165 
166     connect( m_keyboardLayoutsModel, &KeyboardLayoutModel::currentIndexChanged, [&]( int index ) {
167         m_selectedLayout = m_keyboardLayoutsModel->item( index ).first;
168         updateVariants( QPersistentModelIndex( m_keyboardLayoutsModel->index( index ) ) );
169         emit prettyStatusChanged();
170     } );
171 
172     connect( m_keyboardVariantsModel, &KeyboardVariantsModel::currentIndexChanged, this, &Config::xkbChanged );
173 
174     // If the user picks something explicitly -- not a consequence of
175     // a guess -- then move to UserSelected state and stay there.
176     connect( m_keyboardModelsModel, &KeyboardModelsModel::currentIndexChanged, this, &Config::selectionChange );
177     connect( m_keyboardLayoutsModel, &KeyboardLayoutModel::currentIndexChanged, this, &Config::selectionChange );
178     connect( m_keyboardVariantsModel, &KeyboardVariantsModel::currentIndexChanged, this, &Config::selectionChange );
179 
180     m_selectedModel = m_keyboardModelsModel->key( m_keyboardModelsModel->currentIndex() );
181     m_selectedLayout = m_keyboardLayoutsModel->item( m_keyboardLayoutsModel->currentIndex() ).first;
182     m_selectedVariant = m_keyboardVariantsModel->key( m_keyboardVariantsModel->currentIndex() );
183 }
184 
185 void
xkbChanged(int index)186 Config::xkbChanged( int index )
187 {
188     // Set Xorg keyboard layout + variant
189     m_selectedVariant = m_keyboardVariantsModel->key( index );
190 
191     if ( m_setxkbmapTimer.isActive() )
192     {
193         m_setxkbmapTimer.stop();
194         m_setxkbmapTimer.disconnect( this );
195     }
196 
197     connect( &m_setxkbmapTimer, &QTimer::timeout, this, &Config::xkbApply );
198 
199     m_setxkbmapTimer.start( QApplication::keyboardInputInterval() );
200     emit prettyStatusChanged();
201 }
202 
203 void
xkbApply()204 Config::xkbApply()
205 {
206     m_additionalLayoutInfo = getAdditionalLayoutInfo( m_selectedLayout );
207 
208     if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() )
209     {
210         m_additionalLayoutInfo.groupSwitcher = xkbmap_query_grp_option();
211 
212         if ( m_additionalLayoutInfo.groupSwitcher.isEmpty() )
213         {
214             m_additionalLayoutInfo.groupSwitcher = "grp:alt_shift_toggle";
215         }
216 
217         QProcess::execute( "setxkbmap",
218                            xkbmap_layout_args( { m_additionalLayoutInfo.additionalLayout, m_selectedLayout },
219                                                { m_additionalLayoutInfo.additionalVariant, m_selectedVariant },
220                                                m_additionalLayoutInfo.groupSwitcher ) );
221 
222 
223         cDebug() << "xkbmap selection changed to: " << m_selectedLayout << '-' << m_selectedVariant << "(added "
224                  << m_additionalLayoutInfo.additionalLayout << "-" << m_additionalLayoutInfo.additionalVariant
225                  << " since current layout is not ASCII-capable)";
226     }
227     else
228     {
229         QProcess::execute( "setxkbmap", xkbmap_layout_args( m_selectedLayout, m_selectedVariant ) );
230         cDebug() << "xkbmap selection changed to: " << m_selectedLayout << '-' << m_selectedVariant;
231     }
232     m_setxkbmapTimer.disconnect( this );
233 }
234 
235 
236 KeyboardModelsModel*
keyboardModels() const237 Config::keyboardModels() const
238 {
239     return m_keyboardModelsModel;
240 }
241 
242 KeyboardLayoutModel*
keyboardLayouts() const243 Config::keyboardLayouts() const
244 {
245     return m_keyboardLayoutsModel;
246 }
247 
248 KeyboardVariantsModel*
keyboardVariants() const249 Config::keyboardVariants() const
250 {
251     return m_keyboardVariantsModel;
252 }
253 
254 static QPersistentModelIndex
findLayout(const KeyboardLayoutModel * klm,const QString & currentLayout)255 findLayout( const KeyboardLayoutModel* klm, const QString& currentLayout )
256 {
257     QPersistentModelIndex currentLayoutItem;
258 
259     for ( int i = 0; i < klm->rowCount(); ++i )
260     {
261         QModelIndex idx = klm->index( i );
262         if ( idx.isValid() && idx.data( KeyboardLayoutModel::KeyboardLayoutKeyRole ).toString() == currentLayout )
263         {
264             currentLayoutItem = idx;
265         }
266     }
267 
268     return currentLayoutItem;
269 }
270 
271 void
detectCurrentKeyboardLayout()272 Config::detectCurrentKeyboardLayout()
273 {
274     if ( m_state != State::Initial )
275     {
276         return;
277     }
278     cScopedAssignment returnToIntial( &m_state, State::Initial );
279     m_state = State::Guessing;
280 
281     //### Detect current keyboard layout and variant
282     QString currentLayout;
283     QString currentVariant;
284     QProcess process;
285     process.start( "setxkbmap", QStringList() << "-print" );
286 
287     if ( process.waitForFinished() )
288     {
289         const QStringList list = QString( process.readAll() ).split( "\n", SplitSkipEmptyParts );
290 
291         // A typical line looks like
292         //      xkb_symbols   { include "pc+latin+ru:2+inet(evdev)+group(alt_shift_toggle)+ctrl(swapcaps)"       };
293         for ( const auto& line : list )
294         {
295             if ( !line.trimmed().startsWith( "xkb_symbols" ) )
296             {
297                 continue;
298             }
299 
300             int firstQuote = line.indexOf( '"' );
301             int lastQuote = line.lastIndexOf( '"' );
302 
303             if ( firstQuote < 0 || lastQuote < 0 || lastQuote <= firstQuote )
304             {
305                 continue;
306             }
307 
308             QStringList split = line.mid( firstQuote + 1, lastQuote - firstQuote ).split( "+", SplitSkipEmptyParts );
309             cDebug() << split;
310             if ( split.size() >= 2 )
311             {
312                 currentLayout = split.at( 1 );
313 
314                 if ( currentLayout.contains( "(" ) )
315                 {
316                     int parenthesisIndex = currentLayout.indexOf( "(" );
317                     currentVariant = currentLayout.mid( parenthesisIndex + 1 ).trimmed();
318                     currentVariant.chop( 1 );
319                     currentLayout = currentLayout.mid( 0, parenthesisIndex ).trimmed();
320                 }
321 
322                 break;
323             }
324         }
325     }
326 
327     //### Layouts and Variants
328     QPersistentModelIndex currentLayoutItem = findLayout( m_keyboardLayoutsModel, currentLayout );
329     if ( !currentLayoutItem.isValid() && ( ( currentLayout == "latin" ) || ( currentLayout == "pc" ) ) )
330     {
331         currentLayout = "us";
332         currentLayoutItem = findLayout( m_keyboardLayoutsModel, currentLayout );
333     }
334 
335     // Set current layout and variant
336     if ( currentLayoutItem.isValid() )
337     {
338         m_keyboardLayoutsModel->setCurrentIndex( currentLayoutItem.row() );
339         updateVariants( currentLayoutItem, currentVariant );
340     }
341 
342     // Default to the first available layout if none was set
343     // Do this after unblocking signals so we get the default variant handling.
344     if ( !currentLayoutItem.isValid() && m_keyboardLayoutsModel->rowCount() > 0 )
345     {
346         m_keyboardLayoutsModel->setCurrentIndex( m_keyboardLayoutsModel->index( 0 ).row() );
347     }
348 }
349 
350 QString
prettyStatus() const351 Config::prettyStatus() const
352 {
353     QString status;
354     status += tr( "Set keyboard model to %1.<br/>" )
355                   .arg( m_keyboardModelsModel->label( m_keyboardModelsModel->currentIndex() ) );
356 
357     QString layout = m_keyboardLayoutsModel->item( m_keyboardLayoutsModel->currentIndex() ).second.description;
358     QString variant = m_keyboardVariantsModel->currentIndex() >= 0
359         ? m_keyboardVariantsModel->label( m_keyboardVariantsModel->currentIndex() )
360         : QString( "<default>" );
361     status += tr( "Set keyboard layout to %1/%2." ).arg( layout, variant );
362 
363     return status;
364 }
365 
366 Calamares::JobList
createJobs()367 Config::createJobs()
368 {
369     QList< Calamares::job_ptr > list;
370 
371     Calamares::Job* j = new SetKeyboardLayoutJob( m_selectedModel,
372                                                   m_selectedLayout,
373                                                   m_selectedVariant,
374                                                   m_additionalLayoutInfo,
375                                                   m_xOrgConfFileName,
376                                                   m_convertedKeymapPath,
377                                                   m_writeEtcDefaultKeyboard );
378     list.append( Calamares::job_ptr( j ) );
379 
380     return list;
381 }
382 
383 static void
guessLayout(const QStringList & langParts,KeyboardLayoutModel * layouts,KeyboardVariantsModel * variants)384 guessLayout( const QStringList& langParts, KeyboardLayoutModel* layouts, KeyboardVariantsModel* variants )
385 {
386     bool foundCountryPart = false;
387     for ( auto countryPart = langParts.rbegin(); !foundCountryPart && countryPart != langParts.rend(); ++countryPart )
388     {
389         cDebug() << Logger::SubEntry << "looking for locale part" << *countryPart;
390         for ( int i = 0; i < layouts->rowCount(); ++i )
391         {
392             QModelIndex idx = layouts->index( i );
393             QString name
394                 = idx.isValid() ? idx.data( KeyboardLayoutModel::KeyboardLayoutKeyRole ).toString() : QString();
395             if ( idx.isValid() && ( name.compare( *countryPart, Qt::CaseInsensitive ) == 0 ) )
396             {
397                 cDebug() << Logger::SubEntry << "matched" << name;
398                 layouts->setCurrentIndex( i );
399                 foundCountryPart = true;
400                 break;
401             }
402         }
403         if ( foundCountryPart )
404         {
405             ++countryPart;
406             if ( countryPart != langParts.rend() )
407             {
408                 cDebug() << "Next level:" << *countryPart;
409                 for ( int variantnumber = 0; variantnumber < variants->rowCount(); ++variantnumber )
410                 {
411                     if ( variants->key( variantnumber ).compare( *countryPart, Qt::CaseInsensitive ) == 0 )
412                     {
413                         variants->setCurrentIndex( variantnumber );
414                         cDebug() << Logger::SubEntry << "matched variant" << *countryPart << ' '
415                                  << variants->key( variantnumber );
416                     }
417                 }
418             }
419         }
420     }
421 }
422 
423 void
guessLocaleKeyboardLayout()424 Config::guessLocaleKeyboardLayout()
425 {
426     if ( m_state != State::Initial )
427     {
428         return;
429     }
430     cScopedAssignment returnToIntial( &m_state, State::Initial );
431     m_state = State::Guessing;
432 
433     /* Guessing a keyboard layout based on the locale means
434      * mapping between language identifiers in <lang>_<country>
435      * format to keyboard mappings, which are <country>_<layout>
436      * format; in addition, some countries have multiple languages,
437      * so fr_BE and nl_BE want different layouts (both Belgian)
438      * and sometimes the language-country name doesn't match the
439      * keyboard-country name at all (e.g. Ellas vs. Greek).
440      *
441      * This is a table of language-to-keyboard mappings. The
442      * language identifier is the key, while the value is
443      * a string that is used instead of the real language
444      * identifier in guessing -- so it should be something
445      * like <layout>_<country>.
446      */
447     static constexpr char arabic[] = "ara";
448     static const auto specialCaseMap = QMap< std::string, std::string >( {
449         /* Most Arab countries map to Arabic keyboard (Default) */
450         { "ar_AE", arabic },
451         { "ar_BH", arabic },
452         { "ar_DZ", arabic },
453         { "ar_EG", arabic },
454         { "ar_IN", arabic },
455         { "ar_IQ", arabic },
456         { "ar_JO", arabic },
457         { "ar_KW", arabic },
458         { "ar_LB", arabic },
459         { "ar_LY", arabic },
460         /* Not Morocco: use layout ma */
461         { "ar_OM", arabic },
462         { "ar_QA", arabic },
463         { "ar_SA", arabic },
464         { "ar_SD", arabic },
465         { "ar_SS", arabic },
466         /* Not Syria: use layout sy */
467         { "ar_TN", arabic },
468         { "ar_YE", arabic },
469         { "ca_ES", "cat_ES" }, /* Catalan */
470         { "en_CA", "us" }, /* Canadian English */
471         { "el_CY", "gr" }, /* Greek in Cyprus */
472         { "el_GR", "gr" }, /* Greek in Greece */
473         { "ig_NG", "igbo_NG" }, /* Igbo in Nigeria */
474         { "ha_NG", "hausa_NG" }, /* Hausa */
475         { "en_IN", "us" }, /* India, US English keyboards are common in India */
476     } );
477 
478     // Try to preselect a layout, depending on language and locale
479     Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
480     QString lang = gs->value( "localeConf" ).toMap().value( "LANG" ).toString();
481 
482     cDebug() << "Got locale language" << lang;
483     if ( !lang.isEmpty() )
484     {
485         // Chop off .codeset and @modifier
486         int index = lang.indexOf( '.' );
487         if ( index >= 0 )
488         {
489             lang.truncate( index );
490         }
491         index = lang.indexOf( '@' );
492         if ( index >= 0 )
493         {
494             lang.truncate( index );
495         }
496 
497         lang.replace( '-', '_' );  // Normalize separators
498     }
499     if ( !lang.isEmpty() )
500     {
501         std::string lang_s = lang.toStdString();
502         if ( specialCaseMap.contains( lang_s ) )
503         {
504             QString newLang = QString::fromStdString( specialCaseMap.value( lang_s ) );
505             cDebug() << Logger::SubEntry << "special case language" << lang << "becomes" << newLang;
506             lang = newLang;
507         }
508     }
509     if ( !lang.isEmpty() )
510     {
511         guessLayout( lang.split( '_', SplitSkipEmptyParts ), m_keyboardLayoutsModel, m_keyboardVariantsModel );
512     }
513 }
514 
515 void
finalize()516 Config::finalize()
517 {
518     Calamares::GlobalStorage* gs = Calamares::JobQueue::instance()->globalStorage();
519     if ( !m_selectedLayout.isEmpty() )
520     {
521         gs->insert( "keyboardLayout", m_selectedLayout );
522         gs->insert( "keyboardVariant", m_selectedVariant );  //empty means default variant
523 
524         if ( !m_additionalLayoutInfo.additionalLayout.isEmpty() )
525         {
526             gs->insert( "keyboardAdditionalLayout", m_additionalLayoutInfo.additionalLayout );
527             gs->insert( "keyboardAdditionalLayout", m_additionalLayoutInfo.additionalVariant );
528             gs->insert( "keyboardVConsoleKeymap", m_additionalLayoutInfo.vconsoleKeymap );
529         }
530     }
531 
532     //FIXME: also store keyboard model for something?
533 }
534 
535 void
updateVariants(const QPersistentModelIndex & currentItem,QString currentVariant)536 Config::updateVariants( const QPersistentModelIndex& currentItem, QString currentVariant )
537 {
538     const auto variants = m_keyboardLayoutsModel->item( currentItem.row() ).second.variants;
539     m_keyboardVariantsModel->setVariants( variants );
540 
541     auto index = -1;
542     for ( const auto& key : variants.keys() )
543     {
544         index++;
545         if ( variants[ key ] == currentVariant )
546         {
547             m_keyboardVariantsModel->setCurrentIndex( index );
548             return;
549         }
550     }
551 }
552 
553 void
setConfigurationMap(const QVariantMap & configurationMap)554 Config::setConfigurationMap( const QVariantMap& configurationMap )
555 {
556     using namespace CalamaresUtils;
557 
558     const auto xorgConfDefault = QStringLiteral( "00-keyboard.conf" );
559     m_xOrgConfFileName = getString( configurationMap, "xOrgConfFileName", xorgConfDefault );
560     if ( m_xOrgConfFileName.isEmpty() )
561     {
562         m_xOrgConfFileName = xorgConfDefault;
563     }
564     m_convertedKeymapPath = getString( configurationMap, "convertedKeymapPath" );
565     m_writeEtcDefaultKeyboard = getBool( configurationMap, "writeEtcDefaultKeyboard", true );
566 }
567 
568 void
retranslate()569 Config::retranslate()
570 {
571     retranslateKeyboardModels();
572 }
573 
574 void
selectionChange()575 Config::selectionChange()
576 {
577     if ( m_state == State::Initial )
578     {
579         m_state = State::UserSelected;
580     }
581 }
582